diff --git a/examples/async-file.endor b/examples/async-file.endor index 4ba1cd0..89a1dec 100644 --- a/examples/async-file.endor +++ b/examples/async-file.endor @@ -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)) diff --git a/examples/async-network.endor b/examples/async-network.endor index 64dcda2..1aa2acb 100644 --- a/examples/async-network.endor +++ b/examples/async-network.endor @@ -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)) diff --git a/examples/await-file.endor b/examples/await-file.endor index 50858af..c476da1 100644 --- a/examples/await-file.endor +++ b/examples/await-file.endor @@ -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)) diff --git a/examples/box-layout.endor b/examples/box-layout.endor index c49dd59..4637ab5 100644 --- a/examples/box-layout.endor +++ b/examples/box-layout.endor @@ -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) diff --git a/examples/campus-lms.endor b/examples/campus-lms.endor index bc72c07..520ef5f 100644 --- a/examples/campus-lms.endor +++ b/examples/campus-lms.endor @@ -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。 diff --git a/examples/classes.endor b/examples/classes.endor index 6da88c5..e1be991 100644 --- a/examples/classes.endor +++ b/examples/classes.endor @@ -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) diff --git a/examples/constructor-contracts.endor b/examples/constructor-contracts.endor index aab8dac..299bd23 100644 --- a/examples/constructor-contracts.endor +++ b/examples/constructor-contracts.endor @@ -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 = "构造器契约" diff --git a/examples/cooperative-tasks.endor b/examples/cooperative-tasks.endor index 665cab5..b3690f0 100644 --- a/examples/cooperative-tasks.endor +++ b/examples/cooperative-tasks.endor @@ -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)) diff --git a/examples/events.endor b/examples/events.endor index 28562e6..254e5b5 100644 --- a/examples/events.endor +++ b/examples/events.endor @@ -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) diff --git a/examples/exit-lifecycle.endor b/examples/exit-lifecycle.endor index a7f153e..56a488b 100644 --- a/examples/exit-lifecycle.endor +++ b/examples/exit-lifecycle.endor @@ -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) diff --git a/examples/field-contracts.endor b/examples/field-contracts.endor index a6c683d..303993e 100644 --- a/examples/field-contracts.endor +++ b/examples/field-contracts.endor @@ -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 和源码写入同一个受检查的字段"))) diff --git a/examples/flex-constraints.endor b/examples/flex-constraints.endor index 669f7b2..558ed30 100644 --- a/examples/flex-constraints.endor +++ b/examples/flex-constraints.endor @@ -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。"))) diff --git a/examples/flex.endor b/examples/flex.endor index cd475e9..887377f 100644 --- a/examples/flex.endor +++ b/examples/flex.endor @@ -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)) diff --git a/examples/fonts.endor b/examples/fonts.endor index 808e6fb..18a9ecc 100644 --- a/examples/fonts.endor +++ b/examples/fonts.endor @@ -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)) diff --git a/examples/function-types.endor b/examples/function-types.endor index f7a2c3d..5927ee1 100644 --- a/examples/function-types.endor +++ b/examples/function-types.endor @@ -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 = "函数类型检查" diff --git a/examples/gc-lifecycle.endor b/examples/gc-lifecycle.endor index b4c9e1e..06e439f 100644 --- a/examples/gc-lifecycle.endor +++ b/examples/gc-lifecycle.endor @@ -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)) diff --git a/examples/global-dependencies.endor b/examples/global-dependencies.endor index 4e4091d..9df1c8d 100644 --- a/examples/global-dependencies.endor +++ b/examples/global-dependencies.endor @@ -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)) diff --git a/examples/hello.endor b/examples/hello.endor index 11fcf45..d785d51 100644 --- a/examples/hello.endor +++ b/examples/hello.endor @@ -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 中继续按托管引用拥有这些子对象。 } diff --git a/examples/illustrated-manuscript.endor b/examples/illustrated-manuscript.endor index 2ba0c37..0077dd6 100644 --- a/examples/illustrated-manuscript.endor +++ b/examples/illustrated-manuscript.endor @@ -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) diff --git a/examples/layout-cache.endor b/examples/layout-cache.endor index 36f17b7..b02b022 100644 --- a/examples/layout-cache.endor +++ b/examples/layout-cache.endor @@ -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) diff --git a/examples/layout-negotiation.endor b/examples/layout-negotiation.endor index ac96927..bc4e234 100644 --- a/examples/layout-negotiation.endor +++ b/examples/layout-negotiation.endor @@ -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)) diff --git a/examples/main.endor b/examples/main.endor index 3859ea5..5728e55 100644 --- a/examples/main.endor +++ b/examples/main.endor @@ -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) diff --git a/examples/managed-types.endor b/examples/managed-types.endor index 9e2670b..95b4250 100644 --- a/examples/managed-types.endor +++ b/examples/managed-types.endor @@ -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))) diff --git a/examples/method-contracts.endor b/examples/method-contracts.endor index 19b7dff..54af72c 100644 --- a/examples/method-contracts.endor +++ b/examples/method-contracts.endor @@ -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")) diff --git a/examples/mount-lifecycle.endor b/examples/mount-lifecycle.endor index 40f19a7..cc3e183 100644 --- a/examples/mount-lifecycle.endor +++ b/examples/mount-lifecycle.endor @@ -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) diff --git a/examples/native-signatures.endor b/examples/native-signatures.endor index 71b30ce..3f00e9f 100644 --- a/examples/native-signatures.endor +++ b/examples/native-signatures.endor @@ -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) diff --git a/examples/native-subclasses.endor b/examples/native-subclasses.endor index 8b8735c..3ac84e3 100644 --- a/examples/native-subclasses.endor +++ b/examples/native-subclasses.endor @@ -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"))) diff --git a/examples/owned-tasks.endor b/examples/owned-tasks.endor index 1ec05de..efb3187 100644 --- a/examples/owned-tasks.endor +++ b/examples/owned-tasks.endor @@ -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") diff --git a/examples/page.endor b/examples/page.endor index 82fb1c5..421fa05 100644 --- a/examples/page.endor +++ b/examples/page.endor @@ -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 已呈现像素。 diff --git a/examples/presentable.endor b/examples/presentable.endor index ef25a07..0619534 100644 --- a/examples/presentable.endor +++ b/examples/presentable.endor @@ -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" diff --git a/examples/property-bindings.endor b/examples/property-bindings.endor index 384c093..ead98a2 100644 --- a/examples/property-bindings.endor +++ b/examples/property-bindings.endor @@ -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() diff --git a/examples/scene-viewports.endor b/examples/scene-viewports.endor index 0897bce..535f689 100644 --- a/examples/scene-viewports.endor +++ b/examples/scene-viewports.endor @@ -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)) diff --git a/examples/scene3d.endor b/examples/scene3d.endor index f8fcea6..1fac240 100644 --- a/examples/scene3d.endor +++ b/examples/scene3d.endor @@ -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 diff --git a/examples/scroll-axis.endor b/examples/scroll-axis.endor index 355c238..79b6b04 100644 --- a/examples/scroll-axis.endor +++ b/examples/scroll-axis.endor @@ -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))) diff --git a/examples/scroll.endor b/examples/scroll.endor index f6aad2c..97cc881 100644 --- a/examples/scroll.endor +++ b/examples/scroll.endor @@ -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") diff --git a/examples/source-hit.endor b/examples/source-hit.endor index 8113a45..57774ef 100644 --- a/examples/source-hit.endor +++ b/examples/source-hit.endor @@ -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) diff --git a/examples/source-layout.endor b/examples/source-layout.endor index c0880cd..a9b11fb 100644 --- a/examples/source-layout.endor +++ b/examples/source-layout.endor @@ -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) diff --git a/examples/source-paint.endor b/examples/source-paint.endor index 1a53c3a..ca107a0 100644 --- a/examples/source-paint.endor +++ b/examples/source-paint.endor @@ -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) diff --git a/examples/student-grades.endor b/examples/student-grades.endor index b1fd5b0..b43d40f 100644 --- a/examples/student-grades.endor +++ b/examples/student-grades.endor @@ -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), diff --git a/examples/tab-placement.endor b/examples/tab-placement.endor index c3a42dc..c4c822c 100644 --- a/examples/tab-placement.endor +++ b/examples/tab-placement.endor @@ -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() diff --git a/examples/tabs.endor b/examples/tabs.endor index afe823a..83dcd82 100644 --- a/examples/tabs.endor +++ b/examples/tabs.endor @@ -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") diff --git a/examples/visual-protocols.endor b/examples/visual-protocols.endor index afe3dd3..218ea03 100644 --- a/examples/visual-protocols.endor +++ b/examples/visual-protocols.endor @@ -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() diff --git a/examples/window.endor b/examples/window.endor index f78dc63..3c8a1d0 100644 --- a/examples/window.endor +++ b/examples/window.endor @@ -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) diff --git a/examples/windows.endor b/examples/windows.endor index b608051..a0c7964 100644 --- a/examples/windows.endor +++ b/examples/windows.endor @@ -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") diff --git a/examples/workspace.endor b/examples/workspace.endor index 78314ec..72a240a 100644 --- a/examples/workspace.endor +++ b/examples/workspace.endor @@ -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, diff --git a/examples/world-ui.endor b/examples/world-ui.endor index 2fdbd2d..dd75d67 100644 --- a/examples/world-ui.endor +++ b/examples/world-ui.endor @@ -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) diff --git a/examples/wrapped-text.endor b/examples/wrapped-text.endor index 3437f02..fbac9d0 100644 --- a/examples/wrapped-text.endor +++ b/examples/wrapped-text.endor @@ -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() diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 2273d27..4d32f16 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -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" < diff --git a/scripts/render-uml.mjs b/scripts/render-uml.mjs index 1a43b4a..7c18cb2 100644 --- a/scripts/render-uml.mjs +++ b/scripts/render-uml.mjs @@ -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 }); diff --git a/src/bridge/graphics.rs b/src/bridge/graphics.rs index 35ed907..d6f9c0d 100644 --- a/src/bridge/graphics.rs +++ b/src/bridge/graphics.rs @@ -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 { + // 不从 nil、字符串或数字猜测资源 ID,避免把源值误指到 VM 堆中任意对象。 if let Value::Object(o) = v { Ok(*o) } else { Err(invalid("expected Object")) } } +/// 图形几何参数调用,先用 Endor 数字转换再限制绝对值,最后降为渲染器使用的 f32。 fn number(v: &Value) -> Result { + // 原生签名没有逐参数类型声明,因此 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); #[derive(Debug)] +/// LoadFont 创建、Prepare 读取的字体族句柄,字体注册/塑形细节由 render::text 负责。 struct FontResource(TextFamily); #[derive(Debug)] +/// Prepare 生成、Flow 消费的字词/字母预排结果,昂贵的塑形可在多次绕排中复用。 struct TextResource { + // 图形流转中仅复制 Arc,不为每一行重复塑形和生成字形图片。 prepared: Arc, + // 每次 Flow 的行带高度,ranges 用它判断避让形状与这一行的相交范围。 line: f32, } #[derive(Debug)] +/// Circle/Rectangle/ImageExclusion 产生,Flow/ranges 读取的纯原生避让形状,不保存 VM 子对象引用。 struct Exclusion { + // 圆用圆心/半径描述;多边形资源保持 None,避免两种形状同时隐式叠加。 circle: Option<(Point, f32)>, + // 图片轮廓或矩形顶点,已经变换到绕排使用的世界/页面逻辑坐标。 polygon: Vec, + // 在多边形行带与横向阻塞范围外再扩展的避让距离。 padding: f32, } +// 四类资源复用 ObjectPayload 适配;没有托管引用字段,因此不需要额外 trace 子对象。 macro_rules! resource { ($t:ty,$name:literal) => { impl ObjectPayload for $t { + // VM 的 payload:: 通过 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, 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, VmError> { } Ok(data) } +/// decode_image 读取 PaintCommand 的数值属性时调用,缺字段/错误类型直接传播,不自行补缺省。 fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result { number(&s.get_attr(o, key)?) } +/// source_paint 的 image 命令分支及 Sprite/ImageExclusion 调用,验证托管命令并提取纯数据图片绘制命令。 pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result { + // 命令引用真实 ImageResource,不能只靠同名 width/height 属性冒充有效图片。 let resource = object(&s.get_attr(c, "image")?)?; + // 克隆像素 Arc,使发布结果拥有资源,不再依赖命令/资源 ObjectRef 的 VM 生命周期。 let image = s .payload::(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, desktop: ObjectRef, @@ -144,6 +186,7 @@ fn command( angle: f32, opacity: f32, ) -> Result { + // 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 { 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 { 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::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::(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::(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 { 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 { + // 第一参必须是 Prepare 返回的资源;只靠对象上的同名 prepared 属性不能替代原生 payload。 let resource = object(&args[0])?; + // 借 payload 取资源元数据,随后复制 Arc/行高以结束不可变借用,允许下面继续分配 VM 对象。 let text = cx .store() .payload::(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::(o, "Exclusion")) .collect::, _>>()?; + // 第一阶段只计算词/字母摆放,仍未生成任何 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, 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 { Some(endor_core::CallArity::exact(0)) } + /// VM 分派临时 callable 时验证 Mount,生成带局部 x/y 的 Event,再交事件模块执行捕获/目标/冒泡。 fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + // 再检查实时 payload,GUI 命中时的可交互标记不能保证当前对象仍挂载或仍订阅指针事件。 let data = cx .store() .payload::(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) } } diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index 610922e..518145c 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -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; diff --git a/src/bridge/ui_bridge.rs b/src/bridge/ui_bridge.rs index f849c4e..a3445c3 100644 --- a/src/bridge/ui_bridge.rs +++ b/src/bridge/ui_bridge.rs @@ -2,7 +2,20 @@ //! Session 保存托管对象的展示上下文;跨线程发布的是不可变 UiNode/Frame,而不是可以在窗口线程执行的 VM。 //! 刷新按 prepare → layout → paint → commit 推进。挂起工作必须恢复或显式取消;失败不能把未完成的几何和生命周期状态留作已发布结果。 //! -//! Tessera's native visual protocols over Aster's managed object graph. +//! 调用来源有两条:runtime::endor_host 通过 UiBridge 安装环境、推进刷新与清理; +//! Endor VM 则在源程序调用 ui 工厂、类型构造器或实例方法时,进入本模块的 NativeCallable 实现。 +//! Function/TypeMethod/NativeFactory 是语言调用适配,Session 保存跨调用的窗口展示状态; +//! 真正的排版/绘制交给 render,业务对象与 Mount 数据交给 model,均不在这里操作 OS 窗口。 +//! 本模块会在 worker 中访问 VM;Arc/Mutex 只用于共享原生会话,不授权 GUI 线程直接调用 VM。 +//! +//! 阅读调用链时应分清三个入口:bootstrap::install 创建类型和原生 callable; +//! calls 中实现的 Function::invoke 接收源程序的工厂/方法调用;endor_host 的 step_refresh 则推进展示刷新。 +//! 刷新先计算绑定,再用 prepare_frame 判断是否有工作,begin/resume_layout_frame 求几何, +//! begin/resume_paint_frame 求绘制命令,最终由 endor_host 汇总稳定的逐窗文档。 +//! commit_frame 是桥接内部的 Mount/几何提交,生命周期回调仍可令依赖失效; +//! 它不是向 GUI 发布成功的标志,也不意味着后来失败的整段源输入会回滚先前业务写入。 +//! 布局错误有两种收尾:source_layout 尚未完成时恢复准备检查点;进入 finish_layout_frame 后只释放检查点根。 +//! 后一种路径可能已提交几何或执行钩子,运行时须拒绝失败候选并修复 pending,不能把它解释成完整业务事务回滚。 mod bootstrap; mod calls; mod developer_pages; @@ -26,8 +39,11 @@ use crate::{ document::parse_color, }; +/// new_typed_block 的节点弱表上限,也用于容器工厂的变参数量约束,限制单会话宿主开销。 pub const MAX_UI_NODES: usize = 4096; +/// 新建 Page 时按存活对象检查的上限;输入解码也用它拒绝明显越界的页面索引。 pub const MAX_PAGES: usize = 64; +/// dimension 将语言数字转换成原生布局长度前检查的上界,避免异常尺寸流入后续绘图。 pub const MAX_DIMENSION: f64 = 1_000_000.0; // 编译器已知宿主全局类型/能力名,同时用于 install_globals;仅列名不代表绕过实际 VM 名义类型查找。 @@ -84,6 +100,7 @@ pub const TYPE_NAMES: &[&str] = &[ "DisplayList", "PaintCommand", ]; +/// install_globals 暴露的类型/能力查询函数名称;与 bootstrap 安装到 ui 的入口对应。 pub const QUERY_NAMES: &[&str] = &[ "isInstance", "isSubtype", @@ -97,28 +114,36 @@ pub use tree::{UiKind, UiNode}; #[derive(Debug)] /// 整个 VM 的桥接会话。节点索引跨窗口共享,presentations 按窗口隔离派生状态;current 决定方法当前访问哪份展示上下文。 struct Session { - // Weak visual-object registry belongs to the VM, not a particular window. + // new_typed_block 登记、reclaim 清理的弱引用表;不因宿主记账让已不可达的 Block 保活。 nodes: Vec, + // 每个窗口独立拥有 Mount、布局缓存和待发布画面;切换窗口只改变 current,不移动业务对象。 + // Presentation 的 desktop 各不相同,但 window::create 共享 master 的 types 字典,跨窗仍使用同一名义类型身份。 presentations: std::collections::BTreeMap, + // Deref/DerefMut 选择哪个 Presentation;调用前必须保证对应键仍在 presentations 中。 current: u64, + // 主 desktop 的托管身份;create_window 用它建立副窗口与主资源域的关系。 master: ObjectRef, + // 已分配窗口身份的高水位,关闭窗口后也不降低,避免旧事件误指向新窗口。 next_window: u64, // 跨窗口共用 Mount 高水位,避免某窗口取消/重建后与其他窗口已暴露出现 ID 重合。 next_mount: u64, // 正常关闭副窗口后暂存尚待发布的 Close 等命令;Presentation 已移除也不能丢掉最后的原生退役请求。 retired_commands: Vec, + // 以下是供 stats 读取的累计工作量,并非控制下一次刷新的剩余预算。 refresh_yields: usize, refresh_restarts: usize, layout_cancels: usize, paint_cancels: usize, paint_slices: usize, binding_evaluations: usize, + // shutdown 设置;create_window 等入口读取,阻止卸载回调在退出期间重新建立窗口。 exiting: bool, } impl Session { - /// 收集所有窗口的私有 desktop 身份,用于排除宿主记账读取造成的无关布局失效。 + /// 由刷新/依赖收集流程调用,返回所有窗口的宿主 desktop,排除宿主记账读取造成的无关失效。 fn host_desktops(&self) -> std::collections::HashSet { + // 只取值里的 desktop 而非窗口编号;依赖跟踪按托管 ObjectRef 判断读取归属。 self.presentations .values() .map(|view| view.desktop) @@ -128,7 +153,7 @@ impl Session { impl std::ops::Deref for Session { type Target = crate::presentation::Presentation; - /// 将包装类型的只读访问转交给内部当前上下文。 + /// Rust 在 state.desktop 等字段访问时隐式调用,借用 current 对应窗口的 Presentation。 fn deref(&self) -> &Self::Target { // Session 解引用始终指向 current Presentation;调用源回调前切换窗口,结束后恢复主窗口,不能把它当全局唯一展示。 self.presentations @@ -137,13 +162,15 @@ impl std::ops::Deref for Session { } } impl std::ops::DerefMut for Session { - /// 将包装类型的可变访问转交给内部当前上下文。 + /// Rust 在修改当前展示字段时隐式调用;调用方须先选定仍存在的窗口,不能跨窗口复用缓存。 fn deref_mut(&mut self) -> &mut Self::Target { + // 与只读 Deref 使用相同键;缺项表示桥接选择/退役流程破坏了内部不变量,因此直接 expect。 self.presentations .get_mut(&self.current) .expect("selected presentation") } } +// 各原生函数 payload 共享同一个会话;Mutex 保护宿主字段,不是 VM 托管堆的并发访问许可。 type Shared = Arc>; #[derive(Debug, Clone, Copy)] @@ -215,39 +242,58 @@ enum Operation { } impl Operation { - /// 返回该操作显式参数数量范围;这里不计接收者、Type 首参和源子类字段。 + /// 被 signatures、Function/TypeMethod/NativeFactory 的 arity 查询调用,提供统一显式参数范围。 + /// 这里不计接收者、Type 首参和源子类字段;各适配器负责给范围加上自己的调用前缀。 fn arity(self) -> endor_core::CallArity { use Operation::*; // 此 arity 只含显式参数;TypeMethod 另加 receiver,NativeFactory 另加 Type 和子类字段外壳。 let (minimum, maximum) = match self { + // 类型/能力关系与属性绑定都需要两个显式操作数,不接收可选尾参。 IsInstance | IsSubtype | Bind | Implement | Implements | Supports => (2, 2), + // 这些查询所需上下文来自 Session 或绑定 owner,源调用不再传额外实参。 Stats | Viewport | Window | PageWindow | Mounts => (0, 0), + // 状态工厂接初值,绑定 set 接新值;接收者已存于 Function.owner。 State | StateSet => (1, 1), + // increment 的目标已绑定,递增步长由实现固定,不接用户步长参数。 Increment => (0, 0), + // 对象和属性名必填,字号/颜色可选;类型约束另见 operation_signature。 TextOf => (2, 4), + // 滚动块、二维与三维视口都要求两个尺寸加一个内容/场景对象。 Scroll => (3, 3), CanvasViewport => (3, 3), Viewport3D => (3, 3), + // 3D 族从共用构造元数据读取,避免这里维护另一份容易漂移的参数数量表。 SceneObject(kind) => kind.arity(), + // 控制更新接对象与手势,变换接三个分量;这些操作不使用任意长度变参。 CameraControl => (2, 2), Translate3D | Rotate3D => (3, 3), + // 相机三个参数都可省略;场景孩子则可为空,仍受全局节点数量预算限制。 Camera2D => (0, 3), Scene2D => (0, MAX_UI_NODES), + // 文本内容/矩形宽高必填,其余表现参数可选;不接容器子项尾参。 Text => (1, 3), Rect => (2, 4), + // 单值操作复用同一数量规则,具体参数的类型和语义仍由各自签名与调用分支决定。 MoveTab | MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove | Render | Navigate | OnClick | Present => (1, 1), + // 容器两个布局选项在前,后面最多 MAX_UI_NODES 个孩子;+2 不应错计入孩子预算。 Row | Column | Overlay => (2, MAX_UI_NODES + 2), + // 按钮接初始文本,容器插入/替换/移动接槽位或位置与另一个值。 Button => (1, 1), Insert | Replace | Move => (2, 2), + // 页面/窗口状态操作和查询使用绑定 owner 或当前会话,无显式参数。 Page | Unmount | Close | Activate | Back | Forward | Children | NewWindow | CloseWindow | Detach => (0, 0), + // open 可指定 Window;click 可指定 Mount;invalidate 可指定类别,其余走缺省语义。 Open => (0, 1), Click | Invalidate => (0, 1), + // on 必填事件名和回调,可选捕获阶段;dispatch 必填事件,可选具体出现位置。 On => (2, 3), Dispatch => (1, 2), + // 生命周期/事件钩子由宿主传一个协议上下文,用户覆盖必须遵守同一数量约定。 OnEvent | OnMount | OnUnmount => (1, 1), }; + // 同时保留必填下界和可选/变参上界,供 VM 在进入实际操作前拒绝参数数量错误。 endor_core::CallArity::range(minimum, maximum) } } @@ -255,9 +301,13 @@ impl Operation { #[derive(Debug)] /// 将一个宿主 Operation 及会话环境封装成 VM 可调用对象。 struct Function { + // bootstrap 先留空、类型齐全后补入;方法 wrapper 已校验签名时可用 None 转交同一业务分派。 signature: Option, + // calls::invoke 的分支选择值;名称只用于查询与诊断,不能替代这个明确的操作标签。 operation: Operation, + // 自由工厂为 None,绑定方法保存 receiver;调用参数切片中不再重复包含该 receiver。 owner: Option, + // 回调需要访问所属窗口的展示状态;托管强边另外由 ContextNativeFunction 的 captures 声明。 session: Shared, // 原生构造的实际源子类身份;自由 ui 工厂为 None,类型构造器显式传入 Some。 requested_type: Option, @@ -268,37 +318,45 @@ struct Function { #[derive(Debug)] /// 类型查询/能力方法的原生包装,保存调用所需的声明与会话。 struct TypeMethod { + // 包含 receiver 的完整签名,由 VM 在 native 调用边界检查。 signature: CallSignature, + // 验证接收者后转交 Function 分派的操作,不为每个 Block 复制一个函数实现。 operation: Operation, + // 声明此方法的名义类型;is_instance 允许真正的源子类,但拒绝无关对象。 expected: ObjectRef, + // 方法安装时捕获的桥接会话,决定 owner 操作使用的展示环境。 session: Shared, } -/// 判断方法是否仍为宿主提供的原生事件实现,区分它与用户源覆盖。 +/// 由 snapshot 的事件能力提取调用,判断 onEvent 是否仍为默认原生实现,区分源覆盖。 pub(crate) fn is_intrinsic_event( store: &endor_core::ObjectStore, function: ObjectRef, ) -> Result { + // 先检查 payload 是否为 ContextNativeFunction;源函数或其他 callable 不可能是默认 TypeMethod。 let Some(function) = store .object(function)? .payload() .as_any() .downcast_ref::() else { + // 类型不同即非内建实现;对象读取失败则由前面的 ? 返回错误,不能当作正常 false。 return Ok(false); }; + // 原生函数也可能是其他 UI 操作;只有 TypeMethod 包装的 OnEvent 才符合默认事件入口。 Ok(function .implementation::() .is_some_and(|method| matches!(method.operation, Operation::OnEvent))) } impl NativeCallable for TypeMethod { - /// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。 + /// VM 的 callable 签名查询入口,返回 bootstrap 按声明类型建立的完整 receiver/参数/返回契约。 fn signature(&self) -> Option { Some(self.signature.clone()) } /// 在 Operation 的显式参数范围两端各加一,计入 VM 传入的隐式接收者。 fn arity(&self) -> Option { + // operation 的范围不含 receiver;这里构造的是 VM 实际传入 args 的范围。 let arity = self.operation.arity(); Some(endor_core::CallArity::range( arity.required + 1, @@ -306,13 +364,16 @@ impl NativeCallable for TypeMethod { )) } - /// 校验首参是预期名义类型实例,再剥离接收者,复用 Function 分派执行同一 Operation。 + /// VM 分派实例方法后调用;先校验 args[0],再把 receiver 交给 Function.owner 执行业务操作。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { // 名义方法统一存在类型上,VM 把 receiver 放首参;校验后剥离 receiver 再交 Function 的显式参数分派。 + // 不假设源语法一定来自 obj.method:显式调用同样必须提供有效的对象首参。 let owner = object(args.first())?; if !cx.store().is_instance(owner, self.expected)? { + // 拒绝错误接收者,不进入分派器,防止按某种 Block 布局读取无关 payload。 return Err(invalid("incorrect receiver type for visual method")); } + // 使用临时适配器共享分派;原 wrapper 已提供签名,转交时无需重复携带完整 receiver 签名。 Function { signature: None, operation: self.operation, @@ -329,33 +390,41 @@ impl NativeCallable for TypeMethod { #[derive(Debug)] /// 为源代码调用原生类型构造器提供受检查的分配入口。 struct NativeFactory { + // 对 VM 可见的宽外壳:Type 首参 + 原生参数 + 源子类字段。 signature: Option, + // 仅描述原生选项;invoke 切分字段后再检查,不能用外壳的 Any 变参绕过类型契约。 options: Option, + // 决定实际原生分配器,Function 将据此创建 Block、相机或三维对象。 operation: Operation, + // 与该类型注册域一致的展示会话,跨 VM 的同名 Type 不能混用。 session: Shared, } impl NativeCallable for NativeFactory { - /// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。 + /// VM 在类型构造的原生调用边界查询外壳签名;具体原生参数的窄签名由 invoke 进一步检查。 fn signature(&self) -> Option { self.signature.clone() } /// 要求原生必填参数再加一个请求 Type;上界暂开放,子类字段与原生选项在 invoke 内分别校验。 fn arity(&self) -> Option { + // 原生参数有下界,但源子类可增加字段;不能把 Operation 的上界直接当总实参数量上界。 Some(endor_core::CallArity::range( self.operation.arity().required + 1, usize::MAX, )) } - /// 把请求类型的字段后缀与原生选项前缀分开验证,分配真实原生子类并初始化字段,避免非法字段先触发构造副作用。 + /// VM 调用 NativeTyped 构造器时进入;切开原生选项和子类字段,先验证,再分配并初始化结果。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // VM 类型分派把实际请求的子类放在首位,不能退回工厂所属的基类而丢失源子类身份。 let requested = object(args.first())?; + // 复制字段声明以结束对 store 的借用,后续调用 Function 和写属性需要可变访问 cx。 let fields = cx .store() .payload::(requested, "Type")? .fields() .to_vec(); + // 切掉 Type 首参后的实参由两段组成:原生构造选项在前,按声明顺序的子类字段在后。 let values = &args[1..]; // 构造实参末尾是类型声明的继承/自有字段,前缀才是原生构造选项;checked_sub 防止缺字段造成下溢。 let native_count = values @@ -373,6 +442,7 @@ impl NativeCallable for NativeFactory { "native constructor options", &values[..native_count], )?; + // requested_type 通知原生分配器附上真实子类 Type;owner 为空,因为这是构造而非实例方法。 let result = Function { signature: None, operation: self.operation, @@ -381,42 +451,50 @@ impl NativeCallable for NativeFactory { requested_type: Some(requested), } .invoke(cx, &values[..native_count])?; + // 原生工厂必须同步返回对象;不能把挂起调用或普通标量误当已分配实例继续写字段。 let NativeResult::Return(Value::Object(instance)) = result else { + // 契约错误直接交回 VM,避免把不完整结果作为源程序的构造返回值。 return Err(invalid("native constructor did not return an instance")); }; // 分配器必须返回精确 requested 类型,不能只返回基类实例;之后才初始化子类字段。 if cx.store().type_of(instance)? != Some(requested) { + // 精确身份不符说明分配器漏传/忽略了 requested_type,接受基类会破坏源子类语义。 return Err(invalid( "native constructor returned an incorrect nominal type", )); } for (field, value) in fields.iter().zip(&values[native_count..]) { + // 前面已验证数量与类型,此处按同一声明顺序写入;set_attr 负责托管引用与变更记录。 cx.set_attr(instance, &field.name, value.clone())?; } + // 将完成字段初始化的原生子类交回 VM,后续源代码才可使用它。 Ok(NativeResult::Return(Value::Object(instance))) } } -/// 统一构造本模块的参数错误,让调用方停止处理无效输入。 +/// 各解码器和 NativeCallable 分支调用的参数错误构造器,交由 VM 保留调用诊断上下文。 fn invalid(message: &'static str) -> VmError { VmError::InvalidArgument(message) } -/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。 +/// 供桥接实参/属性解码调用:只接受对象值,不把 nil、标量或缺失实参转换成虚构的对象身份。 fn object(value: Option<&Value>) -> Result { + // Option 同时覆盖缺失参数和实际值;返回 ObjectRef 仅复制句柄,不增加托管拥有根。 match value { Some(Value::Object(value)) => Ok(*value), _ => Err(invalid("expected an object")), } } -/// 把当前源值或文本选择解码成此调用需要的文本表示。 +/// 被工厂、方法及 color 参数解码调用,只接收语言字符串值,不隐式调用对象的 repr。 fn text(value: Option<&Value>) -> Result { + // 拷贝字符串是为了结束源 Value 的借用,让调用者可以把文本移入新的 Block payload。 match value { Some(Value::Text(value)) => Ok(value.clone()), _ => Err(invalid("expected text")), } } -/// 读取整数参数,拒绝非整数或无法在目标范围表示的值。 +/// 被 calls 中的容器索引/槽位等参数解析调用,要求可表示为 i64 的非负整数,再转为 u64。 fn integer(value: Option<&Value>) -> Result { + // 先走数字自身的精确转换,再做有符号到无符号检查;不能用浮点截断接受带小数的索引。 match value { Some(Value::Number(value)) => value .as_i64() @@ -425,18 +503,22 @@ fn integer(value: Option<&Value>) -> Result { _ => Err(invalid("expected a nonnegative integer")), } } -/// 解析颜色值并检查其满足当前原生绘制约定。 +/// 被文本/矩形工厂和颜色修改操作调用;缺省时使用调用方指定颜色,显式传入错误值则报错。 fn color(value: Option<&Value>, default: &str) -> Result { + // transpose 将 Some(Err) 传播出去,同时保留 None;只有未传参数才使用 default。 let value = value .map(|v| text(Some(v))) .transpose()? .unwrap_or_else(|| default.into()); + // 此处只校验并保留源字符串,render/document 在使用颜色时按同一格式转换成像素颜色。 parse_color(&value).map_err(|_| invalid("color must use #RRGGBB or #RRGGBBAA"))?; Ok(value) } -/// 读取布局尺寸参数,检查非负、有限和最大尺寸限制。 +/// 被原生工厂的尺寸/字号等参数解析调用,把 Endor 数字限制在可用的有限非负布局范围。 fn dimension(value: Option<&Value>) -> Result { + // 缺失和非 Number 值都不是合法必填尺寸;默认尺寸由调用方选择,解码器不自行猜测。 let Some(Value::Number(number)) = value else { + // 在访问 number 表示之前退出,防止把 nil 或文本当成零尺寸。 return Err(invalid("expected a finite nonnegative dimension")); }; // 分别支持大整数、十进制和二进制实数表示,不接受非实值;转 f64 后检查有限与范围,再降 f32。 @@ -448,10 +530,12 @@ fn dimension(value: Option<&Value>) -> Result { } .filter(|v| v.is_finite() && (0.0..=MAX_DIMENSION).contains(v)) .ok_or(invalid("dimension exceeds limits"))?; + // MAX_DIMENSION 远小于 f32 的有限上界;通过上面的检查后再按原生几何格式降低精度。 Ok(value as f32) } -/// 为一个托管对象安装绑定到当前会话的原生方法,声明调用名与具体 Operation。 +/// 由 calls 的 state 工厂调用,为状态对象安装 set/increment 等捕获 owner 的原生方法。 +/// 普通 Block 实例方法走 TypeMethod 共享实现;这里处理的是需要固定 owner 的对象属性函数。 fn bind( cx: &mut NativeContext<'_>, owner: ObjectRef, @@ -459,10 +543,12 @@ fn bind( operation: Operation, session: &Shared, ) -> Result<(), VmError> { + // 只在读 desktop 身份时持锁,随后分配/设属性不持有 Session 的锁,避免不必要的嵌套借用。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // 签名描述显式参数,owner 存入 payload;调用者不需要把状态对象再当首参传入。 let function = cx.allocate(ContextNativeFunction::new( name, Function { @@ -475,34 +561,40 @@ fn bind( // 绑定方法显式捕获 owner 与 desktop,让托管 GC 识别函数的真实强边,Rust Shared 会话本身不隐式保活 VM 对象。 vec![owner, desktop], ))?; + // 名义函数检查与普通源函数一致;把 callable 写成 owner 属性后由该托管强边持有。 attach_desktop_type(cx, function, "Function", desktop)?; cx.set_attr(owner, name, Value::Object(function)) } -/// 使用默认原生类型创建 Block;需要源子类身份的构造走 new_typed_block。 +/// PresentResult/present_value 等内部包装路径调用,创建没有源子类请求的默认原生 Block。 fn new_block( cx: &mut NativeContext<'_>, data: Data, session: &Shared, ) -> Result { + // 复用类型校验和节点/Page 预算,不允许内部包装绕开普通工厂的资源限制。 new_typed_block(cx, data, session, None) } -/// 分配有名义类型和默认位置/缩放的独立相机,供新视口拥有自己的导航状态。 +/// calls 的 Camera2D/CanvasViewport 分配路径调用,为视口建立有名义身份的独立相机状态。 fn new_camera2d( cx: &mut NativeContext<'_>, session: &Shared, requested: Option, args: &[Value], ) -> Result { + // 相机和使用它的视口必须在同一个 VM 类型域;当前窗口 desktop 与 master 共享 types,不为副窗口另造 Camera2D 类型。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // 源子类构造保留 requested;自由工厂和视口默认相机使用注册的 Camera2D。 let definition = requested.unwrap_or(desktop_type(cx.store(), "Camera2D", desktop)?); + // payload 标识相机种类,具体 x/y/zoom 则是下面写入的托管属性,供源代码和依赖追踪读取。 let camera = cx.allocate(crate::viewport::Camera2D)?; cx.attach_type(camera, definition)?; for (index, name, fallback) in [(0, "x", 0.0), (1, "y", 0.0), (2, "zoom", 1.0)] { + // 位置缺省为原点、缩放缺省为 1;显式错误值由 coordinate 返回错误,不能静默套用缺省。 let value = args .get(index) .map(crate::scroll::coordinate) @@ -515,13 +607,14 @@ fn new_camera2d( Ok(camera) } -/// 创建原生 Block 并附加请求的 Endor 类型;先验证继承关系,再登记到会话节点索引。 +/// calls 的视觉工厂与 new_block 调用:按 Data.kind 分配 payload,保留请求子类并登记节点弱索引。 fn new_typed_block( cx: &mut NativeContext<'_>, data: Data, session: &Shared, requested: Option, ) -> Result { + // payload 种类确定合法名义基类;同为 Block 也不能把矩形 payload 伪装成文本类型。 let type_name = match data.kind { Kind::Text { .. } => "TextBlock", Kind::Rect { .. } => "RectBlock", @@ -536,28 +629,36 @@ fn new_typed_block( Kind::Button => "Button", Kind::Page => "Page", }; + // 在 data 移入分配器之前记住是否 Page;除通用节点预算外,Page 还受单独数量限制。 let page = matches!(data.kind, Kind::Page); + // 先读取注册域,再释放锁进行托管分配;登记弱表时会重新短暂持锁。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; + // expected 是当前 VM 中合法基类的精确身份,type_object 才是实例最终要附加的真实类型。 let expected = desktop_type(cx.store(), type_name, desktop)?; + // 没有源子类请求时使用基类;有请求时仍必须经过下面的继承关系验证。 let type_object = requested.unwrap_or(expected); // 叶/容器原生种类对应不同类型基类,禁止用 TextBlock 分配器创建并非其子类的名义对象。 if !cx.store().is_subtype(type_object, expected)? { return Err(invalid("native constructor cannot allocate this subclass")); } + // 业务 payload 先建立,再附上已验证的名义类型;此处没有布局几何,几何属于后续 Mount。 let block = block::allocate(cx, data)?; cx.attach_type(block, type_object)?; { + // 这个作用域把锁限制在预算检查和弱索引写入期间,返回对象时不再持锁。 let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; // 节点弱表受宿主预算限制;实际死亡弱项通过后续 reclaim 压缩,不能把弱索引当永不增长的免费结构。 if state.nodes.len() >= MAX_UI_NODES { return Err(VmError::ExecutionLimit("4096 node budget")); } if page { + // 弱表可能含已死亡节点,所以 Page 预算数存活页面,而非弱记录总数。 let mut pages = 0; for weak in &state.nodes { + // 只有成功升级且 payload 的 page 标志为真的节点才计入;普通 Block 不占页面预算。 if let Some(node) = cx.upgrade(*weak)? && cx.store().payload::(node, "Block")?.page { @@ -565,38 +666,46 @@ fn new_typed_block( } } if pages >= MAX_PAGES { + // 在登记新节点前失败,运行时仍负责释放此次调用中已经分配但未发布的对象。 return Err(VmError::ExecutionLimit("64 page budget")); } } + // 只登记弱身份,真实拥有关系由返回值、容器子项或页面注册表建立。 state.nodes.push(cx.store().downgrade(block)?); } Ok(block) } -/// 在当前 VM 中建立窗口、desktop 和独立 Presentation,实际 OS 窗口等命令发布后再创建。 +/// calls 中 Window/Detach 操作调用,建立托管窗口和 Presentation;OS 窗口由 GUI 收到发布命令后创建。 fn create_window( cx: &mut NativeContext<'_>, state: &mut Session, definition: ObjectRef, ) -> Result { if cx.store().is_read_only() { + // 布局/绘制只读调用不能借“分配新值”偷偷增加已有窗口注册表和外部资源。 return Err(invalid( "window creation is forbidden during read-only rendering", )); } if state.exiting { + // shutdown 已关闭资源域,卸载回调即使仍能执行也不能重新创建窗口延长退出。 return Err(invalid("runtime is exiting; window creation is forbidden")); } // 同一 VM 最多16窗口(含主壳);ID 单调分配并限制到 i64 正范围,关闭后不复用旧资源身份。 if state.presentations.len() >= 16 { + // 将主窗口一并计入,避免每个副窗口都独立持有缓存和托管根而无界增长。 return Err(VmError::ExecutionLimit("16 window budget")); } + // checked_add 防溢出,i64 上界保证身份能放入语言整数使用的原生窗口命令路径。 let id = state .next_window .checked_add(1) .filter(|id| *id <= i64::MAX as u64) .ok_or(invalid("window identities exhausted"))?; + // model/window 建立托管资源关系,返回窗口对象、私有桌面及页面字典,三者用于不同层次。 let (window, desktop, tabs) = crate::window::create(cx, state.master, id, definition)?; + // 缓存和 Mount 按窗口独立;共享同一业务对象也不应共享它在两个窗口中的几何。 let mut presentation = crate::presentation::Presentation::new(desktop, tabs); // 新窗口即使暂无内容也需发布有限空帧,让 GUI 根据投影创建 OS 窗口,而不是在 VM 中直接创建。 presentation.force_frame = true; @@ -605,30 +714,35 @@ fn create_window( Ok(window) } -/// 从页面登记信息取得所属窗口,保持 Page 的归属与原生资源身份一致。 +/// show_in 与页面/窗口方法调用,遍历真实注册表确定 Page 当前归属;未打开的 Page 返回 None。 fn page_window( store: &endor_core::ObjectStore, state: &Session, page: ObjectRef, ) -> Result, VmError> { for (id, view) in &state.presentations { + // 从每个私有 desktop 取得托管 Window,不能只依据当前活动页判断窗口的全部页面。 let window = object(Some(&store.get_attr(view.desktop, "window")?))?; if crate::window::pages(store, window)?.contains(&page) { + // 返回原生资源 ID 而非 Window ObjectRef,供 Session 的 presentations 索引使用。 return Ok(Some(*id)); } } + // 无归属是新 Page 的正常状态,show_in 会再选 current;注册表读取失败已由 ? 传播。 Ok(None) } -/// 撤销页面在展示会话中的登记,配合模型层处理活动页面与生命周期。 +/// show_in 跨窗口迁移及 calls 关闭页面时调用,撤销登记并为源窗口安排替代活动页。 fn remove_page( cx: &mut NativeContext<'_>, view: &mut crate::presentation::Presentation, page: ObjectRef, ) -> Result<(), VmError> { + // view 决定移除的是哪个窗口的关系;同一个 Page 的业务内容并不在这里销毁。 let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; crate::window::unregister_page(cx, window, page)?; // 双层 pending 区分无请求与请求清空;移除当前/待选页时改选剩余第一页,或显式 pending=None页面。 if view.pending.unwrap_or(view.active) == Some(page) { + // 必须在 unregister_page 之后选取,才不会重新选中刚移走的 Page。 let next = crate::window::pages(cx.store(), window)?.first().copied(); cx.set_attr( view.desktop, @@ -639,30 +753,37 @@ fn remove_page( } Ok(()) } -/// 在指定窗口安排页面展示,并维护目标 Presentation 的活动/待发布状态。 +/// 页面 open/move 等调用与 show 的共同入口:验证目标、迁移注册关系,再安排一次待提交展示。 fn show_in( cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared, target: Option, ) -> Result<(), VmError> { + // 一次持锁覆盖“查旧归属—移除—登记新归属”,避免 Session 内出现中途被其他桥接调用观察的关系。 let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; + // old 是迁移前归属;后面仅在新旧 ID 不同时撤源登记,重复 activate 不应搬动页面。 let old = page_window(cx.store(), &state, page)?; + // target 是托管 Window,id 是对应宿主窗口编号;验证后才能用它索引 Presentation。 let id = match target { // 显式目标窗口既要未关闭,也要对应本会话 Presentation 的同一 desktop,拒绝其他 VM 的窗口身份。 Some(window) => { + // 先检查 payload 种类,再验证资源状态及 desktop 身份,不能只信任传入对象的数字 ID。 let data = cx .store() .payload::(window, "Window")?; if data.closed { + // 已退役窗口仍可能被源代码持有,但不能再作为展示目标。 return Err(invalid("native window is closed")); } + // ID 用于查表,desktop 的相等检查补上所属桥接会话验证。 let id = data.id; if state .presentations .get(&id) .is_none_or(|view| view.desktop != data.desktop) { + // 缺少 Presentation 或 desktop 不匹配都说明不是本会话可操作的窗口。 return Err(invalid("window belongs to another runtime")); } id @@ -683,10 +804,12 @@ fn show_in( page, )?; } + // 目标经过检查后取得可变展示上下文,用于写 pending;不会在这里改 active 或发布帧。 let view = state .presentations .get_mut(&id) .ok_or(invalid("missing target window"))?; + // model 注册表保存 Page 的强拥有关系,也决定标签顺序;只写 pending 不足以完成登记。 let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; crate::window::register_page(cx, window, page)?; cx.set_attr(view.desktop, "pending", Value::Object(page))?; @@ -694,7 +817,7 @@ fn show_in( view.pending = Some(Some(page)); Ok(()) } -/// 在当前窗口安排页面展示,实际几何和画面由后续刷新提交。 +/// calls 的激活/导航及 PresentResult 调用,不指定新目标窗口,委托 show_in 保留既有归属或选择 current。 fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> { show_in(cx, page, session, None) } @@ -702,26 +825,32 @@ fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result #[derive(Debug)] /// 接收源 present 结果并将其转换成宿主可展示内容的原生续接步骤。 struct PresentResult { + // ui.open 的续接需确保有 Page 并安排展示;ui.present 的续接只返回视觉对象。 open: bool, + // 源 present 返回后仍要回到原桥接域分配包装页/修改 pending,因此续接保存同一会话。 session: Shared, } impl NativeCallable for PresentResult { - /// 接收 present 委托返回的唯一视觉对象,作为后续展示处理的输入。 + /// VM 的原生续接调用只提供被委托 present 的一个返回值;参数数量在进入 invoke 前检查。 fn arity(&self) -> Option { Some(endor_core::CallArity::exact(1)) } - /// 验证 present 结果为 Block;open 模式必要时包装 Page 并展示,否则只返回转换得到的视觉对象。 + /// VM 完成源 Presentable.present 后调用;验证返回 payload,并按 open 标志选择包装和展示行为。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 类型声明不能替代返回值防御检查,错误返回立即中止,不能把它包装成看似成功的文本。 let [Value::Object(root)] = args else { return Err(invalid("Presentable.present must return a Block")); }; + // payload 检查保证返回的是原生视觉 Block;page 标志决定是否还需外包 Page。 let is_page = cx.store().payload::(*root, "Block")?.page; // present 委托返回值必须为 Block;open=true 必要时包 Page 并展示,open=false 只返回转换结果。 let result = if self.open { + // open 对已是 Page 的值保留身份,对普通 Block 创建单子项 Page,均走同一 show 流程。 let page = if is_page { *root } else { + // 包装只新增页面,不复制返回对象;同一个 Block 可随后产生自己的 Mount。 new_block( cx, Data::new(Kind::Page, vec![*root], 0.0, 0.0), @@ -731,18 +860,21 @@ impl NativeCallable for PresentResult { show(cx, page, &self.session)?; page } else { + // present 是值转换,不能附带把页面激活/打开的外部副作用。 *root }; Ok(NativeResult::Return(Value::Object(result))) } } -/// 返回已有 Block,或把其他值的可读表示包装成 TextBlock;Presentable 委托由外层调用分派负责。 +/// calls 的 open/present 普通值路径调用:已有 Block 保持身份,其余转成默认 TextBlock。 +/// 具有 Presentable 协议的源方法调用在 calls 的外层处理,返回后才进入 PresentResult。 fn present_value( cx: &mut NativeContext<'_>, value: &Value, session: &Shared, ) -> Result { + // 快速路径只接受真实 Block payload,避免对已有视觉对象调用 repr 后丢失其行为与内容。 if let Value::Object(block) = value && cx .store() @@ -751,6 +883,7 @@ fn present_value( .as_any() .is::() { + // 复用对象身份,不在此新增根或克隆业务 payload;调用边界管理返回值的拥有关系。 return Ok(*block); } // 非 Block 的标量/对象转可读文字,Presentable 方法委托在 calls.rs 的更外层处理,本函数不自己调用 present。 @@ -762,8 +895,10 @@ fn present_value( Value::Object(v) => cx.store().repr(*v)?, }; if value.len() > 16_384 { + // 按 UTF-8 字节限制文本开销;这是展示限制,不是“最多 16384 个 Unicode 字符”。 return Err(invalid("presentation exceeds 16 KiB")); } + // 回退视觉使用默认颜色和字号种类;几何留给后续测量,不在转换阶段猜测像素尺寸。 new_block( cx, Data::new( @@ -783,16 +918,23 @@ fn present_value( #[must_use = "pending layout must be resumed on its owning VM"] /// 桥接布局的挂起/完成结果;PendingLayout 必须与其窗口上下文一起继续处理。 pub enum LayoutProgress { + // 交给 endor_host 保存;恢复时必须携带同一 VM、窗口和检查点,不能换域重跑。 Pending(Box), + // 几何已就绪的帧草稿;运行时仍需完成绘制和发布阶段,不等于此刻已显示到屏幕。 Complete(Box), } /// 一次未提交布局的上下文:目标窗口、根、布局/缓存、延后钩子以及用于失败恢复的检查点。 struct LayoutContext { + // 本次布局使用的页面模式/相机参数;窗口 resize 重定向时需要和新约束比较。 surface: crate::page::Surface, + // 不可变视觉树草稿,布局和后续绘制必须使用与这一轮匹配的对象快照。 root: Arc, + // 与本轮页面注册表一致的纯数据标签投影,不能在提交时混入下一轮的顺序。 tabs: Vec, + // 原生输入给出的整窗可用尺寸;不同于页面局部约束,完成布局时据此构造完整帧。 full_viewport: Option, + // 私有宿主对象和资源 ID 一起记录所属窗口,续体恢复不能误用 Session.current 的新值。 desktop: ObjectRef, window: u64, // 布局同步返回的生命周期序列拥有根,暂留到安全提交边界执行/释放,取消时也必须清理。 @@ -803,18 +945,23 @@ struct LayoutContext { /// 挂起布局的拥有者。续体与上下文必须一起保留并最终恢复或取消。 pub struct PendingLayout { + // source_layout 拥有尚未完成的 VM 调用和测量状态,必须继续推进或显式取消。 work: crate::source_layout::Work, + // 与 work 配套的树、窗口和检查点;单独丢弃上下文会使取消后无法恢复未提交 Mount。 context: LayoutContext, } /// 运行时持有的桥接能力句柄,通过共享 Session 协调托管对象与每窗口展示。 pub struct UiBridge { + // runtime 安装全局名字和调用 ui.open 时使用;真正的 VM 拥有根由 Candidate 管理。 pub ui: ObjectRef, + // 工厂/方法与运行时共用展示会话,窗口切换、版本和缓存均从这里协调。 session: Shared, + // 本桥接域的任务调度器,运行时通过 poll_tasks 推进,退出通过 shutdown/cancel_tasks 清理。 tasks: crate::tasks::Scheduler, } -/// 解析对象的方法并在同一 VM 中调用,使源覆盖与原生成员共用方法查找规则。 +/// dispatch_tab/页面事件等桥接控制路径调用,通过 VM 正常查找绑定方法,保留源覆盖与继承语义。 fn invoke_member( vm: &mut Vm, owner: ObjectRef, @@ -824,7 +971,9 @@ fn invoke_member( ) -> Result { // 使用 VM 正常绑定规则创建临时方法值;无论 call 成功/失败,先保存 result 再释放 bound 根。 let bound = vm.bind_attr(owner, method)?; + // 暂存调用结果而非直接 ? 返回,确保即使用户方法报错也会执行后面的 release_value(bound)。 let result = match &bound { + // 方法值为对象才可能调用;VM 再检查对象是否 callable,并应用帧数/指令预算。 Value::Object(function) => vm .call_with_limits( *function, @@ -835,29 +984,36 @@ fn invoke_member( }, ) .map_err(|error| crate::endor_host::runtime_error(vm, error)), + // 普通属性没有函数身份,不能把它当无参 getter 或忽略调用。 _ => Err(anyhow::anyhow!("object method is not callable")), }; vm.release_value(bound)?; result } -/// 将托管页面注册表投影成稳定标签 ID、标题与活动状态,不把托管引用交给原生侧栏。 +/// prepare/刷新流程调用,把页面注册表投影成 GUI 可直接读取的标签数据;不向 GUI 传 ObjectRef。 fn project_tabs( vm: &Vm, state: &mut crate::presentation::Presentation, ) -> Result> { + // 从窗口模型取得完整页面顺序,不能只用当前 active 页面构造标签栏。 let window = object(Some(&vm.get_attr(state.desktop, "window")?))?; + // pages 是本次读取的托管身份列表;下方逐项转为不依赖 VM 的标题、数字 ID 和选中状态。 let pages = crate::window::pages(vm.object_store(), window)?; // 标签选中反映待提交页面(若有请求),否则沿用 active;页面标题和顺序仍从真实窗口注册表提取。 let active = state.pending.unwrap_or(state.active); + // live 重建仍有效的弱身份到标签 ID 映射,过期/移出页面的旧记录不再保留。 let mut live = Vec::new(); + // tabs 是对外返回的纯数据;和 live 分开,避免渲染线程接触托管弱引用。 let mut tabs = Vec::new(); for (index, page) in pages.iter().copied().enumerate() { + // 弱身份识别同一个 Page,即使它改标题或位置也不改变该窗口内的标签 ID。 let weak = vm.object_store().downgrade(page)?; // 同一 Page 弱身份复用该窗口标签 ID,新增页用高水位;移出后 live 更新会移除旧映射,不靠标题匹配。 let id = if let Some((_, id)) = state.tab_ids.iter().find(|(w, _)| *w == weak) { *id } else { + // 新页面才分配单调增长的 ID;checked_add 避免溢出后把旧拖动事件指向另一标签。 state.next_tab = state .next_tab .checked_add(1) @@ -874,38 +1030,47 @@ fn project_tabs( .collect(), _ => format!("Page {}", index + 1), }; + // 对外标签只保存可显示数据;工具标记用于 GUI 识别开发工具入口,不泄露 Page 对象。 tabs.push(crate::tabs::Tab { id, title, developer_tool: match vm.get_attr(page, "_tesseraTool") { + // 仅接受源属性中的字符串工具名,缺失或其他值都投影为普通页面。 Ok(Value::Text(name)) => Some(name), _ => None, }, active: active == Some(page), }); } + // 覆盖映射而不是追加,后续 dispatch_tab 只允许解析这次仍在注册表里的页面身份。 state.tab_ids = live; Ok(tabs) } impl UiBridge { - /// Detach all occurrences without running layout/paint. All windows are - /// already closed before source unmount hooks run, so they cannot remount. - /// 按窗口和展示生命周期执行有界退出清理,允许调用方根据故障状态决定是否执行回调。 + /// endor_host 清理运行时调用,取消任务并退役所有窗口展示;callbacks 决定能否继续执行用户卸载钩子。 + /// 返回窗口、Mount 和取消任务数量供诊断使用;不为退出再启动布局或绘制。 pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> { // 退出先关闭新任务入口并取消旧任务,再标 session.exiting,避免卸载回调重新创建任务/窗口。 let tasks = self.tasks.quiesce(vm)?; + // 保存各窗口卸载驱动的拥有根,必须等所有窗口先关闭后才逐个调用并释放。 let mut drivers = Vec::new(); + // 统计退出前仍活动的出现位置,不按共享业务 Block 数量计数。 let mut mounts = 0; + // 闭包收集准备阶段错误但不立刻退出外层:此前已生成的驱动仍需释放。 let prepared = (|| -> Result { + // 只在修改窗口表时持锁;用户钩子执行移到锁作用域之外,避免回调重入死锁。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; state.exiting = true; + // 复制键后再逐窗可变借用,保证本轮遍历稳定且不持有 map 迭代借用。 let ids: Vec<_> = state.presentations.keys().copied().collect(); for id in &ids { + // ids 来自同一锁内的表,未删除过键,因此缺项表示内部不变量被破坏。 let view = state.presentations.get_mut(id).expect("known window"); + // 每个窗口自己的 desktop 用于切断挂载关系,不能用主窗口 desktop 代替。 let desktop = view.desktop; mounts += view.mounts.active.len(); // 正常域用空结构 synchronize 安排孩子先卸载的 hooks;故障域直接 retire,不能再次执行用户代码。 @@ -918,11 +1083,14 @@ impl UiBridge { &crate::document::Frame::default(), false, )? { + // synchronize 只准备卸载顺序;现在不运行回调,以便先关闭其他窗口。 drivers.push((*id, hooks)); } } else { + // 故障 VM 不再执行源代码;直接断开挂载关系,把清理限于宿主操作。 view.mounts.retire(vm, desktop)?; } + // 取得 model/window 的资源对象,以便退役页面拥有关系与窗口状态。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; // 所有窗口先标 closed 并断 Page 拥有关系,然后才在下面执行用户卸载 hooks,阻止回调重新打开已关闭窗口。 crate::window::retire(vm, window)?; @@ -934,12 +1102,15 @@ impl UiBridge { } Ok(ids.len()) })(); + // 保留准备失败或数量结果;后续钩子/释放错误仍可把结果转成 Err,但不跳过驱动释放。 let mut result = prepared.map(|windows| (windows, mounts, tasks)); // 若之前清理/回调失败,余下 hooks 不再执行,但所有驱动根仍逐项 release,避免失败路径泄漏。 for (id, hooks) in drivers { if result.is_ok() { + // 只有此前所有退出操作正常时才调用源代码;每个驱动有独立有界 VM 预算。 let call = (|| -> Result<()> { self.select_window(id)?; + // 钩子调用的返回值也是拥有值,即使无业务用途也必须释放。 let value = vm .call_with_limits( hooks, @@ -954,47 +1125,55 @@ impl UiBridge { Ok(()) })(); if let Err(error) = call { + // 一次失败后停止后续用户钩子,继续外层释放所有已准备的驱动根。 result = Err(error); } } if let Err(error) = vm.release(hooks) { + // 无论钩子是否实际调用都释放;释放失败也报告,不能把错误吞作正常退出。 result = Err(error.into()); } } + // 退出方法返回前恢复桥接默认上下文;Presentation 表尚未在本方法内删除主窗口。 self.select_window(crate::window::MAIN_WINDOW)?; result } - /// 查询任务系统是否仍有可推进的协作工作,供 worker 选择等待策略。 + /// endor_host/worker 查询是否仍有协作任务,以决定继续推进还是等待外部事件;不执行任务本身。 pub fn has_cooperative_tasks(&self) -> bool { self.tasks.has_cooperative_work() } - /// 把当前 VM 交给任务调度器推进,限制一轮交付数量。 + /// endor_host 的 VM 轮询调用,在所属 VM 中交付至多 limit 项任务,保持回调串行。 pub fn poll_tasks(&self, vm: &mut Vm, limit: usize) -> Result { self.tasks.poll(vm, limit) } - /// 取消当前域中的任务,撤销关闭/失败后不应再发生的交付。 + /// endor_host 取消/退役运行时调用,取消本域全部任务;已在后台进行的 I/O 也不得再交付旧回调。 pub fn cancel_tasks(&self, vm: &mut Vm) -> Result { self.tasks.cancel_all(vm) } - /// 返回当前仍有效的展示窗口身份,用于稳定遍历一次全窗口刷新。 + /// endor_host 启动全窗口刷新时调用,取得经过迁移退旧优先排序的窗口身份快照。 pub fn window_ids(&self) -> Result> { + // 持锁期间只读/排序原生资源表,不执行 VM 回调;调用者取得独立列表后再开始逐窗工作。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 复制身份,不把 Session 的 map 借用暴露给运行时;后续每窗刷新仍显式 select_window。 let mut ids: Vec<_> = state.presentations.keys().copied().collect(); // 优先处理切离旧 active 页的窗口,使跨窗口迁移先退旧出现再挂新出现;同优先级按窗口 ID 稳定排序。 ids.sort_by_key(|id| { + // 第一个排序键区分“旧 active 即将退出”和其他窗口,第二个键确保同组顺序可重复。 let view = &state.presentations[id]; ( if view.active.is_some() && view.pending.is_some() && view.pending.flatten() != view.active { + // pending = Some(None) 也属于切离旧页,需先安排卸载旧出现位置。 0 } else { + // 无旧页或没有切换请求,无需抢在迁移源窗口之前刷新。 1 }, *id, @@ -1002,9 +1181,10 @@ impl UiBridge { }); Ok(ids) } - /// 判断窗口是否仍登记且没有进入关闭状态。 + /// runtime 在接受窗口输入前查询;锁异常、缺失或 closing 都视为不再可接受,不执行 VM 操作。 pub fn accepts_window(&self, id: u64) -> bool { self.session.lock().is_ok_and(|state| { + // 控制线程只查询原生会话状态;页面对象身份与版本还有单独的后续校验。 state .presentations .get(&id) @@ -1012,31 +1192,38 @@ impl UiBridge { }) } - /// 切换当前 Presentation 上下文;不会创建新窗口或复制 VM。 + /// endor_host 的逐窗刷新、事件及退出钩子调用,选择后续 Session 字段访问的目标窗口。 pub fn select_window(&self, id: u64) -> Result<()> { + // current 的检查与写入放在同一短锁中,Deref 的“选中窗口存在”前提由本方法维护。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; if !state.presentations.contains_key(&id) { + // 先检查再改 current,失败时保留原选择,避免 Deref 命中不存在的 Presentation。 bail!("window is unavailable"); } state.current = id; Ok(()) } - /// 从托管展示域撤销窗口,退役 Mount 并清理该窗口的私有对象关系。 + /// endor_host 故障/关闭路径调用,直接退役副窗口并丢弃未发布命令,不触发源卸载回调。 pub fn retire_window(&self, vm: &mut Vm, id: u64) -> Result<()> { if id == crate::window::MAIN_WINDOW { + // 主壳由原生生命周期管理,不能通过删除 Presentation 假装已关闭 OS 主窗口。 bail!("the primary shell is closed by the OS window control"); } + // 锁覆盖副窗口所有关系的撤销,后续不在此执行源回调,因此无需放锁再进入 VM。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; let Some(view) = state.presentations.get_mut(&id) else { + // 已退役的窗口再次清理是正常幂等操作,不能让重复关闭导致整个运行时失败。 return Ok(()); }; + // 把要清理窗口的身份保存下来,随后依次断 Mount、窗口页面和焦点属性的托管引用。 let desktop = view.desktop; + // model/window 负责拥有关系;Session 中删项本身不足以关闭托管资源。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; view.mounts.retire(vm, desktop)?; crate::window::retire(vm, window)?; @@ -1046,6 +1233,7 @@ impl UiBridge { // Draining is not publication: failed-domain effects are discarded. // 故障宿主退役的命令只丢弃不发布,防止失败域遗留 Resize/Focus 等影响当前原生状态。 let _ = crate::window::take_commands(vm, window)?; + // 从主资源字典删除副窗口强边,允许仍无其他引用的私有窗口对象被回收。 let windows = object(Some(&vm.get_attr(state.master, "windows")?))?; vm.dict_delete(windows, &Value::Text(id.to_string()))?; state.presentations.remove(&id); @@ -1053,8 +1241,9 @@ impl UiBridge { Ok(()) } - /// 收尾已经关闭的展示资源,避免它们参与下一次窗口遍历。 + /// endor_host 在正常全窗口刷新收尾调用,移除已 closing 的副窗口并保留其待发布原生命令。 pub fn finish_closed(&self, vm: &mut Vm) -> Result<()> { + // 正常收尾与故障 retire_window 不同:本锁内先保存命令,再删除托管/原生资源表项。 let mut state = self .session .lock() @@ -1066,9 +1255,12 @@ impl UiBridge { .filter(|(id, view)| **id != crate::window::MAIN_WINDOW && view.closing) .map(|(id, _)| *id) .collect(); + // 该字典是托管拥有关系,需和原生 presentations 的删项保持一致。 let windows = object(Some(&vm.get_attr(state.master, "windows")?))?; for id in ids { + // 键来自上面已筛选列表,持同一把锁时不会被其他路径删掉。 let desktop = state.presentations[&id].desktop; + // 在移除资源表前取出 outbox,避免正常关闭命令失去最后的读取机会。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; state .retired_commands @@ -1079,8 +1271,9 @@ impl UiBridge { state.current = crate::window::MAIN_WINDOW; Ok(()) } - /// 从各托管窗口收集待发布命令;原生执行时还需校验 session。 + /// endor_host/worker 形成发布结果时调用,收集并清空各窗口 outbox;GUI 执行仍要校验 session。 pub fn take_window_commands(&self, vm: &Vm) -> Result> { + // 需要可变锁消费 retired_commands;VM 侧 take_commands 也清空各自 outbox,不在此调用源方法。 let mut state = self .session .lock() @@ -1088,14 +1281,16 @@ impl UiBridge { // 先取已移除窗口的最后命令,再合并仍活动窗口 outbox;按窗口/窗口内序号排序,不丢失正常 Close 请求。 let mut commands = std::mem::take(&mut state.retired_commands); for view in state.presentations.values() { + // 活动和正在关闭但尚未移除的窗口都可能有命令,不能只收集当前窗口。 let window = object(Some(&vm.get_attr(view.desktop, "window")?))?; commands.extend(crate::window::take_commands(vm, window)?); } + // 保证同窗命令按递增序号执行;不同窗口之间只提供稳定顺序,不伪造跨窗口事务顺序。 commands.sort_by_key(|command| (command.window, command.sequence)); Ok(commands) } - /// 校验并更新所属窗口的逻辑尺寸输入;尺寸不变时不制造新布局,改变时标记需要刷新。 + /// endor_host 创建会话/接收 resize 时调用,更新选中窗口的逻辑尺寸,返回是否真的发生变化。 pub fn set_viewport(&self, vm: &mut Vm, viewport: Option) -> Result { if let Some(size) = viewport && (!size.width.is_finite() @@ -1103,18 +1298,23 @@ impl UiBridge { || !(0.0..=MAX_DIMENSION as f32).contains(&size.width) || !(0.0..=MAX_DIMENSION as f32).contains(&size.height)) { + // 在分配托管输入前拒绝 NaN/无穷/负值/过大值,保留原来合法尺寸和发布画面。 bail!("invalid window viewport"); } + // 校验成功后再持锁更新当前窗口的尺寸,其他 Presentation 保留各自 viewport。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; // 尺寸比较在分配新输入对象前完成,避免相同 resize 通知改变 VM 版本而触发自我失效。 if state.layout.viewport == viewport { + // 相同输入返回 false,worker 不必因此强制再排版或修改依赖版本。 return Ok(false); } if let Some(size) = viewport { + // 只在有尺寸时创建托管协议对象;该局部对象先有根,设置 desktop 属性成功后再释放局部根。 let input = vm.new_object()?; + // 闭包暂存写属性错误,确保任意一个字段写入失败也能经过下面的 release(input)。 let result = (|| { vm.set_attr(input, "width", Value::Number((size.width as f64).into()))?; vm.set_attr(input, "height", Value::Number((size.height as f64).into()))?; @@ -1124,6 +1324,7 @@ impl UiBridge { vm.release(input)?; result?; } else { + // 未知尺寸用 nil 明确表示,不把旧尺寸残留在源 ui.viewport 可见输入里。 vm.set_attr(state.desktop, "viewport", Value::Nil)?; } state.layout.viewport = viewport; @@ -1134,10 +1335,10 @@ impl UiBridge { Ok(true) } - /// 将 ui 入口与兼容名称安装到本次程序的全局字典,保持所有调用处在同一个 VM 域。 + /// endor_host 完成 bootstrap 后调用,把 UI 命名空间、兼容名字和类型查询入口写入程序全局字典。 pub fn install_globals(&self, vm: &mut Vm, globals: ObjectRef) -> Result<()> { vm.dict_set(globals, Value::Text("ui".into()), Value::Object(self.ui))?; - // Compatibility spelling used by the original module examples. + // 旧模块示例把 Block 写成 View;别名引用同一 Type,不建立另一个互不相容的同名类型。 vm.dict_set( globals, Value::Text("View".into()), @@ -1145,13 +1346,15 @@ impl UiBridge { )?; // 全局名引用 ui 中同一批名义类型/函数对象,模块和 Console 不会各复制一份类型体系。 for name in TYPE_NAMES.iter().chain(QUERY_NAMES) { + // bootstrap 必须已安装相应名字;取值失败就返回,不能悄悄让程序缺少某个声明的全局。 let value = vm.get_attr(self.ui, name)?; vm.dict_set(globals, Value::Text((*name).into()), value)?; } Ok(()) } - /// 检查当前会话是否已经安排或拥有可展示页面。 + /// endor_host 处理主程序结果时查询,判断源程序是否已自行打开页面,从而避免重复包装/展示返回值。 pub fn has_page(&self) -> Result { + // 检查所有窗口;pending 的 Some(None) 是清空请求,不等于已经有一个新页面。 Ok(self .session .lock() @@ -1160,12 +1363,14 @@ impl UiBridge { .values() .any(|view| view.pending.flatten().is_some() || view.active.is_some())) } - /// 验证标签拖放仍指向有效页面和窗口关系,避免旧拖动操作跨域生效。 + /// runtime 的输入接纳路径调用,验证拖放两端的版本和注册顺序仍与发布画面一致,不修改页面。 pub fn accepts_tab_drop(&self, vm: &Vm, input: &crate::events::Input) -> bool { let Some(target) = &input.tab_drop else { + // 普通事件没有拖放目标,本方法不额外限制;它们由其他输入身份检查处理。 return true; }; let Ok(state) = self.session.lock() else { + // 无法可靠验证时拒绝,而不是让不可用的会话继续重排页面。 return false; }; // 拖放源与目标都必须匹配各自发布版本,且真实 Page 注册顺序仍对应标签弱身份表,防止用陈旧列表下标重排。 @@ -1174,15 +1379,19 @@ impl UiBridge { (target.window, target.layout_version), ] { let Some(view) = state.presentations.get(&id) else { + // 任一端窗口已经退役都使整次拖动失效。 return false; }; if view.closing || view.published_version != version { + // 不仅源端,目标端布局变化也会让落点索引不再可靠。 return false; } let Ok(Value::Object(window)) = vm.get_attr(view.desktop, "window") else { + // 内部关系读取异常在这个布尔验证接口统一映射为拒绝,不产生半次迁移。 return false; }; let Ok(pages) = crate::window::pages(vm.object_store(), window) else { + // 页面注册表不可读时同样不能相信 GUI 发来的标签顺序。 return false; }; if view.tab_ids.len() != pages.len() @@ -1191,29 +1400,36 @@ impl UiBridge { .zip(&view.tab_ids) .all(|(page, (weak, _))| vm.object_store().downgrade(*page).ok() == Some(*weak)) { + // 数量相同也可能已换页/重排,所以逐个比较弱身份而非只比较长度。 return false; } if id == target.window && target.index > pages.len() { + // 允许插到末尾(等于 len),拒绝超出末尾的过期/无效索引。 return false; } } true } - /// 通过展示协议打开源值,必要时创建或选择承载它的页面。 + /// endor_host 的程序结果展示路径调用,经 ui.open 复用 Presentable 协议及普通值包装规则。 pub fn open_value(&self, vm: &mut Vm, value: &Value) -> Result<()> { + // 使用已安装 callable 而不直接调用 new_block,源 Presentable 的覆盖因此仍会执行。 let function = object(Some(&vm.get_attr(self.ui, "open")?))?; + // 结果 Page 已由页面注册表持有,本地返回根用完即释放,避免每次 open 泄漏一个根。 let page = vm.call(function, std::slice::from_ref(value))?; vm.release_value(page)?; Ok(()) } - /// 处理页面激活、关闭、移动和拆分等标签操作,保持窗口所有权与展示状态一致。 + /// dispatch 的标签事件分支调用,按发布时 Tab ID 找回 Page,再调用可被源代码覆盖的页面方法。 fn dispatch_tab(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> { + // 锁内只解析来源和身份,返回 Page 后释放锁,真正用户方法执行不能持有 Session 锁。 let page = { + // 只借用当前窗口已发布的标签映射,不升级成新的页面根;页面注册表仍持有 Page。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; if state.published_version != input.layout_version { + // 标签画面已经更新,旧 ID/顺序可能不再有效;拒绝后由 GUI 使用新发布数据。 bail!("event targets an obsolete presentation"); } // input.mount 在标签路径表示 Tab ID,先映回弱 Page,再确认仍在当前窗口 pages 集合,不是 Mount 几何下标。 @@ -1223,6 +1439,7 @@ impl UiBridge { .find(|(_, id)| *id == input.mount) .map(|(weak, _)| *weak) .ok_or_else(|| anyhow::anyhow!("tab has expired"))?; + // 再在真实窗口注册表中验证弱身份,不能仅凭旧 tab_ids 让已移出的页面继续响应操作。 let window = object(Some(&vm.get_attr(state.desktop, "window")?))?; crate::window::pages(vm.object_store(), window)? .iter() @@ -1231,28 +1448,35 @@ impl UiBridge { .ok_or_else(|| anyhow::anyhow!("Page is no longer open"))? }; if let Some(target) = &input.tab_drop { + // 拖放可能跨窗口;第二段锁只验证目标,避免下方 moveToWindow 源覆盖重入时死锁。 let window = { + // 回调前复制目标窗口身份;释放锁后再执行可覆盖的 moveToWindow。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 输入的窗口 ID 必须仍有展示上下文;不能为过期拖动自动重建一个窗口。 let view = state .presentations .get(&target.window) .ok_or_else(|| anyhow::anyhow!("drop window has closed"))?; if view.published_version != target.layout_version { + // 目标标签布局改变后插入位置不可沿用,即使源页面本身仍存活也要拒绝。 bail!("drop targets an obsolete presentation"); } object(Some(&vm.get_attr(view.desktop, "window")?))? }; if target.window != input.window { + // 跨窗口迁移走名义方法分派,给源子类决定是否接受/重定向,而非直接改宿主字典。 let value = invoke_member(vm, page, "moveToWindow", &[Value::Object(window)], 300_000)?; vm.release_value(value)?; } // An override may deliberately decline or redirect the move. Do // not reorder an unrelated window after that source decision. + // 回调可能改写实际归属,必须在调用结束后重新读取,不能沿用最初 target 当作已成功。 let destination = { + // 重新加锁读取回调后的真实归属,不沿用迁移前的 Session 借用。 let state = self .session .lock() @@ -1261,7 +1485,9 @@ impl UiBridge { }; // 源 moveToWindow 可覆盖并拒绝/改投其他窗口;只有确认实际归属仍为预期目标才继续重排与激活。 if destination == Some(target.window) { + // 用回调之后的真实顺序定位页面,避免回调中增删标签使旧下标错指其他页面。 let pages = crate::window::pages(vm.object_store(), window)?; + // 找不到页面说明迁移过程中它已被关闭/移走;不能继续以默认 0 号位置重排。 let from = pages .iter() .position(|candidate| *candidate == page) @@ -1270,11 +1496,14 @@ impl UiBridge { let position = if input.window == target.window && from < target.index { target.index - 1 } else { + // 跨窗口插入或向前拖动保留原缝位置,不做同窗移除产生的偏移修正。 target.index }; if position >= pages.len() { + // 用户方法改变了目标列表,当前计划位置已不可用,不把它静默夹到末尾。 bail!("drop destination changed during Page method"); } + // 重排同样保留源 moveTab 覆盖;结果是临时拥有值,随后显式释放。 let value = invoke_member( vm, page, @@ -1283,19 +1512,23 @@ impl UiBridge { 300_000, )?; vm.release_value(value)?; + // 最后激活被拖页面;若源 activate 另有决定,下一轮刷新以真实 pending 为准。 let value = invoke_member(vm, page, "activate", &[], 300_000)?; vm.release_value(value)?; } return Ok(()); } + // 无拖放目标的标签事件只需一次页面方法调用;普通标签选择统一映射到 activate。 let method = match input.kind.as_str() { "tab-close" => "close", "tab-detach" => "detachToWindow", _ => "activate", }; + // detach 的返回 Window 还可能用于屏幕定位,因此先保存返回根,不立即释放。 let value = invoke_member(vm, page, method, &[], 1_000_000)?; // detach 返回值可能被源覆盖成其他值,仅确为 Window 且带屏幕位置才调用 moveTo;最后统一释放原返回值。 let placed = (|| -> Result<()> { + // 只有带坐标且真实返回 ManagedWindow 的拆离操作才移动 OS 资源;其他覆盖返回值正常保留语义。 if input.kind == "tab-detach" && let Some([x, y]) = input.screen_position && let Value::Object(window) = &value @@ -1303,6 +1536,7 @@ impl UiBridge { .payload::(*window, "Window") .is_ok() { + // moveTo 也经绑定方法调用,坐标转为语言数字;返回值和外层 detach 返回值分别释放。 let result = invoke_member( vm, *window, @@ -1317,13 +1551,15 @@ impl UiBridge { } Ok(()) })(); + // 即使 moveTo 出错也先释放 detach 返回根,再传播 placed,防止错误路径泄漏。 vm.release_value(value)?; placed?; Ok(()) } - /// 在所属窗口的展示上下文中处理输入,区分页面管理、滚动、焦点、相机与普通事件。 + /// endor_host 已选定输入窗口后调用,校验信封并分派宿主交互或托管事件;源代码仍在同一 VM 串行执行。 pub fn dispatch(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> { + // 信封层校验保证坐标/增量等字段满足事件格式,后面仍要验证窗口、版本、Mount 与可见性。 input .validate() .map_err(|message| anyhow::anyhow!(message))?; @@ -1334,17 +1570,22 @@ impl UiBridge { .current != input.window { + // current 决定 Session 的隐式字段访问;不匹配就拒绝,避免把另一窗口输入应用到当前页。 bail!("event belongs to another window"); } // 窗口关闭通过名义 close 方法,保留源覆盖语义;直接调用宿主原生退役只用于故障清理路径。 if input.kind == "window-close" { + // 复制 desktop 身份后立即释放锁,下面的 close 源覆盖可以安全重入其他 UI 方法。 let desktop = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))? .desktop; + // Window 是源代码可覆盖的资源对象;不是 winit 的 OS Window。 let window = object(Some(&vm.get_attr(desktop, "window")?))?; + // 绑定产生临时拥有值;先保存调用结果,再清理该方法值,保证报错也不跳过释放。 let bound = vm.bind_attr(window, "close")?; + // 保存 Result 以便绑定值始终释放;result? 放在释放之后,避免错误路径留下根。 let result = match &bound { Value::Object(function) => vm.call_with_limits( *function, @@ -1362,36 +1603,44 @@ impl UiBridge { } // 内置工具打开走同一 VM 的 developerTools 能力,返回值只作临时根使用,不跨到 GUI。 if let Some(name) = input.kind.strip_prefix("developer-open:") { + // 只传工具名字,页面工厂和工具状态仍在所属 VM 中创建,GUI 不接收托管返回对象。 let function = object(Some(&vm.get_attr(self.ui, "developerTools")?))?; + // 工具工厂返回的页面已由托管展示关系持有,调用结束释放这里只为接收返回值而持有的根。 let page = vm.call(function, &[Value::Text(name.into())])?; vm.release_value(page)?; return Ok(()); } if matches!(input.kind.as_str(), "page-pan" | "page-zoom") { + // 整页相机操作使用 page_input 中的模式/坐标检查,不走普通 Block.click。 return self.dispatch_page_view(vm, input); } if matches!(input.kind.as_str(), "viewport-pan" | "viewport-zoom") { + // 局部 CanvasViewport 的相机不同于整页相机,需要按命中出现位置转换增量。 return self.dispatch_viewport_view(vm, input); } if matches!( input.kind.as_str(), "scene-pan" | "scene-orbit" | "scene-zoom" ) { + // 三维交互交给场景控制器,保留源更新方法及投影坐标语义。 return self.dispatch_scene_view(vm, input); } if matches!( input.kind.as_str(), "tab-activate" | "tab-close" | "tab-detach" | "tab-drop" ) { + // 标签路径的 input.mount 是 Tab ID,不是 Mount;必须在普通目标解析之前分流。 return self.dispatch_tab(vm, input); } // 普通指针输入取同一锁内的版本/目标/完整出现候选快照;随后不持 session 锁执行源 hitTest 或事件回调。 let (weak, desktop, candidates, viewport, surface) = { + // 一次锁内拍下同版本信息,随后 hitTest/回调在锁外运行,避免读到窗口选择的半次变化。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; if state.published_version != input.layout_version { + // 弱对象尚存活不代表几何仍适合旧坐标;版本不符时整次普通输入失效。 bail!("event targets an obsolete presentation"); } ( @@ -1407,11 +1656,15 @@ impl UiBridge { ) }; if input.kind == "scroll" { + // 升级弱 Mount 获得临时拥有根,保证检查/回调期间不会因源代码删关系而悬空。 let mount = vm .upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("scroll Mount expired"))?; + // 暂存滚动处理错误,以便退出前无条件释放升级得到的 Mount 根。 let result = (|| { + // 读取这一具体出现位置的几何,不使用共享 Block 上的全局坐标。 let data = vm.payload::(mount, "Mount")?; + // scroll 必须有指针位置,才能验证是否仍处于裁剪与页面视口内。 let point = input .point .ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?; @@ -1425,13 +1678,17 @@ impl UiBridge { { bail!("scroll targets an obsolete or clipped Mount"); } + // 屏幕增量还需转换成 Mount 所属空间,不能直接当作内容滚动的逻辑距离。 let delta = input .delta .ok_or_else(|| anyhow::anyhow!("scroll has no delta"))?; let Some(delta) = data.geometry.logical_delta(input.point, delta) else { + // 投影不可逆/无法形成有效局部增量时忽略此轮,不伪造一个零或屏幕空间增量。 return Ok(()); }; + // 通过托管方法保留滚动协议,调用预算单独有界;方法根与结果根随后各自释放。 let method = vm.bind_attr(mount, "scrollBy")?; + // 数量/类型之外的滚动规则交给该方法;临时绑定值在 result 成功/失败两种情况下都释放。 let result = match method { Value::Object(function) => vm.call_with_limits( function, @@ -1450,6 +1707,7 @@ impl UiBridge { vm.release_value(result?)?; Ok(()) })(); + // 滚动失败也清理临时根;只有释放后才把保存的 result 交回运行时。 vm.release(mount)?; return result; } @@ -1459,24 +1717,30 @@ impl UiBridge { .iter() .any(|point| !surface.contains(*point, viewport)) { + // 按下或释放任一点已离开可见页,不能仅凭旧 Mount 身份触发点击。 return Ok(()); } let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)? else { + // resolve 会运行真实源 hitTest;没有共同有效命中目标是正常无事件结果。 return Ok(()); }; mount } else { + // 无坐标输入已有捕获目标,升级弱身份后仍要在下面检查 mounted/interactive。 vm.upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("Mount has expired"))? }; + // 同一所有权模式覆盖三维拾取、指针和普通事件:保存任何错误,最后统一 release(mount)。 let result = (|| { + // 复制 payload 结束 VM 只读借用,后续分配事件或执行用户方法需要可变借用 VM。 let data = vm.payload::(mount, "Mount")?.clone(); // 3D 视口点击先生成 RayHit3D 并附到 Event.hit,再沿视口出现传播,不能只按二维外框假定击中某网格。 if data.geometry.scene3d_viewport && input.kind == "click" && let Some(point) = input.point { + // 用原生 callable 承接 3D 拾取与事件传播;captures 显式保活 Mount 与 desktop。 let function = vm.allocate(ContextNativeFunction::new( "3D pointer click", crate::scene3d_model::PointerPick { @@ -1486,6 +1750,7 @@ impl UiBridge { }, vec![mount, desktop], ))?; + // 先暂存结果,释放临时 callable 根后再传播错误,保持失败路径也无额外根残留。 let result = vm.call_with_limits( function, &[], @@ -1499,15 +1764,19 @@ impl UiBridge { return Ok(()); } if !data.mounted { + // 对象可达但已经卸载,不能把旧事件传给仍被其他地方引用的业务 Block。 bail!("event targets an obsolete presentation"); } if !data.geometry.interactive { + // 几何可能是装饰或明确禁交互;不能因为外框命中就默认接受所有输入。 bail!("Mount does not accept input"); } if input.kind.starts_with("pointer") { + // 原始指针事件用于图形交互,保留具体 kind 与坐标,不转换为高层 click。 let p = input .point .ok_or_else(|| anyhow::anyhow!("pointer needs point"))?; + // PointerDispatch 在 VM 中执行并显式捕获所有托管依赖,不从 GUI 线程运行源处理器。 let f = vm.allocate(ContextNativeFunction::new( "native pointer event", crate::bridge::graphics::PointerDispatch { @@ -1519,6 +1788,7 @@ impl UiBridge { }, vec![data.block, mount, desktop], ))?; + // 一次有界的 VM 调用完成指针分派;先释放 callable 根,再传播执行错误。 let result = vm.call_with_limits( f, &[], @@ -1538,12 +1808,15 @@ impl UiBridge { Value::Number((data.geometry.id as i64).into()), )?; vm.set_attr(desktop, "focusRequested", Value::Bool(true))?; + // click 的 receiver 是 Block,Mount 作为出现上下文;focus 的 receiver 则直接是 Mount。 let (owner, name, args) = match input.kind.as_str() { "click" => (data.block, "click", vec![Value::Object(mount)]), "focus" => (mount, "requestFocus", vec![]), _ => bail!("unsupported native event"), }; + // 保留源方法覆盖;临时绑定值需要在调用成功与失败两种情况下都释放。 let bound = vm.bind_attr(owner, name)?; + // 临时绑定保持 receiver;错误同样先保存,释放绑定值后才传播。 let result = match &bound { Value::Object(function) => vm.call_with_limits( *function, @@ -1562,49 +1835,60 @@ impl UiBridge { vm.release(mount)?; result } - /// 整理不再有效的弱索引与展示根,并配合 VM 回收,避免派生缓存延长业务对象生命周期。 + /// endor_host 在执行/清理边界调用,压缩节点弱表和每窗快照缓存;不改写业务对象拥有关系。 pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec) -> Result<()> { // 先回收托管环,再扫描弱节点表,只保留能升级的身份;render_cache.prune 同步清除死亡业务索引。 vm.collect_cycles(); + // VM 环回收结束后再压缩原生弱表,避免用回收前仍能升级的状态保留多余缓存索引。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 重建表只保留当前能升级的弱身份;不能把升级得到的临时强根永久留在宿主索引中。 let mut live = Vec::new(); for weak in &state.nodes { if let Some(object) = vm.upgrade(*weak)? { + // 升级是有拥有权的 VM 操作,探测结束立即释放,再保存原弱引用。 vm.release(object)?; live.push(*weak); } } state.nodes = live; for presentation in state.presentations.values_mut() { + // 同一业务节点可能缓存于多个窗口,逐窗剪枝,避免只清理 current 的派生状态。 presentation.render_cache.prune(vm.object_store()); } Ok(()) } - /// 丢弃未成功发布的展示请求,恢复后续输入可以使用的会话状态。 + /// endor_host 的输入/刷新失败路径调用,修复未发布 pending;不会回滚 REPL 中已成功完成的业务赋值。 pub fn discard_pending(&self, vm: &mut Vm) -> Result<()> { + // 修复宿主 pending/注册关系,不运行用户方法;因此持锁逐窗处理不会进入回调重入路径。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; for presentation in state.presentations.values_mut() { + // 真实 Window 注册表是归属依据,不能单靠失败前的 Presentation.active 恢复页面。 let window = object(Some(&vm.get_attr(presentation.desktop, "window")?))?; // A failed first render must not become visible on the next input // merely because show() already registered its Page with the Window. // 第一次 render 失败时撤销尚未发布的 Page 登记,避免下一次无关输入把失败页意外显示出来。 if presentation.active.is_none() && presentation.last_tabs.is_empty() { + // 从未发布过的窗口不保留失败 render 偶然注册的 Page;拷贝后逐项移除,避免迭代时改集合。 let unpublished = crate::window::pages(vm.object_store(), window)?.to_vec(); for page in unpublished { + // 使用宿主撤销入口,不再执行可能再次失败或产生副作用的源页面方法。 crate::window::unregister_page_host(vm, window, page)?; } } + // 撤销首次失败登记后重新读表;先前成功的 close/move 仍可能已改变现有窗口的拥有关系。 let pages = crate::window::pages(vm.object_store(), window)?; // 失败输入已成功执行的关闭/迁移语句不回滚;重新选择仍在真实窗口注册表中的页,修复 pending 与拥有关系。 let next = if presentation.closing { + // 正在关闭的资源不能为了恢复画面而重新挑一页继续展示。 None } else { + // 原 active 仍登记就保留,否则选择当前真实列表首项;空表自然得到 None。 presentation .active .filter(|page| pages.contains(page)) @@ -1614,8 +1898,10 @@ impl UiBridge { // Repair ownership changes before the next publication so a closed or // moved Page cannot survive in an old context's mount dictionary. presentation.pending = if next != presentation.active || presentation.closing { + // Some(None) 是明确卸载旧页,不等同于无刷新请求的 None。 Some(next) } else { + // 无需改变活动页时清掉失败请求,避免后续输入重复展示失败候选。 None }; vm.set_attr( @@ -1631,33 +1917,40 @@ impl UiBridge { .active .is_some_and(|page| vm.object(page).is_err()) { + // active 的托管对象已不存在时去除 Rust 借用句柄,不能让下一轮继续拿它提取快照。 presentation.active = None; } } Ok(()) } - /// 重新评估已失效的属性绑定,并把结果计入会话统计。 + /// endor_host 在刷新稳定循环中调用,先只读计算失效绑定,再由宿主应用目标属性;随后 prepare_frame 才排空这些变化。 pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> { // 只在锁内快照弱节点与宿主桌面集合,实际绑定回调在锁外执行,避免回调访问 UI 时死锁。 let (nodes, desktops) = { + // 锁内只复制弱索引与排除集合,锁外 binding::refresh 才真正执行源计算。 let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; (state.nodes.clone(), state.host_desktops()) }; + // binding::refresh 在所属 VM 中运行受控计算并返回实际评估次数,不把节点总数当作工作量。 let evaluated = crate::binding::refresh(vm, &nodes, &desktops)?; + // 计算回调已返回,重新短暂加锁累计次数,不把会话锁贯穿用户代码。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 诊断计数饱和增长,不让长期会话的统计溢出反过来打断有效程序执行。 state.binding_evaluations = state.binding_evaluations.saturating_add(evaluated); Ok(()) } - /// 排空一次 VM 变化记录并通知各窗口,提取活动页面快照,判断本次是否需要布局与发布。 + /// endor_host 每轮刷新调用,广播 VM 变化后为 current 决定无工作、空帧或带视觉根的新一轮布局。 pub fn prepare_frame(&self, vm: &mut Vm) -> Result>>> { // 一次排空变更必须广播所有 Presentation,不能让先刷新的窗口吃掉其他窗口共享业务数据的失效通知。 + // 此处消费的是 VM 变化日志,不是各窗口的刷新请求;observe_changes 会把影响保存在各自缓存/force_frame 中。 let changes = vm.take_changes(); + // 本锁内广播变化与提取只读快照,保证 current 根与各窗口的失效观察来自同一批更新。 let mut state = self .session .lock() @@ -1667,8 +1960,10 @@ impl UiBridge { // One VM change drain feeds every presentation before selecting a root. // Otherwise a window refreshed first could consume another window's work. for presentation in state.presentations.values_mut() { + // 每个窗口都必须先观察同一批变化,即使本轮最后只为 current 提取一个根。 presentation.observe_changes(vm, &changes); } + // 没页面得到 Ok(None);有页面但快照验证失败则传播 Err,不能伪装成正常空帧。 let root = active .map(|v| state.render_cache.snapshot(vm.object_store(), v)) .transpose()?; @@ -1678,11 +1973,14 @@ impl UiBridge { (None, None) => true, _ => false, }; + // 焦点请求是宿主控制状态,视觉树即使复用同一 Arc 也需要一次完整帧处理。 let focus_changed = matches!( vm.get_attr(state.desktop, "focusRequested"), Ok(Value::Bool(true)) ); + // 标签标题/顺序可独立于内容根变化,所以投影后与上次提交结果单独比较。 let tabs = project_tabs(vm, &mut state)?; + // 汇总显式展示请求、视觉树、焦点/滚动及强制失效;任一变化都不能用 Arc 相同快速跳过。 let changed = state.pending.is_some() || !same || focus_changed @@ -1693,7 +1991,9 @@ impl UiBridge { || state.force_frame || tabs != state.last_tabs; if changed { + // 只在确有新帧工作时记录快照诊断,避免空闲轮询覆盖最后一次有效失效原因。 if !same { + // 根身份变化才归因于 snapshot;仅标签/焦点变更不伪造快照失效记录。 state.last_invalid_dependency = format!("snapshot {}", state.render_cache.last_invalidation); } @@ -1703,29 +2003,36 @@ impl UiBridge { // 双层 Option 三态:外 None 无工作;Some(None) 需提交空帧;Some(Some(root)) 需布局/绘制根。 Ok(changed.then_some(root)) } - /// 提交已经完成的出现几何与快照,并处理展示生命周期;不把挂起或失败的布局当成已发布帧。 + /// 布局完成路径调用,提交桥接内部几何和生命周期,供后续 paint 使用;外部发布由 endor_host 稳定循环完成。 pub fn commit_frame( &self, vm: &mut Vm, snapshot: Option>, frame: &crate::document::Frame, ) -> Result<()> { + // 同步 Mount 和更新桥接状态时需要排他访问;调用用户 hooks 前显式 drop(state)。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // pending 的 Some(None) 要清空展示,不能简单 or(active) 又把旧页取回来。 let page = state.pending.unwrap_or(state.active); + // synchronize 必须使用本轮选中窗口的私有资源域,而不是 master 的全局注册表。 let desktop = state.desktop; + // 同步过程可能升级节点,用弱表快照避免同时借用 Session 的其他可变字段。 let nodes = state.nodes.clone(); // 提交几何同步清除临时 layout Context,并产生挂载/卸载序列;提交桥接状态不等于已写 GUI 共享文档。 let hooks = state .mounts .synchronize(vm, desktop, page, nodes, frame, false)?; + // 暂存提交/钩子错误,无论中途哪一步失败都经过下方 hooks 根释放。 let result = (|| { if snapshot.is_none() { + // 空帧不应残留旧出现几何,否则下一次输入可能命中已经退场的内容。 state.layout.clear_occurrences(); } if let Some(page) = state.pending.take() { + // 消费本轮请求;没有请求就沿用 active,不凭快照的 Option 擅自改业务页面身份。 state.active = page; } vm.set_attr(state.desktop, "pending", Value::Nil)?; @@ -1741,6 +2048,7 @@ impl UiBridge { // across user code. Publish only after the refresh loop settles. drop(state); if let Some(hooks) = hooks { + // 用户钩子可能再次修改页面,执行后 endor_host 仍会检查是否需要下一轮刷新。 let value = vm .call_with_limits( hooks, @@ -1756,12 +2064,13 @@ impl UiBridge { Ok(()) })(); if let Some(hooks) = hooks { + // 调用成功、VM 报错或提交字段写入失败都释放驱动拥有根,不能依赖 Rust Option 自动清理。 vm.release(hooks)?; } result } - /// 在已完成的几何上启动绘制协议,保留绘制任务所需的宿主上下文。 + /// endor_host 在 LayoutProgress::Complete 后调用,按帧的出现顺序启动第一片源 paint 工作。 pub fn begin_paint_frame( &self, vm: &mut Vm, @@ -1769,6 +2078,7 @@ impl UiBridge { frame: crate::document::Frame, ) -> Result { let (desktop, mounts) = { + // 取绘制顺序与弱身份只读 Session,source_paint::start 在锁外执行 VM 协议。 let state = self .session .lock() @@ -1778,6 +2088,7 @@ impl UiBridge { .mounts .iter() .map(|mount| { + // 以 frame.mounts 的顺序查活动弱身份;缺项代表布局/挂载不一致,必须报错而非漏画。 state .mounts .active @@ -1789,38 +2100,45 @@ impl UiBridge { .collect::>>()?; (state.desktop, mounts) }; + // 每片预算 256,由 source_paint 保留未完成续体;返回 Complete(Draft) 或 Pending(Work),两者不能当作同一种完成结果。 let progress = crate::source_paint::start(vm, desktop, mounts, root, frame, 256)?; self.accept_paint_progress(progress) } - /// 恢复同一次源绘制调用,把完成的 Draft 交回发布流程。 + /// endor_host 保存 PendingPaint 后调用,在同一 VM 上继续最多一片工作,不从头重放 paint 源方法。 pub fn resume_paint_frame( &self, vm: &mut Vm, work: crate::source_paint::Work, ) -> Result { + // Work 拥有前片续体和草稿,消费后返回新的挂起 Work 或最终完整 Draft。 let progress = work.resume(vm, 256)?; self.accept_paint_progress(progress) } - /// 区分挂起与完成的绘制进度;完成时保存真实读取依赖并接回发布流程。 + /// begin_paint_frame/resume_paint_frame 共用收尾,只有 Complete 才更新展示域的完整 paint 依赖。 fn accept_paint_progress( &self, progress: crate::source_paint::Progress, ) -> Result { + // 运行时调用前已选定窗口;这里不从 Draft 再选择 current,恢复调用也必须维持同一窗口上下文。 if let crate::source_paint::Progress::Complete(draft) = &progress { + // 借用 Draft 而非消费,调用方仍需把它交回运行时进行验证和文档发布。 + // 只有完整 Draft 写入依赖,Pending 不改变上一轮已提交读取集合。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; // 完成后过滤所有窗口私有 desktop 读取,再拆对象版本与名称依赖;挂起切片不覆盖上一份完成的 paint 依赖。 let desktops = state.host_desktops(); + // 私有 desktop 是宿主记账读,排除后才得到源程序真正依赖的属性/名字集合。 let reads = draft .reads .iter() .filter(|read| !desktops.contains(&read.object())) .cloned() .collect(); + // 按对象版本读取与命名查找拆分,下一轮 observe_changes 使用各自的失效判断。 let (reads, names) = crate::presentation::split_reads(reads); state.paint_reads = reads; state.paint_names = names; @@ -1829,7 +2147,7 @@ impl UiBridge { Ok(progress) } - /// 取消尚未完成的源绘制,释放其续体和未发布上下文。 + /// endor_host 的 resize/输入替换/退役路径调用,取消所属 VM 的 paint 续体并强制该窗口重新刷新。 pub fn cancel_paint_frame( &self, vm: &mut Vm, @@ -1838,6 +2156,7 @@ impl UiBridge { ) -> Result<()> { // 先尝试取消 VM 续体,再尝试选原窗口并强制下一帧;前一操作失败也执行必要的宿主失效准备。 let cancelled = work.cancel(vm); + // 这一步即使 cancelled 是 Err 也先尝试;否则失败的取消会留下“旧帧看似仍有效”的缓存状态。 let invalidated = self.select_window(window).and_then(|()| { self.session .lock() @@ -1845,22 +2164,25 @@ impl UiBridge { .force_frame = true; Ok(()) }); + // 所有必要清理尝试完成后再按顺序传播错误,不让前一个 ? 跳过后一个失效操作。 cancelled?; invalidated } - /// 为目标快照建立 Mount、检查点和布局上下文,再启动可挂起的只读布局求解。 + /// endor_host 从 prepare_frame 得到新根后调用,建立出现结构和恢复检查点,再开始有预算的源布局。 pub fn begin_layout_frame( &self, vm: &mut Vm, root: Option>, ) -> Result { + // 一个 VM 同时只能推进原来的续体;不能在仍只读/挂起时再开另一帧破坏调用与恢复上下文。 anyhow::ensure!( !vm.has_suspended_call() && !vm.object_store().is_read_only(), "cannot prepare another frame during suspended layout" ); // 显式空页面无需源布局,清测量/布局读取并推进空帧版本,再同步退役旧 Mount;不是无工作返回。 let Some(root) = root else { + // 空帧已没有可复用的测量结果和源布局依赖,分三项清理,不能继续引用旧页读取集合。 self.session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))? @@ -1876,11 +2198,13 @@ impl UiBridge { .map_err(|_| anyhow::anyhow!("UI session poisoned"))? .layout_names .clear(); + // 空帧仍有窗口、标签和新版本:GUI 必须能识别旧出现位置已全部退场。 let frame = { let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 不复用旧版本,否则旧输入可能被当成对应这个空页面的有效事件。 state.layout.version = state .layout .version @@ -1893,10 +2217,13 @@ impl UiBridge { ..crate::document::Frame::default() } }; + // 走共同提交路径退役 Mount/运行卸载钩子,不能仅返回一个默认空 Frame 留下旧生命周期。 self.commit_frame(vm, None, &frame)?; return Ok(LayoutProgress::Complete(Box::new(frame))); }; + // 准备私有布局草稿与缓存副本,之后的源调用在锁外运行;正式状态等完成时再写回。 let (mut layout, cache, desktop, page, nodes) = { + // 克隆布局/缓存作为本轮私有求解输入,page 同时保留显式清空请求;此处尚不消费 pending 或覆盖 active。 let state = self .session .lock() @@ -1909,6 +2236,7 @@ impl UiBridge { state.nodes.clone(), ) }; + // 标签投影会更新稳定 ID 映射,因此短暂可变借用 Session,而非沿用上面的只读锁。 let tabs = { let mut state = self .session @@ -1918,9 +2246,11 @@ impl UiBridge { }; // 记录真实原生页面视口供页面相机 clamp;兼容 Page.mode 只改变根约束,不永久篡改保存的窗口尺寸。 let full_viewport = layout.viewport; + // 根未声明特殊表面时采用默认页面模式;保存副本用于完成时添加范围和相机 clamp。 let surface = root.surface.clone().unwrap_or_default(); layout.page_mode = root.surface.as_ref().map(|surface| surface.mode); { + // 只借用当前全局出现身份下界,放锁后才在私有 layout 上创建结构;即使取消也不允许复用已暴露 ID。 let state = self .session .lock() @@ -1928,8 +2258,10 @@ impl UiBridge { // 按全会话高水位分配结构出现 ID,取消后也不回收已暴露过的数值身份。 layout.reserve_identity_floor(state.next_mount); } + // 先建立出现结构与 ID,尚未执行源 measure/arrange;结构供 Mount 准备与协议调用查上下文。 let mut frame = layout.structure(&root)?; { + // 本轮已暴露的 ID 即使后续取消也不能回收,所以马上推进全会话高水位,而非等最终提交。 let mut state = self .session .lock() @@ -1937,6 +2269,7 @@ impl UiBridge { state.next_mount = state.next_mount.max(layout.identity_high_watermark()); frame.window = state.current; } + // 同步准备阶段可能暂改 Mount;把可恢复检查点、延后钩子及有序弱身份一起交给后续工作。 let (hooks, mounts, checkpoint) = { let mut state = self .session @@ -1944,12 +2277,14 @@ impl UiBridge { .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; // 在准备态 synchronize 改写 Mount 前保留旧字典与 payload;准备失败立即恢复,不能留下半套父子关系。 let checkpoint = state.mounts.checkpoint(vm, desktop)?; + // true 表示准备模式:Mount 关系可供布局读取,但生命周期钩子延后,不在此执行用户代码。 let hooks = match state .mounts .synchronize(vm, desktop, page, nodes, &frame, true) { Ok(hooks) => hooks, Err(error) => { + // 即使结构准备失败也先恢复旧关系;恢复失败本身也必须作为错误报告。 checkpoint.restore(vm, desktop, &mut state.mounts)?; return Err(error.into()); } @@ -1967,36 +2302,42 @@ impl UiBridge { hooks, checkpoint, }; + // Draft 携带布局求解用的私有 runtime/cache;reads 从空开始收集本轮实际依赖。 let draft = crate::source_layout::Draft { frame, runtime: layout, cache, reads: Vec::new(), }; + // 暂存 Result 而不立即 ?,让 accept_layout_progress 在出错时仍能恢复 context 的检查点。 let progress = crate::source_layout::start(vm, desktop, mounts, root, draft, 256); self.accept_layout_progress(vm, context, progress) } - /// 记录本次刷新主动让出 worker 的次数,用于诊断分片执行。 + /// endor_host 在布局/绘制挂起让出 worker 时调用,累计诊断次数,不改变下一片执行预算。 pub fn note_refresh_yield(&self) -> Result<()> { + // 本锁仅保护跨窗口共享的诊断累计值,不恢复续体、不选择窗口,也不改变本轮是否需要发布。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 统计饱和而非溢出报错,避免长期运行时仅因诊断计数达到上限而中断。 state.refresh_yields = state.refresh_yields.saturating_add(1); Ok(()) } - /// 记录视口目标替换导致的刷新重启,便于区分正常挂起与取消重试。 + /// endor_host 重定向刷新时调用,分别记录重启以及本轮是否确实取消 layout/paint,供 stats 诊断。 pub fn note_refresh_restart( &self, cancelled_layout: bool, cancelled_paint: bool, ) -> Result<()> { + // 三个统计在同一短锁内更新,让 stats 不会看到“重启已记账而对应取消尚未记账”的中间值。 let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; + // 一次重启不一定同时取消两个阶段;布尔转 usize 让各计数只增加实际发生的事件。 state.refresh_restarts = state.refresh_restarts.saturating_add(1); state.layout_cancels = state .layout_cancels @@ -2007,8 +2348,9 @@ impl UiBridge { Ok(()) } - /// 撤销未完成布局所保留的窗口、挂载与生命周期上下文。 + /// cancel_layout_frame/布局出错路径调用,恢复所属窗口的检查点并释放尚未执行的生命周期驱动。 fn discard_layout_context(&self, vm: &mut Vm, context: LayoutContext) -> Result<()> { + // 消费 context 后只提取恢复所需的资源,树/标签等纯 Rust 值自然释放,不执行用户回调。 let LayoutContext { surface: _, desktop, @@ -2020,21 +2362,26 @@ impl UiBridge { // 恢复必须在原窗口;若窗口或 session 锁已不可用,至少释放检查点拥有根,不能尝试恢复到另一个窗口。 let selected = self.select_window(window); let restored = if selected.is_ok() { + // 选窗成功也可能锁损坏,分别处理“可恢复”和“只能释放检查点根”两种情况。 match self.session.lock() { Ok(mut state) => { + // restore 还原准备阶段暂改的 Mount 字典/payload;随后强制下一帧重新建立几何。 let result = checkpoint.restore(vm, desktop, &mut state.mounts); state.force_frame = true; result.map_err(anyhow::Error::from) } + // 锁不可用时仍先释放 VM 拥有根,再报告会话损坏,不能泄漏也不能假装恢复成功。 Err(_) => checkpoint .release(vm) .map_err(anyhow::Error::from) .and(Err(anyhow::anyhow!("UI session poisoned"))), } } else { + // 原窗口已不可选择,不拿 current 的其他窗口去恢复旧检查点,仅清理拥有权。 checkpoint.release(vm).map_err(anyhow::Error::from) }; // 无论选择/恢复是否失败都先尝试释放延迟 hooks;保存结果后再按优先级传播错误。 + // 不运行待取消的钩子;Option 转置后保存错误,确保上面的恢复尝试已完成。 let released = hooks.map(|hooks| vm.release(hooks)).transpose(); selected?; restored?; @@ -2042,8 +2389,9 @@ impl UiBridge { Ok(()) } - /// 取消源布局续体并清理对应上下文,保持已发布帧可用。 + /// endor_host 取消/重定向刷新时调用,分别取消 owning VM 续体与恢复准备态 Mount,保留外部已发布帧。 pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> { + // Work 和 Context 绑定为一对,不能只取消 VM 调用而丢下临时挂载关系。 let PendingLayout { work, context } = pending; // 取消只读执行与恢复 Mount 是两件事,均先尝试;没有继续执行原续体时也不能漏掉检查点恢复。 let cancelled = work.cancel(vm); @@ -2052,31 +2400,34 @@ impl UiBridge { restored } - /// 关闭失败布局的挂起工作并恢复检查点;供错误或候选撤销路径使用。 + /// endor_host 撤销失败候选布局时调用,与正常取消共用同一清理实现,不另起一轮布局回放源代码。 pub fn abort_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> { self.cancel_layout_frame(vm, pending) } - /// 继续同一次尚未发布的布局任务;完成时沿原上下文收尾,不能新建一轮来重放副作用。 + /// endor_host 持有 PendingLayout 后调用,在同一 VM、原窗口和原检查点上恢复最多一片布局工作。 pub fn resume_layout_frame( &self, vm: &mut Vm, pending: PendingLayout, ) -> Result { + // 解包只转移拥有权,仍将这一份 context 原样传到完成/错误收尾,不能重建新的检查点。 let PendingLayout { work, context } = pending; // 恢复时原窗口不存在则取消 owning VM 调用并清上下文,不重新建窗口或重放源布局。 if let Err(error) = self.select_window(context.window) { + // 先保存原选择错误,取消与恢复均尝试执行,最后按清理结果和原错误顺序返回。 let cancelled = work.cancel(vm); let restored = self.discard_layout_context(vm, context); cancelled?; restored?; return Err(error); } + // 不在这里用 ? 丢掉错误,否则失败续体配套的 context 没有机会恢复/释放。 let progress = work.resume(vm, 256); self.accept_layout_progress(vm, context, progress) } - /// 接收布局的 Pending/Complete 结果;保留上下文或执行最终投影和提交。 + /// begin_layout_frame/resume_layout_frame 共用入口,把源布局结果转成桥接进度,集中处理恢复和清理。 fn accept_layout_progress( &self, vm: &mut Vm, @@ -2092,22 +2443,25 @@ impl UiBridge { }))) } Ok(crate::source_layout::Progress::Complete(draft)) => self + // 源布局已结束;从这里开始的投影/提交错误由 finish_layout_frame 释放资源,不再走下方准备态恢复分支。 .finish_layout_frame(vm, context, draft) .map(|frame| LayoutProgress::Complete(Box::new(frame))), Err(error) => { + // 源布局失败不能发布 draft;先撤销准备态关系并释放检查点/钩子,再传播执行错误。 self.discard_layout_context(vm, context)?; Err(error) } } } - /// 依次投影滚动/视口、补页面元数据、保存读取版本、提交 Mount,并处理延后的生命周期调用。 + /// accept_layout_progress 收到 Complete 时调用,投影最终几何、保存依赖和提交 Mount,交回待绘制帧。 fn finish_layout_frame( &self, vm: &mut Vm, context: LayoutContext, draft: crate::source_layout::Draft, ) -> Result { + // 消费开始阶段保存的上下文,所有元数据都来自这一轮,不能临时从另一窗口重取。 let LayoutContext { mut surface, root, @@ -2118,15 +2472,18 @@ impl UiBridge { hooks, checkpoint, } = context; + // 消费完整求解结果;reads/cache 在生命周期回调前保存,才能发现钩子对布局依赖的新修改。 let crate::source_layout::Draft { mut frame, runtime: mut layout, mut cache, mut reads, } = draft; + // 把全部投影/提交错误保存在闭包结果中,外层始终尝试释放 hooks 和 checkpoint 的拥有根。 let result: Result = (|| { self.select_window(window)?; { + // select_window 已恢复 context.window,锁只覆盖读取该窗 Mount 数据和原生投影,源生命周期钩子稍后在锁外执行。 let state = self .session .lock() @@ -2141,16 +2498,19 @@ impl UiBridge { frame.tabs = tabs; frame.window = window; if let Some(root) = frame.mounts.first() { + // 首个出现是根几何,作为页面内容范围;没有出现时保留已有表面范围。 surface.extent = crate::layout::Size { width: root.width, height: root.height, }; } if let Some(viewport) = full_viewport { + // 用真实整页视口约束相机,不能用为特殊 Page.mode 改写过的局部布局约束。 surface.clamp(viewport); } frame.surface = surface; // Capture versions before lifecycle hooks can change a dependency. + // 排除所有窗口的宿主私有记账读取;共享原生方法可能捕获主 desktop,不能只排除 current。 let desktops = self .session .lock() @@ -2162,7 +2522,9 @@ impl UiBridge { // 源布局实际业务读取版本在生命周期 hooks 前保存,hooks 改写依赖后下一轮才能识别失效并重算。 reads.retain(|read| !desktops.contains(&read.object())); cache.exclude_host_desktops(&desktops); + // 对象版本读取和命名查找分开存储,供 Presentation.observe_changes 判断不同类别失效。 let (reads, names) = crate::presentation::split_reads(reads); + // 焦点输入只接受能表示为非负 u64 的数字,nil/错误类型自然表示没有焦点。 frame.focus = match vm.get_attr(desktop, "focus") { Ok(Value::Number(id)) => id.as_i64().and_then(|n| u64::try_from(n).ok()), _ => None, @@ -2172,6 +2534,7 @@ impl UiBridge { .focus .filter(|id| frame.mounts.iter().any(|m| m.id == *id && m.interactive)); { + // 生命周期回调前先写回本轮计算结果;回调产生的新变化随后由运行时刷新循环再观察。 let mut state = self .session .lock() @@ -2185,6 +2548,7 @@ impl UiBridge { // 几何先提交到桥接状态,再执行准备阶段延后的挂载 hooks;其变化由全窗口刷新循环再收敛。 self.commit_frame(vm, Some(root), &frame)?; if let Some(hooks) = hooks { + // 准备阶段暂存的驱动现在才执行;无论返回值业务上是否使用都需释放其 VM 根。 let value = vm .call_with_limits( hooks, @@ -2200,15 +2564,19 @@ impl UiBridge { Ok(frame) })(); // 收尾计算无论成功/失败,都先释放 hooks 与检查点的拥有根;只有所有必要清理通过才返回帧。 + // 两项释放都先求值,不能在 hooks 释放处直接 ? 而跳过 checkpoint 的清理。 let released_hooks = hooks.map(|hooks| vm.release(hooks)).transpose(); + // 此阶段完成/报错均消费检查点根;这里是释放,不是把已提交几何回滚到准备前。 + // 若投影、commit_frame 或延后 hooks 失败,result? 阻止帧进入 paint;endor_host 的失败路径另修复待展示关系。 let released_checkpoint = checkpoint.release(vm); + // 所有清理尝试结束后才传播主结果与释放错误,不把失败帧交给绘制/发布调用者。 let frame = result?; released_hooks?; released_checkpoint?; Ok(frame) } - /// 读取当前源程序已经选择的展示内容,为编译入口生成最终文档提供根。 + /// endor_host::compile 的兼容一次性执行路径调用,读取源程序安排展示的 Page 并提取不可变视觉树。 pub fn rendered(&self, vm: &Vm) -> Result>> { let state = self .session @@ -2224,13 +2592,14 @@ impl UiBridge { } } -/// 从会话的类型注册表取得指定类型,再给原生分配的对象附加托管类型身份。 +/// calls 创建状态/协议结果时调用,从当前会话取 desktop,再给对象附上指定的名义类型。 fn attach_named_type( cx: &mut NativeContext<'_>, object: ObjectRef, name: &str, session: &Shared, ) -> Result<(), VmError> { + // 仅复制资源域身份,释放锁后才操作 VM;不会把 Session 锁延伸到类型附加路径。 let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? @@ -2238,18 +2607,19 @@ fn attach_named_type( attach_desktop_type(cx, object, name, desktop) } -/// 按给定 desktop 查找类型并附加,支持不同窗口使用自己的展示上下文。 +/// bridge/model/runtime 的原生分配器调用,给已分配 payload 附上同一 desktop 注册的语言类型。 pub fn attach_desktop_type( cx: &mut NativeContext<'_>, instance: ObjectRef, name: &str, desktop: ObjectRef, ) -> Result<(), VmError> { + // 缺少类型条目直接返回错误,不能自动创建同名 Type 让实例与已有契约不兼容。 let definition = desktop_type(cx.store(), name, desktop)?; cx.attach_type(instance, definition) } -/// 读取 desktop 的托管类型注册表;缺失或类型错误时返回 VM 错误。 +/// 签名构造、能力检查和各分配器调用,查询 bootstrap 注册的精确 Type 身份,不按名字新建类型。 pub(crate) fn desktop_type( store: &endor_core::ObjectStore, name: &str, @@ -2260,10 +2630,12 @@ pub(crate) fn desktop_type( object(store.dict_get(types, &Value::Text(name.into()))?.as_ref()) } -/// 将当前托管或可变状态提取为后续阶段使用的派生表示。 +/// endor_host::compile 在 main 未通过页面 API 自行展示时调用,把 main 返回的 Block 提取成不可变根。 pub fn snapshot(vm: &Vm, value: &Value) -> Result> { + // 兼容 main 必须返回真实对象;标量值的 ui.open 回退规则不在这个直接快照接口中执行。 let Value::Object(root) = value else { bail!("main must return a Block"); }; + // block::snapshot 继续检查 payload/树语义;仅是 Value::Object 还不足以证明它可展示。 block::snapshot(vm.object_store(), *root).map_err(Into::into) } diff --git a/src/bridge/ui_bridge/bootstrap.rs b/src/bridge/ui_bridge/bootstrap.rs index bd4d557..3a494ee 100644 --- a/src/bridge/ui_bridge/bootstrap.rs +++ b/src/bridge/ui_bridge/bootstrap.rs @@ -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> 均不能替代它们。 pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { // 先建立 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) -> Result { ("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) -> Result { 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) -> Result { 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) -> Result { ("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) -> Result { 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) -> Result { .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) -> Result { 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) -> Result { { 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) -> Result { ("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) -> Result { 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) -> Result { // 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) -> Result { )?; } 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) -> Result { "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) -> Result { ), }; 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) -> Result { 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) -> Result { )?; } 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) -> Result { 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) -> Result { ), )?; } else { + // 这里只有 ObjectList;保留 desktop 捕获,以便专用分配器给结果附加列表类型。 vm.replace_payload( function, ContextNativeFunction::new( @@ -369,6 +426,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { } } } + // 对这批公开入口补 Function 类型身份;不重新分配 callable,ui 属性仍指向原对象。 for name in [ "open", "text", @@ -390,6 +448,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { "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) -> Result { ("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) -> Result { ))?; 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) diff --git a/src/bridge/ui_bridge/calls.rs b/src/bridge/ui_bridge/calls.rs index 59ad18c..064be71 100644 --- a/src/bridge/ui_bridge/calls.rs +++ b/src/bridge/ui_bridge/calls.rs @@ -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 { self.signature.clone() } - /// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。 + /// VM 查询自由函数的实参数量,或供转交适配器使用;这里不再给 receiver/Type 首参加一。 fn arity(&self) -> Option { Some(self.operation.arity()) } - /// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。 + /// VM 或外层原生调用适配器进入的实际业务分派,返回借用结果或委托;不负责 GUI 帧的原子发布。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { 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::(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::(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::(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::() && 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", "", 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::(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::(*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)) } diff --git a/src/bridge/ui_bridge/developer_pages.rs b/src/bridge/ui_bridge/developer_pages.rs index 9f8be78..0b3b210 100644 --- a/src/bridge/ui_bridge/developer_pages.rs +++ b/src/bridge/ui_bridge/developer_pages.rs @@ -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 { Some(endor_core::CallArity::exact(1)) } - /// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。 + /// VM 执行 ui.developerTools 时调用:New tab 委托源工厂,其余工具优先激活已有页面,否则创建标记页。 fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { + // 同时验证数量和字符串表示;不能把任意对象的可读表示当作工具名字接受。 let [Value::Text(name)] = args else { + // 错误发生在任何页面分配/登记之前,因此无须撤销一次半完成的工具打开。 return Err(invalid("developerTools requires a tool name")); }; // 工具名白名单包含四个管理页和特殊新标签动作,拒绝任意字符串进入工厂/工具标记路径。 if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) { + // 白名单错误交回 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( diff --git a/src/bridge/ui_bridge/page_input.rs b/src/bridge/ui_bridge/page_input.rs index 0eb6789..fbca725 100644 --- a/src/bridge/ui_bridge/page_input.rs +++ b/src/bridge/ui_bridge/page_input.rs @@ -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::(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::(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); diff --git a/src/bridge/ui_bridge/signatures.rs b/src/bridge/ui_bridge/signatures.rs index adf6a42..162d48b 100644 --- a/src/bridge/ui_bridge/signatures.rs +++ b/src/bridge/ui_bridge/signatures.rs @@ -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 { 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 { 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::, 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,参数注解与可变尾参分开保存;不在此处执行操作或分配业务对象。 diff --git a/src/bridge/ui_bridge/start_page.endor b/src/bridge/ui_bridge/start_page.endor index 73eb030..3fcf843 100644 --- a/src/bridge/ui_bridge/start_page.endor +++ b/src/bridge/ui_bridge/start_page.endor @@ -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 } diff --git a/src/bridge/ui_bridge/tree.rs b/src/bridge/ui_bridge/tree.rs index e930762..bca0141 100644 --- a/src/bridge/ui_bridge/tree.rs +++ b/src/bridge/ui_bridge/tree.rs @@ -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, + // 兼容 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, + // 父布局分配时需要预留的外边距,不作为孩子内容绘制的本地原点偏移。 pub margin: crate::layout::Insets, + // 两轴的自然/固定/填充尺寸规则,由盒模型参与测量和最终安排。 pub sizing: crate::box_model::Sizing, // 单边可选覆盖值;None 表示沿用 kind 的统一 padding,不代表边距为零。 pub padding_edges: [Option; 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, // 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。 pub interactive: bool, + // 标识原始 pointer 事件能力,与高层 click 的可交互标记分别参与输入分派。 pub pointer_events: bool, + // 具体叶/容器/场景内容,原生测量与软件绘制按此枚举选择规则。 pub kind: UiKind, } impl UiNode { - /// 按节点种类返回其视觉子项;叶节点使用空切片,避免为遍历另建组成。 + /// 布局、快照和命中结构遍历调用,返回已有孩子切片,不分配新列表或递归复制子树。 pub fn children(&self) -> &[Arc] { 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, + // 是否存在可用控制器,输入宽阶段据此判断相机手势能力。 controls: bool, + // 各世界 UI 面板的逻辑尺寸,与 children 次序对应,供投影与布局共用。 panels: Vec, + // 面板 UI 的普通二维树;业务对象身份仍由 UiNode.block_id/slots 描述。 children: Vec>, }, // 二维世界组成保留孩子的显式位置,不使用行列的累计偏移定位。 Scene2D { + // 每个孩子的世界 x/y,数组下标与 children 对齐,不按叠放顺序推算位置。 positions: Vec<[f32; 2]>, children: Vec>, }, // 有限布局盒子观察独立 Scene2D,相机仅影响投影,不改变场景节点原始逻辑位置。 CanvasViewport { + // 参加父布局的有限盒子尺寸;不是 Scene2D 孩子的总包围盒。 width: f32, height: f32, + // 纯数据相机快照,只影响本视口的世界到屏幕映射,不改写原世界树。 camera: crate::viewport::CameraView, children: Vec>, }, // 孩子重叠布置,顺序同时决定叠放次序;普通有限布局与世界 Canvas 不同。 Overlay { + // 统一内边距,单边覆盖仍由 UiNode.padding_edges 提供。 padding: f32, children: Vec>, }, // 旧 Page Canvas 模式的兼容投影,位置数组与孩子顺序一致,页面级 Surface 保存其相机。 Canvas { + // 页面根中每个孩子的业务位置,保持与 children 同长度同顺序;Surface 再统一变换到页面视图。 positions: Vec<[f32; 2]>, + // 业务组成提取的不可变子树;数组顺序决定遍历/绘制顺序,不按 x/y 自动排序。 children: Vec>, }, // 按钮是有子内容的容器,不把标签文本硬编码进 UiKind;交互资格另由 UiNode 标志表达。 Button { + // 内容与边框之间的统一内边距;单边覆盖仍优先使用 UiNode.padding_edges。 padding: f32, + // 多个内容孩子的主轴间距,不含父节点外 margin 或按钮边框厚度。 gap: f32, + // 标签只是其中一个普通 Text 子树,按钮没有独立的硬编码文字字段。 children: Vec>, }, // 文字叶节点保留源字号等级与颜色文本;排版及最终设备比例由后续布局/显示列表转换处理。 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>, }, + // 横排容器与 Column 使用相同盒模型,但 gap 和弹性分配沿水平主轴计算。 Row { + // 统一内边距,由 layout::insets 与单边覆盖/边框合成有效值。 padding: f32, + // 横向相邻孩子间距,不是孩子自身宽度或两边 margin 的替代值。 gap: f32, + // 源组成的逻辑顺序,不因视觉位置变化而重排 slot。 children: Vec>, }, // 无视觉内容的间隔叶节点,提供布局占用尺寸。 Spacer(f32), // 滚动盒保存有限分配尺寸、允许轴和普通内容;实际偏移按 Mount 出现分别维护,不放到共享节点里。 Scroll { + // axis 指定允许消费的滚动方向;实际可消费距离还取决于具体 Mount 的内容/视口范围。 axis: crate::scroll::Axis, + // 有限视口的自然尺寸;内容可能大于视口,溢出范围和偏移由布局与 Mount 派生。 width: f32, height: f32, + // 内容布局的统一内边距,与视口外 margin 分开。 padding: f32, + // 内容子项的纵向间隔,保持与原生滚动容器布局规则一致。 gap: f32, + // 当前内容树;滚动仅改变出现投影,不把移动后的坐标写入共享 UiNode。 children: Vec>, }, } diff --git a/src/main.rs b/src/main.rs index f698d0e..3b6523c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::::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() } diff --git a/src/model/binding.rs b/src/model/binding.rs index 395b572..8a94ab8 100644 --- a/src/model/binding.rs +++ b/src/model/binding.rs @@ -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::(*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, ) -> Result { // 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::(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?; } diff --git a/src/model/block.rs b/src/model/block.rs index 31b62e7..b363aa3 100644 --- a/src/model/block.rs +++ b/src/model/block.rs @@ -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, } @@ -71,6 +90,7 @@ impl ObjectPayload for Block { } /// 沿 Block 的共享 Cell 定位属性存储,使别名替换后读取到同一份新 Data。 fn attribute_target(&self, store: &ObjectStore) -> Result, 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, 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::() { 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, VmError> { + // VM 询问赋值是否共享 Cell 时,只接收另一 Block 包装;其他值交回常规赋值规则。 if !store.object(source)?.payload().as_any().is::() { 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, + // 容器统一内边距,单边 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, + // 子项外边距,参与父固有尺寸与分配,不包含在本项边框盒宽高中。 pub margin: crate::layout::Insets, + // 边框盒 auto/fill/显式尺寸及 min/max 限制,解码后由布局应用。 pub sizing: crate::box_model::Sizing, // 四边覆盖顺序为上、右、下、左;None 表示回退统一 padding,不等于零。 pub padding_edges: [Option; 4], + // 四边边框厚度,占据边框盒内部空间,并生成独立绘制命令。 pub border: crate::layout::Insets, + // 已验证颜色文本,供边框绘制使用;不是业务内容自身的填充色。 pub border_color: String, + // overflow=clip 时限制后代的绘制与命中,不自动赋予滚动能力。 pub clip_children: bool, + // CanvasViewport/Viewport3D 使用的相机强引用;共享相机需源代码显式复用对象。 pub camera: Option, + // 三维视口的 Scene3D 业务根,面板 UI 由场景提取流程展开。 pub scene3d: Option, + // 三维视口自定义控制器;提取时检查名义 update,输入路径才调用它。 pub controller: Option, + // 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, + // Page 当前导航历史位置,与 history 一起维护前进/后退关系。 pub history_index: usize, + // 目标拥有的属性连接,连接再持有 compute;取消令牌不反向保活目标。 pub bindings: Vec, + // 旧 textOf 源对象与字段名推导入口;通用 text 绑定建立后清除以避免竞争。 pub text_binding: Option<(ObjectRef, String)>, + // 兼容 onClick 直接回调,事件路径调用,GC 需保活该函数。 pub click: Option, + // 对象拥有的事件连接,派发按这些连接决定监听器资格。 pub subscriptions: Vec, // 源可读写的视觉字段存储;Data 自身保存结构边与构造默认值,提取时再将当前属性覆盖到派生副本。 pub properties: Option, @@ -185,6 +235,8 @@ impl Data { pub fn new(kind: Kind, children: Vec, 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 { + // 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 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