8 Commits

172 changed files with 5699 additions and 331 deletions
Generated
+132
View File
@@ -18,6 +18,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -92,6 +98,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -308,6 +320,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "crc32fast"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.23" version = "0.8.23"
@@ -434,12 +455,32 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]] [[package]]
name = "font-types" name = "font-types"
version = "0.11.3" version = "0.11.3"
@@ -779,6 +820,26 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -809,6 +870,45 @@ dependencies = [
"jni-sys 0.3.1", "jni-sys 0.3.1",
] ]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "num_enum" name = "num_enum"
version = "0.7.6" version = "0.7.6"
@@ -1104,6 +1204,11 @@ dependencies = [
[[package]] [[package]]
name = "objects" name = "objects"
version = "0.1.0" version = "0.1.0"
dependencies = [
"num-bigint",
"num-rational",
"num-traits",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
@@ -1201,6 +1306,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -1466,6 +1584,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "simd_cesu8" name = "simd_cesu8"
version = "1.2.0" version = "1.2.0"
@@ -1655,10 +1779,12 @@ dependencies = [
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"png",
"serde", "serde",
"serde_json", "serde_json",
"softbuffer", "softbuffer",
"tiny_http", "tiny_http",
"unicode-linebreak",
"unicode-segmentation", "unicode-segmentation",
"winit", "winit",
] ]
@@ -2325,6 +2451,12 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
+20
View File
@@ -1,3 +1,4 @@
# Cargo 包元数据与编译器最低版本;二进制入口默认取 src/main.rs。
[package] [package]
name = "tessera" name = "tessera"
version = "0.1.0" version = "0.1.0"
@@ -6,24 +7,43 @@ rust-version = "1.91"
description = "Cross-platform desktop rendering client" description = "Cross-platform desktop rendering client"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
# 跨平台运行依赖:语言执行、文字排版、序列化、窗口/像素表面与轻量 HTTP 服务。
# Aster 三个 crate 使用固定在本仓库 vendor 子模块中的本地源码,不从注册表选择同名实现。
[dependencies] [dependencies]
# 应用边界的错误聚合与上下文,保留底层失败原因。
anyhow = "1.0" anyhow = "1.0"
# Aster 字节码编译器,把前端结果交给同一套 VM 执行。
compiler = { path = "vendor/aster/crates/compiler" } compiler = { path = "vendor/aster/crates/compiler" }
# 系统字体发现、塑形与换行,用于测量和软件文字绘制。
cosmic-text = "0.19" cosmic-text = "0.19"
# 解码脚本加载的 PNG 图像,供通用精灵绘制与透明轮廓计算。
png = "0.18"
# 按 Unicode 断行机会准备可复用文字片段,供排除区域绕排。
unicode-linebreak = "0.1.5"
# Aster 托管对象、类型系统、VM 和可恢复调用;依赖包名 core 在 Rust 中重命名为 endor_core。
endor_core = { package = "core", path = "vendor/aster/crates/core" } endor_core = { package = "core", path = "vendor/aster/crates/core" }
# Endor 源语法解析与原始位置诊断。
frontend = { path = "vendor/aster/crates/frontend" } frontend = { path = "vendor/aster/crates/frontend" }
# 派生序列化协议,rc 特性允许发布模型中的 Arc/Rc 数据参与序列化。
serde = { version = "1.0", features = ["derive", "rc"] } serde = { version = "1.0", features = ["derive", "rc"] }
# HTTP 请求/响应与事件信封的 JSON 编解码。
serde_json = "1.0" serde_json = "1.0"
# 把宿主软件光栅化得到的像素缓冲呈现到原生窗口。
softbuffer = "0.4.8" softbuffer = "0.4.8"
# 本地 Agent API 的 HTTP 监听与请求响应,不引入异步 Web 运行时。
tiny_http = "0.12" tiny_http = "0.12"
# 按 Unicode 字素边界处理编辑/选择,避免把组合字符拆开。
unicode-segmentation = "1.12" unicode-segmentation = "1.12"
# 跨平台窗口、事件循环、DPI 与输入法事件。
winit = "0.30.13" winit = "0.30.13"
# 仅 macOS 编译原生标题栏适配,显式开启所用 AppKit/Foundation API,其他平台不引入这些依赖。
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5.2" objc2 = "0.5.2"
objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSApplication", "NSButton", "NSEvent", "NSView", "NSWindow"] } objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSApplication", "NSButton", "NSEvent", "NSView", "NSWindow"] }
objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry", "NSString", "NSUserDefaults"] } objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry", "NSString", "NSUserDefaults"] }
# 发布构建:thin LTO 与单代码生成单元偏重最终优化,strip 去除可执行文件符号以控制体积。
[profile.release] [profile.release]
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
+8
View File
@@ -22,6 +22,14 @@ experimental `namespace` / `export scene` / `render` parser has been removed.
Host UI integration lives in Tessera; Aster needs no Tessera-specific changes. Host UI integration lives in Tessera; Aster needs no Tessera-specific changes.
Lyra CLI and Hestia Server are independent hosts and are not application dependencies. Lyra CLI and Hestia Server are independent hosts and are not application dependencies.
## Endor manuscript example
[illustrated-manuscript.endor](examples/illustrated-manuscript.endor) implements
cursor-following dragon animation, small directional fire and text exclusion in
ordinary Endor. It uses generic graphics resources, source paint, pointer events
and Page-owned Tasks; no native demo mode is required. See [run instructions,
script walkthrough and validation](docs/illustrated-manuscript.md).
## Native build and start ## Native build and start
Use Rust 1.91 or newer. Initialize the source submodule following the dedicated Use Rust 1.91 or newer. Initialize the source submodule following the dedicated
+65 -8
View File
@@ -1,6 +1,6 @@
# Tessera 中文源码注释与阅读索引 # Tessera 中文源码注释与阅读索引
本轮只为 Tessera 主项目补充注释,Aster 子模块固定版本与内容均沿用主线。注释按模块职责、类型/函数入口、关键字段、执行阶段和失败/取消路径组织;没有改变接口、算法、常量或资源预算。 本轮进一步细化 Tessera 自有代码的中文注释:覆盖所有 Rust 模块、运行时/API 测试、内置起始页、45 个 Endor 示例、Cargo 依赖配置及两个工具脚本。说明聚焦具体输入/输出、状态归属、计算步骤、依赖观察、取消/失败路径和资源预算;不修改接口、算法、常量或非注释程序文本。Aster 是独立维护的 vendor 子模块,其固定版本与内容沿用主线。
## 建议阅读顺序 ## 建议阅读顺序
@@ -24,7 +24,7 @@
## Rust 逐文件说明 ## Rust 逐文件说明
每行说明该文件本次新增注释解释的职责和目的。生产函数/方法均增加中文入口说明;复杂状态机另外补充阶段和不变量说明。集成测试文件增加整体阅读与检查点说明,测试程序文本和源码范围断言保持原样。 每行说明该文件本次深化注释的职责和目的。生产函数/方法的入口说明与内部关键逻辑互相补充;VM 可调用适配器说明实际参数约定,而非仅重复 trait 名称。集成测试中的每个顶层测试/辅助函数都有具体场景和断言说明,测试程序字符串与断言保持原样。
| 文件 | 注释解释的职责与目的 | | 文件 | 注释解释的职责与目的 |
| --- | --- | | --- | --- |
@@ -73,7 +73,7 @@
| [src/runtime/api/tests.rs](../src/runtime/api/tests.rs) | API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 | | [src/runtime/api/tests.rs](../src/runtime/api/tests.rs) | API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 |
| [src/runtime/api/worker.rs](../src/runtime/api/worker.rs) | 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。 | | [src/runtime/api/worker.rs](../src/runtime/api/worker.rs) | 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。 |
| [src/runtime/endor_host.rs](../src/runtime/endor_host.rs) | 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。 | | [src/runtime/endor_host.rs](../src/runtime/endor_host.rs) | 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。 |
| [src/runtime/endor_host/tests.rs](../src/runtime/endor_host/tests.rs) | 运行时集成回归与示例验收。测试经真实 Aster 前端、编译器、VM 和展示协议执行,不是手工伪造布局结果。 | | [src/runtime/endor_host/tests.rs](../src/runtime/endor_host/tests.rs) | 说明全部运行时测试和辅助函数的场景/断言,并解释新主线性能基准的 DPI 分组、预热、三类计时边界和统计方式。 |
| [src/runtime/endor_script.rs](../src/runtime/endor_script.rs) | 区分完整 Module 与顶层 Script,并保留输入源码位置。 | | [src/runtime/endor_script.rs](../src/runtime/endor_script.rs) | 区分完整 Module 与顶层 Script,并保留输入源码位置。 |
| [src/runtime/event_dispatch.rs](../src/runtime/event_dispatch.rs) | 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 | | [src/runtime/event_dispatch.rs](../src/runtime/event_dispatch.rs) | 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 |
| [src/runtime/events.rs](../src/runtime/events.rs) | 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 | | [src/runtime/events.rs](../src/runtime/events.rs) | 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 |
@@ -106,11 +106,68 @@
| 文件 | 本次改动及目的 | | 文件 | 本次改动及目的 |
| --- | --- | | --- | --- |
| [src/bridge/ui_bridge/start_page.endor](../src/bridge/ui_bridge/start_page.endor) | 解释卡片源测量/背景、页面工厂、纵横布局与滚动组合、共享计数和场景位置,便于从真实 Endor 页面理解宿主边界。 | | [src/bridge/ui_bridge/start_page.endor](../src/bridge/ui_bridge/start_page.endor) | 解释卡片源测量/背景、页面工厂、纵横布局与滚动组合、共享计数和场景位置,便于从真实 Endor 页面理解宿主边界。 |
| [scripts/build-macos-app.sh](../scripts/build-macos-app.sh) | 说明本地构建前提、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 | | [scripts/build-macos-app.sh](../scripts/build-macos-app.sh) | 说明 macOS 构建环境前提、失败退出、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 |
| [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 | | [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 |
| [src/README.md](../src/README.md) | 增加本注释索引入口,便于从源码目录开始阅读。 | | [Cargo.toml](../Cargo.toml) | 解释每个依赖负责的能力、Aster 本地路径依赖、macOS 条件依赖与 release 优化配置。 |
| [docs/code-comments.md](code-comments.md) | 本逐文件说明与阅读顺序,覆盖本轮提交的文件及共同边界。 | | [docs/code-comments.md](code-comments.md) | 更新本逐文件说明、示例索引和实际核对方式,使本次注释范围与可验证结果对应。 |
## 核对方式 ## Endor 示例逐文件说明
检查差异仅新增独立注释行和注释相关空白,并与主线去除注释后的文本对比;同时运行格式、完整 GUI 回归、Clippy 和包含私有项的 rustdoc。脚本使用语法检查,不执行文档渲染或应用打包。具体检查结果在 PR 中记录。 每个示例的类、方法及关键逻辑旁增加中文说明。`page.endor` 和 `hello.endor` 被测试固定断言源码行号,因此使用行尾注释,保留原始行数;其余示例使用独立注释行,程序符号与字面量不变。
| 文件 | 注释解释的场景与目的 |
| --- | --- |
| [examples/async-file.endor](../examples/async-file.endor) | 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。 |
| [examples/async-network.endor](../examples/async-network.endor) | 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。 |
| [examples/await-file.endor](../examples/await-file.endor) | 把 callback-free 文件 Task 与 ui.spawn 协作函数组合:await 保存执行位置,等待完成后继续,不阻塞 VM worker。 |
| [examples/box-layout.endor](../examples/box-layout.endor) | 盒模型组合:容器与 Page/相机独立,border-box 尺寸、padding、margin 和对齐分别参与布局。 |
| [examples/campus-lms.endor](../examples/campus-lms.endor) | 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。 |
| [examples/classes.endor](../examples/classes.endor) | 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。 |
| [examples/constructor-contracts.endor](../examples/constructor-contracts.endor) | 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。 |
| [examples/cooperative-tasks.endor](../examples/cooperative-tasks.endor) | 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。 |
| [examples/events.endor](../examples/events.endor) | 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。 |
| [examples/exit-lifecycle.endor](../examples/exit-lifecycle.endor) | 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。 |
| [examples/field-contracts.endor](../examples/field-contracts.endor) | 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。 |
| [examples/flex-constraints.endor](../examples/flex-constraints.endor) | flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。 |
| [examples/flex.endor](../examples/flex.endor) | 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。 |
| [examples/fonts.endor](../examples/fonts.endor) | 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。 |
| [examples/function-types.endor](../examples/function-types.endor) | Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。 |
| [examples/gc-lifecycle.endor](../examples/gc-lifecycle.endor) | 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。 |
| [examples/global-dependencies.endor](../examples/global-dependencies.endor) | 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。 |
| [examples/hello.endor](../examples/hello.endor) | 解释辅助 heading、兼容 main 入口、局部业务引用和原生布局,保留源码位置夹具。 |
| [examples/layout-cache.endor](../examples/layout-cache.endor) | 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。 |
| [examples/layout-negotiation.endor](../examples/layout-negotiation.endor) | 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。 |
| [examples/main.endor](../examples/main.endor) | 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。 |
| [examples/managed-types.endor](../examples/managed-types.endor) | 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。 |
| [examples/method-contracts.endor](../examples/method-contracts.endor) | 方法覆盖遵守参数可更宽、返回可更具体的规则。 |
| [examples/mount-lifecycle.endor](../examples/mount-lifecycle.endor) | 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。 |
| [examples/native-signatures.endor](../examples/native-signatures.endor) | 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。 |
| [examples/native-subclasses.endor](../examples/native-subclasses.endor) | 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。 |
| [examples/owned-tasks.endor](../examples/owned-tasks.endor) | owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。 |
| [examples/page.endor](../examples/page.endor) | 解释顶层脚本创建文字/矩形、Page 与 render 的关系,保留四行结构树夹具。 |
| [examples/presentable.endor](../examples/presentable.endor) | 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。 |
| [examples/property-bindings.endor](../examples/property-bindings.endor) | 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。 |
| [examples/scene-viewports.endor](../examples/scene-viewports.endor) | 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。 |
| [examples/scene3d.endor](../examples/scene3d.endor) | 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。 |
| [examples/scroll-axis.endor](../examples/scroll-axis.endor) | 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。 |
| [examples/scroll.endor](../examples/scroll.endor) | 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。 |
| [examples/source-hit.endor](../examples/source-hit.endor) | 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。 |
| [examples/source-layout.endor](../examples/source-layout.endor) | 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。 |
| [examples/source-paint.endor](../examples/source-paint.endor) | 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。 |
| [examples/student-grades.endor](../examples/student-grades.endor) | 学生成绩中心为纯内存演示,记录均虚构,不连接学校数据库。 |
| [examples/tab-placement.endor](../examples/tab-placement.endor) | 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。 |
| [examples/tabs.endor](../examples/tabs.endor) | 代码与原生侧栏都操作真实 Page 对象;activate/close/open 改变展示关系,业务对象可继续被全局引用保留。 |
| [examples/visual-protocols.endor](../examples/visual-protocols.endor) | 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。 |
| [examples/window.endor](../examples/window.endor) | 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。 |
| [examples/windows.endor](../examples/windows.endor) | 两个原生窗口共用一个 Aster VM 和 count 业务对象,各自拥有独立 Page 与 Mount。 |
| [examples/workspace.endor](../examples/workspace.endor) | 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。 |
| [examples/world-ui.endor](../examples/world-ui.endor) | 世界空间 UI:普通 Endor 树先在独立 UI 分辨率中布局/绘制,再作为 Panel3D 平面的纹理参与三维深度。 |
| [examples/wrapped-text.endor](../examples/wrapped-text.endor) | 多窗口共享长段落:同一文字对象在每个 Mount 按当前宽度独立换行,后继位置跟随实际高度。 |
## 核对方式与边界
- 与本次分支起点及最新主线比较,逐文件核对移除注释后的文本;Rust/Endor 另做词法扫描,完整保留字符串/原始字符串内容,确认新增说明没有进入程序字面量。
- 使用 rustfmt 的 `--emit stdout`、`skip_children=true` 逐文件做 Rust 语法解析,丢弃输出,不格式化文件、不运行 Cargo、不生成编译产物。
- 对工具脚本使用 `node --check` 和 `sh -n`,只检查语法,不执行渲染、构建或打包;Cargo 配置另以 TOML 解析核对。
- 检查固定行号的示例夹具、测试依赖的源码替换片段和 `git diff --check`。这些检查能证明注释外代码保持一致及语法可解析,不能替代运行时回归结果。
- 本次未执行编译、GUI 回归、Clippy 或 rustdoc;需要构建的验证应在用户已确认的远程 runner 上运行,不能把未执行步骤写成通过。实际交付结果记录在 PR 中。
+111
View File
@@ -0,0 +1,111 @@
# Endor 脚本:龙与文字实时绕排
完整程序:[examples/illustrated-manuscript.endor](../examples/illustrated-manuscript.endor)。它通过普通 Page、源 measure/paint、托管事件及 owner-scoped Task 工作。Tessera 没有 Dragon / Flame / Manuscript 专用 Rust 类型、专用启动参数或动画循环;Rust 只补齐可被其他脚本复用的图形与文字能力。
## 运行
在仓库根目录启动普通应用:
```sh
cargo run --release --locked
```
另开终端,在同一个仓库根目录提交原始 Endor 文件:
```sh
curl --request PUT --header 'Content-Type: text/plain; charset=utf-8' \
--data-binary @examples/illustrated-manuscript.endor \
http://127.0.0.1:4737/v1/namespaces/app.main/code
```
这是普通源码提交,没有预编译场景或原生替身。资源路径目前相对于应用工作目录;通过以上根目录启动方式使用 `examples/assets/manuscript/`。从其他目录启动时,修改脚本的 `assets` 路径为绝对路径。图片、字体、正文都在示例目录,不编入宿主。素材与字体许可见 [NOTICE](../examples/assets/manuscript/NOTICE.md)。
移动鼠标让龙跟随,按住左键喷火,松开停止。离开窗口、取消指针或失焦也会停止喷火。空闲两秒回到卷曲姿势。原生窗口缩放触发普通布局和 paint;窄窗口切换到脚本预备的小字号。关闭 Page 会取消其定时任务,不留下应用级动画循环。
## 脚本对象分工
| 对象 | 状态与责任 |
| --- | --- |
| `Segment` | 一节身体的位置、角度及共享图片引用 |
| `FireArt` | 一张火焰图及其原始轴角;这些校准值是示例素材数据 |
| `Flame` | 一个粒子的位置、速度、尺寸、年龄及 FireArt 引用 |
| `Dragon` | 身体链、火焰集合、目标点、输入状态、时钟样本和随机种子;实现追随、恢复、生成/更新/删除粒子及绘图描述 |
| `Manuscript : RectBlock` | 普通视觉子类;组合预处理文字、首字母图片、龙和 Page,覆盖 measure/paint,通过 onMount/onUnmount 管理 Task |
| `Page` / `Task` / `Event` / `Mount` | 沿用 Tessera 已有托管体系:Page 是任务 owner,Task 调用脚本 tick,Event 带局部指针位置,Mount 提供当前出现的几何 |
这些是同一 VM 中的普通对象与引用。身体并不是多个重量远程 actor;图片被多个出现/粒子共享,临时 PaintCommand 只描述本帧绘制。
## 从输入到一帧
1. `view.on("pointermove", dragon.pointer)` 显式订阅连续输入。宿主把原生指针转换成 Event,再交给普通传播链;脚本读取 `event.mount.bounds()`,将目标点换算到自己的 700 单位画布。按下/松开只改变脚本 `pressed`。
2. `onMount` 用 `ui.after(page,"animation",80,self.tick)` 创建 Page 所有的任务。`tick` 调用脚本 `Dragon.step()`,再安排下一个任务。80 ms 是脚本参数;宿主不知道这是龙的动画。
3. `step` 先采样通用单调时钟。头向目标移动,其余 19 节依次跟随上一节,保持间距 30,并把相邻转角差限制在 ±0.25 弧度。空闲时由脚本计算卷曲目标并插值回去。
4. 粒子在脚本中更新位置、速度与年龄,倒序删除超过 12 步的粒子。每步最多产生 1 个、总数最多 12 个,最长边仅 12–20 个画布单位。随机变化由脚本自己的确定性种子和数学函数产生。
5. 普通属性变更使源 paint 的已观察依赖失效。`paint` 从当前几何生成图像绘制命令,再由脚本选择哪些图片形成避让区,交给通用文字流布局。
6. 正常文字使用缓存整词形状;喷火时脚本请求字素粒度,并在 `heat` 中按粒子距离修改临时文字命令的位置、角度、透明度和颜色。没有把热力规则藏进宿主。
7. 返回的 DisplayList 经过现有只读 paint 校验、Frame 发布和 CPU 栅格化,在普通窗口中呈现。VM 对象不进入窗口线程。
paint 内创建/修改的是临时返回对象,不能写已有龙状态或读取文件/时钟。资源加载、时钟采样和状态更新发生在初始化、事件或任务中。
## 绕排与预处理
脚本初始化时用 `prepareText` 准备两套正文和提示文字。字体/字号/行高/颜色/设备分辨率属于准备参数;每帧只传新几何与避让区,复用字形形状和透明图像。
`Dragon.artwork` 返回普通图像命令。脚本循环调用通用 `imageExclusion(command,padding)`,由图片 alpha 轮廓产生保守凸包,并给首字母添加矩形避让。`flowText` 对整个行带求交、合并遮挡区间,再依次填充剩余的左右片段;同一个文字 cursor 跨片段继续,不复制或跳过词。单词装不下所有可用片段时才退回字素边界。
当前提供面向本示例的从左到右文字流,不声称完整支持所有文字系统的双向段落。字素绘制会改变部分字偶距;这是喷火效果的取舍。正文超出当前页的部分留在准备数据中,当前示例保持单页,未增加滚动文档业务。
## 火焰角度与尺寸
火焰 PNG 自身方向各不相同;把它们一律当作朝右图片会错位。脚本在 `FireArt.axis` 中记录每张图片原始轴角,绘制时使用:
```endor
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)
```
角度以弧度表示,在屏幕坐标中正方向为顺时针;原始素材校准值与 PNG 一起属于应用数据。尺寸按最长边归一化,窄高的 PNG 不会因宽度较小而放大。舌头同样通过局部 anchor 定位,使其偏移随头部一起旋转。
## 可复用宿主接口
入口是 `g = ui.graphics`,数学入口是 `g.math`。宿主实现位于 `src/bridge/graphics.rs`、`src/render/image.rs` 和 `src/render/text_flow.rs`。
| 方法 | 行为 |
| --- | --- |
| `loadImage(path)` | 读取有界普通 PNG 文件,返回共享不可变资源及 width/height |
| `loadFont(path,family)` | 注册开放字体,验证指定 family;相同资源复用,冲突不替换既有字体 |
| `readText(path)` / `textSlice(text,start,end)` / `string(value)` | 初始化用有界 UTF-8 资源读取、按字符切片及标量文本转换 |
| `prepareText(text,font,size,lineHeight,color,rasterScale)` | 生成与障碍无关的缓存文字资源 |
| `sprite(image,x,y,width,height,angle,anchorX,anchorY,opacity)` | 返回普通 PaintCommand;旋转围绕原点,anchor 决定图片局部偏移 |
| `imageExclusion(command,padding)` | 图像实际 alpha 轮廓的保守凸包,应用该命令的平移、缩放与旋转 |
| `circleExclusion(x,y,r)` / `rectExclusion(x,y,w,h)` | 通用几何避让区 |
| `flowText(prepared,x,y,w,h,exclusions,letterMode)` | 生成可检查/修改的普通文字图像命令列表 |
| `math.float/sin/cos/sqrt/atan2/abs/min/max/clamp/floor/now` | 通用实数与图形数学;float 是显式二进制浮点入口,now 仅在事件/任务中采样 |
| `ObjectList.append/remove/extend/added` | 通用托管集合编辑/批量追加/持久添加;paint 只能编辑本次创建的临时集合 |
PNG 单张最多 4M 像素,文件最多 4 MiB;准备文字最多 16 KiB、缓存最多 8M 像素;最多 512 个避让区、1024 行和 10000 条绘图命令。资源读取不允许在 paint/layout 中执行。图形边界显式窄化数字,源语言的精确数值表示不会被改写。
原生连续指针输入需显式订阅。生产端在有界队列中只合并相同窗口/session/目标的相邻绝对移动采样,按下/松开/取消及目标变化都是顺序边界。后台 relay 直接把内部 Input envelope 送进同一 VM worker 队列并等待完成,窗口线程不等待;不绕行 HTTP。worker 等既有刷新结束后才重验 Mount/session 并更新连续事件版本,避免动画发布新帧时丢掉移动或松开。外部 HTTP 无法创建内部 envelope,继续严格校验帧版本。
## Aster 语言扩展
为使上述逻辑留在源码中,独立 Aster 分支实现了真实四则运算、非字面量负号、六种比较、if/else 与 while,并同步 ASDL、AST 生成产物、名称分析、bytecode 验证与 VM。整数/十进制/有理数保持精确;有 BinaryFloat 操作数才选择浮点算术,混合数值比较避免大整数丢精度。循环沿用同一累计指令限制与协作暂停机制。不是在 Tessera 中解释另一个小语言。
## 验证与范围
```sh
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo fmt --all -- --check
TESSERA_MANUSCRIPT_SNAPSHOTS=/absolute/output/path \
cargo test --release --locked manuscript_script_snapshots -- --nocapture
```
快照测试加载实际 Endor 文件,经 VM、事件、源 paint 和软件渲染器输出 endor-rest/crossing/fire/narrow 等 PNG。覆盖持续喷火达到上限、角度校准、最长边尺寸、字形呈现、宽窄窗口,以及任务确实调用脚本和关闭 Page 后停止。另一项集成测试验证指针驱动头部移动、取消与粒子回收。不是用 Rust 动画或手工绘图替代源码执行。
当前采用既有 CPU 后端;无 GPU 实现,也未达到稳定 60 FPS。参考本机 release 样本,16 次持续喷火的 VM 更新与完整 Frame 刷新约 1.4–1.5 秒(不含 OS 呈现),不能把 80 ms 定时参数当作实际帧率。性能结果属于脚本通路而非之前已移除的原生原型。
本次还补上了指针类型的 HTTP 白名单。最初只测 VM 分发遗漏了真实 worker 入口,随后通过真实 HTTP 回归复现并修复 `unsupported event kind`;动画期间的过期事件竞争则通过内部原生 envelope 解决。测试分别覆盖 HTTP 的坐标/类型/严格版本、原生 envelope 换帧后仍投递且旧会话不穿透,以及生产端合并不跨越按下/松开。最终运行窗口已由用户确认“持续正常,松开也正常”。系统自动事件投递/截图权限未开启,因此最终展示图来自实际脚本的 VM→Frame→软件渲染快照,原生交互验证来自用户操作。
+4
View File
@@ -37,3 +37,7 @@ and the running start page's four working actions.
The file is client-side sample material; Tessera's API stores source by namespace. The file is client-side sample material; Tessera's API stores source by namespace.
Return to the [project guide](../README.md). Return to the [project guide](../README.md).
## 龙与文字绕排
`illustrated-manuscript.endor` 通过通用图形资源、源 paint、指针事件与 Page 任务实现。龙、火焰和文字受热逻辑均在 Endor 中。资源在 `assets/manuscript/`;从仓库根目录启动应用并提交源码,详见 [运行和脚本解释](../docs/illustrated-manuscript.md)。
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# Illustrated Manuscript assets and attribution
Dragon sprites (head, tongue, front/back wings, body-1 through body-19), fire
sprites (Layer 2 through Layer 11), dropcap.png and story.txt are from
https://github.com/dengshu2/illustrated-manuscript (main, retrieved 2026-11-12).
The upstream package.json declares `ISC`; upstream has no separate LICENSE file.
Its complete README is retained as UPSTREAM.md. It credits the original demo to
River Marchand (@Riyvir), Neither/Nor: https://neithernor.studio/.
Reference live demo: https://illustrated-manuscript.vercel.app/.
The native chain animation, exclusion and fire algorithms are adapted from that
project's src/dragon.js and src/main.js; this port contains no JavaScript runtime.
IMFellEnglish-Regular.ttf is the unmodified IMFeENrm28P.ttf from
https://github.com/google/fonts/tree/main/ofl/imfellenglish.
Copyright (c) 2010, Igino Marini. SIL Open Font License 1.1, retained in OFL.txt.
The face is embedded for offline, reproducible manuscript typography.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+67
View File
@@ -0,0 +1,67 @@
# Illustrated Manuscript
An interactive illuminated manuscript page featuring an animated dragon that follows your cursor, breathes fire on click, and causes text to flow around it in real-time.
**[→ Live Demo](https://illustrated-manuscript.vercel.app/)**
## Overview
This project renders a medieval manuscript page on Canvas, featuring a hand-painted dragon made of sprite segments. The dragon tracks the mouse cursor with chain-physics (20 body segments with angle constraints), and clicking makes it breathe fire. Text reflows around the dragon body and fire particles each frame using [Pretext](https://github.com/chenglou/pretext)'s `layoutNextLine` API — no DOM measurements, no CSS reflow.
### Features
- **Real-time text exclusion** — text wraps around arbitrary moving shapes (dragon body + fire particles), re-laid out every frame via Pretext
- **Chain-physics dragon** — 20-segment inverse-kinematics body, hand-painted sprites, wing flapping, idle rest pose
- **Fire-breathing** — click to spawn particle bursts from the dragon's mouth; particles push and distort nearby text characters
- **Illuminated drop cap** — hand-drawn initial letter in manuscript tradition
- **Medieval aesthetic** — IM Fell English font, parchment tones, hand-painted sprites throughout
## Tech Stack
- **[Pretext](https://github.com/chenglou/pretext)** — Pure JS/TS text measurement & layout engine by [Cheng Lou](https://x.com/_chenglou). Enables laying out text without DOM reflow, line by line with variable widths
- **WebGL2 + 2D Canvas** — offscreen 2D Canvas compositing, uploaded to WebGL2 texture for display
- **Vite** — build tool
## Project Structure
```
src/
config.js — constants, responsive layout calculations
dragon.js — dragon physics, sprite rendering, fire particle system
story.js — manuscript story text
main.js — entry point: WebGL pipeline, input, text layout, render loop
public/
dragon-sprites/ — 23 hand-painted dragon body part PNGs
fire-sprites/ — 10 fire particle PNGs
img/dropcap.png — illuminated drop cap
(IM Fell English loaded via Google Fonts)
```
## Getting Started
```bash
npm install
npm run dev
```
Open in browser. Desktop recommended — mobile support is limited.
## How It Works
1. **Text preparation**: story text is segmented and measured once via `prepareWithSegments()`
2. **Each frame**: dragon segments and fire particles generate exclusion rectangles
3. **Text layout**: for each line, available width ranges are computed by subtracting exclusion zones, then `layoutNextLine()` fills each range
4. **Fire text effect**: characters near fire particles are displaced, rotated, and color-shifted based on proximity
5. **Rendering**: everything composites on an offscreen 2D Canvas, then uploads to a WebGL2 fullscreen quad
## Credits
Created by [River Marchand (@Riyvir)](https://x.com/Riyvir), Creative Director at [Neither/Nor](https://neithernor.studio/).
Built with [Pretext](https://github.com/chenglou/pretext) by [Cheng Lou](https://x.com/_chenglou) — a pure JavaScript/TypeScript library for multiline text measurement & layout, enabling web UIs to bypass DOM measurements and reflow entirely.
> *"This little illuminated dragon is very happy about Pretext."* — [@Riyvir](https://x.com/Riyvir/status/2038093450139279426)
## License
ISC
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+47
View File
@@ -0,0 +1,47 @@
It was the third candle the abbess had confiscated, and Sable had nodded gravely each time and stolen another from the chapel stores. She kept reading by its unsteady light, hunched beneath her wool blanket in the scriptorium where the novices slept on straw pallets between the copying desks.
It was the bestiary that held her. Not the psalter, not the gospels, not the lives of saints with their wooden sufferings. The bestiary. Some brother at Lindisfarne had painted it two centuries ago, and his creatures had a quality she could not name, a weight to them, as if they had been observed from life rather than copied from pattern books. His lions looked hungry. His basilisks looked bored.
And his dragon, coiled in the lower margin of the forty-seventh leaf, looked like it was breathing.
She had watched it for six nights before she was certain. The movement was slight, a swelling of the ribs, a settling, barely more than the flicker the candlelight threw across the page. On the seventh night she held her breath and placed a finger beside its painted flank, and the parchment was warm.
\u201CYou could simply ask,\u201D the dragon said.
Sable jerked her hand back. The dragon opened one eye, yellow as egg tempera, and regarded her with an expression she recognized from the abbey cats: mild contempt for a creature too slow to be interesting but too close to ignore.
\u201CI am called Voss,\u201D it said. \u201CThough the brother who painted me called me several other things, mostly under his breath, mostly because I would not hold still.\u201D
\u201CYou are paint,\u201D Sable said. It came out less firmly than she intended.
\u201CI am gall ink and oak soot and gum arabic,\u201D Voss replied. \u201CAlso rabbit skin glue, which I find personally offensive. But I am not paint. Paint does not get stiff from two centuries of holding the same pose.\u201D He uncurled one leg and stretched it across the margin rule, his claws dimpling the parchment. \u201CYou, on the other hand, are trespassing. This page is not assigned reading for novices.\u201D
\u201CI am not a novice. I clean the scriptorium.\u201D
\u201CEven worse. A servant reading. The brothers would be scandalized.\u201D
\u201CThe brothers are asleep.\u201D
\u201CThe brothers,\u201D Voss said, \u201Care always asleep. That is the principal advantage of monasteries.\u201D He yawned, and his breath smelled of lamp-black and something older, something mineral, like a cave after rain. A thin red tongue uncurled and tasted the air above the page.
Sable leaned closer. In the candlelight she could see the crosshatching of his scales, each one laid down with a single stroke of the brush. The brother who painted him had not scrimped. Most bestiary dragons were outlines stuffed with vermillion, flat and heraldic. This one had shadow under its jaw. This one had a nick in its left ear.
\u201CWhat do you eat?\u201D she asked.
\u201CSilverfish, mostly. They get into the binding paste. Occasionally a bookworm, if I am feeling athletic.\u201D He cocked his head. \u201CWhy are you not frightened?\u201D
\u201CShould I be?\u201D
\u201CConvention demands it. A maiden, a dragon. There are expectations.\u201D
\u201CI scrub floors and empty chamber pots. I am not anyone\u2019s idea of a maiden.\u201D
Voss made a sound that might have been a laugh. It was dry and papery, like a sheet of vellum being folded. \u201CGood,\u201D he said. \u201CMaidens are tedious. They always scream at exactly the wrong moment. You, at least, have the sense to ask questions first.\u201D
He settled back into the margin, tucking his tail around the descender of a large Q. \u201CCome back tomorrow night,\u201D he said. \u201CBring a candle that does not gutter so badly. And if you can manage it, a bit of cheese. I have not tasted cheese in two hundred years, but I believe I would still recognize it.\u201D
\u201CCan you eat cheese? You are made of ink.\u201D
\u201CI am made of ink that remembers being alive,\u201D Voss said. \u201CThere is a considerable difference.\u201D He closed his eyes. The warmth under Sable\u2019s fingertips faded, and the dragon on the page was still again, just pigment and binder on stretched calfskin, exactly as lifeless as it should have been.
Sable sat in the dark for a long time after the candle burned out. Then she went to the larder and stole a piece of cheese.\u2026
+7
View File
@@ -1,18 +1,25 @@
// 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。
// owner/key 相同的新读取替换旧任务;失败只记录 Task.error,不执行 apply。
// 持有三个显示对象与当前任务;task 初值占位,第一次 read 后替换为真实 Task。
class FileReader { class FileReader {
output: TextBlock output: TextBlock
problem: TextBlock problem: TextBlock
status: TextBlock status: TextBlock
task: Object task: Object
// 仅成功时调用,result 在成功回调期间已可读;写原 TextBlock 触发显示刷新。
function apply(self: FileReader): Unit { function apply(self: FileReader): Unit {
self.output.text = self.task.result() self.output.text = self.task.result()
} }
// 以 self 为 owner、file 为 key 启动读取,清空旧输出,并把状态/错误文字绑定到当前任务。
function read(self: FileReader, path: String): Unit { function read(self: FileReader, path: String): Unit {
self.task = ui.readText(self, "file", path, self.apply) self.task = ui.readText(self, "file", path, self.apply)
self.output.text = "" self.output.text = ""
// Task.status/error 是零参数只读方法,可直接作为绑定计算;换任务后绑定也随本次调用更新。
self.status.bind("text", self.task.status) self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error) self.problem.bind("text", self.task.error)
} }
} }
// 先建立界面对象,保持 reader 为可从 Console 操作的全局业务入口。
output = TextBlock("尚未读取文件") output = TextBlock("尚未读取文件")
problem = TextBlock("") problem = TextBlock("")
status = TextBlock("") status = TextBlock("")
+7
View File
@@ -1,21 +1,28 @@
// 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。
// 完整响应保存为 String,界面只显示完成提示,避免把可能很大的响应放进文字控件。
// response 是结果业务存储;output/problem/status 仅呈现任务进度与提示。
class TextFetcher { class TextFetcher {
output: TextBlock output: TextBlock
problem: TextBlock problem: TextBlock
status: TextBlock status: TextBlock
task: Object task: Object
response: String response: String
// 成功后取出完整 UTF-8 结果,写 response 供 Console 查询;provider 失败不会进入此方法。
function apply(self: TextFetcher): Unit { function apply(self: TextFetcher): Unit {
self.response = self.task.result() self.response = self.task.result()
self.output.text = "请求完成;在 REPL 查看 fetcher.response" self.output.text = "请求完成;在 REPL 查看 fetcher.response"
} }
// 同一 self/request 身份的新请求取消旧交付,重置旧内容并把显示绑定到新任务。
function fetch(self: TextFetcher, url: String): Unit { function fetch(self: TextFetcher, url: String): Unit {
self.task = ui.fetchText(self, "request", url, self.apply) self.task = ui.fetchText(self, "request", url, self.apply)
self.response = "" self.response = ""
self.output.text = "" self.output.text = ""
// 绑定读取当前 Task 方法,完成和失败由 worker 的任务状态变更传播。
self.status.bind("text", self.task.status) self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error) self.problem.bind("text", self.task.error)
} }
} }
// 界面不自动访问网络,用户以 Console 明确指定 URL 后才创建任务。
output = TextBlock("尚未请求网络") output = TextBlock("尚未请求网络")
problem = TextBlock("") problem = TextBlock("")
status = TextBlock("") status = TextBlock("")
+7
View File
@@ -1,24 +1,31 @@
// 把 callback-free 文件 Task 与 ui.spawn 协作函数组合:await 保存执行位置,等待完成后继续,不阻塞 VM worker。
// readText 的 file 与 spawn 的 workflow 使用不同 key,分别管理读取和等待流程。
// 保存 provider Task、协程 Task 和结果;两种任务都是托管对象,可以从 Console 独立查看。
class AwaitReader { class AwaitReader {
output: TextBlock output: TextBlock
status: TextBlock status: TextBlock
task: Object task: Object
file: Object file: Object
response: String response: String
// await 在文件未完成时挂起并保留 self/操作数;恢复后只执行后半段一次,再把字符串作为 workflow.result。
function apply(self: AwaitReader): String { function apply(self: AwaitReader): String {
self.response = await self.file self.response = await self.file
self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()" self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()"
return self.response return self.response
} }
// 先创建读任务,再调度普通函数 apply;spawn 而非新 async 语法提供可暂停执行上下文。
function read(self: AwaitReader, path: String): Unit { function read(self: AwaitReader, path: String): Unit {
self.file = ui.readText(self, "file", path) self.file = ui.readText(self, "file", path)
self.task = ui.spawn(self, "workflow", self.apply) self.task = ui.spawn(self, "workflow", self.apply)
self.response = "" self.response = ""
self.output.text = "等待文件" self.output.text = "等待文件"
// 显示的是 workflow 状态,等待 provider 时它仍报告 running;结束后 result 可从 Task 或 response 读取。
self.status.bind("text", self.task.status) self.status.bind("text", self.task.status)
} }
} }
output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)") output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)")
status = TextBlock("") status = TextBlock("")
// 占位值只用于满足初始字段构造;在 read 内被真实 Task 替换,页面建立不自动读文件。
reader = AwaitReader(output,status,ui.state(0),ui.state(0),"") reader = AwaitReader(output,status,ui.state(0),ui.state(0),"")
page = Page() page = Page()
page.title = "await 文件" page.title = "await 文件"
+10 -1
View File
@@ -1,8 +1,12 @@
// Containers compose independently of Page and of any camera. // 盒模型组合:容器与 Page/相机独立,border-box 尺寸、padding、margin 和对齐分别参与布局。
// 同一文字共享内容,在左右不同有限分配中各自换行;外层 Overlay 负责叠放固定角标并裁剪溢出。
// 内部布局基于容器自己的约束;Page 和相机只提供外部展示/投影上下文。
// 文字 boxWidth=fill 占父分配的可用宽度,marginBottom 在文字边框盒外参与排布。
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2) shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
shared.boxWidth = "fill" shared.boxWidth = "fill"
shared.marginBottom = 8 shared.marginBottom = 8
// 左 Column 使用 fill、min/max 宽度限制和四边边框;非对称 paddingLeft 覆盖统一 padding。
left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索")) left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索"))
left.boxWidth = "fill" left.boxWidth = "fill"
left.minWidth = 180 left.minWidth = 180
@@ -15,24 +19,29 @@ left.borderColor = "#CBD5E1"
left.paddingLeft = 24 left.paddingLeft = 24
left.crossAlign = "center" left.crossAlign = "center"
// 右 Column 也引用同一 shared,但最大宽只有 240,因此对应 Mount 的换行高度可以不同。
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared) right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
right.boxWidth = "fill" right.boxWidth = "fill"
right.maxWidth = 240 right.maxWidth = 240
right.crossAlign = "end" right.crossAlign = "end"
// Row 分配有限 760 主轴空间,扣 padding/gap 后在两侧 fill 子项之间分配并服从上限。
content = Row(24, 24, left, right) content = Row(24, 24, left, right)
content.boxWidth = 760 content.boxWidth = 760
content.crossAlign = "start" content.crossAlign = "start"
// 角标是 Overlay 的另一子项,alignSelf 与 margin 控制它在容器交叉轴上的位置。
badge = TextBlock("Endorphin · layout") badge = TextBlock("Endorphin · layout")
badge.marginRight = 12 badge.marginRight = 12
badge.marginBottom = 12 badge.marginBottom = 12
badge.alignSelf = "end" badge.alignSelf = "end"
// 固定 800x400 的 Overlay 不按线性顺序挤开兄弟,overflow=clip 同时约束绘制和命中。
surface = Overlay(0, 0, content, badge) surface = Overlay(0, 0, content, badge)
surface.boxWidth = 800 surface.boxWidth = 800
surface.boxHeight = 400 surface.boxHeight = 400
surface.mainAlign = "end" surface.mainAlign = "end"
surface.overflow = "clip" surface.overflow = "clip"
// Page 只提供展示入口,根容器自身的盒模型规则不依赖页面导航方式。
page = Page() page = Page()
page.title = "嵌套盒模型" page.title = "嵌套盒模型"
page.render(surface) page.render(surface)
+40 -5
View File
@@ -1,7 +1,9 @@
// Endor Campus — a multi-page learning environment inspired by Canvas LMS. // 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。
// Fictional student/course data. Paste the complete source into Console and Enter. // 无服务器、账号登录或真实上传;提交/评分按钮只应用预设字段值,其他页通过绑定读同一状态。
// All seven tabs are ordinary managed Pages. There is no server or Canvas login. // 作为一次完整输入提交;七页共用同一VM,会话重置后数据消失。
// 白色圆角卡片容器,继承 Column 的排版,只覆盖自身背景绘制。
class CampusPanel: Column { class CampusPanel: Column {
// 按当前出现位置 geometry 创建一条背景命令,子项由展示遍历继续绘制。
function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
fill.kind = "roundedRect" fill.kind = "roundedRect"
@@ -14,18 +16,23 @@ class CampusPanel: Column {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// 通过额外 width 字段固定表格列的期望宽,保持成绩表头和行对齐。
class CampusCell: Column { class CampusCell: Column {
width: Number width: Number
// 先算子内容自然高度,再改新测量结果宽度,保留真实字体引起的高度。
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints) result = Column.measure(self, mount, constraints)
result.width = self.width result.width = self.width
return result return result
} }
} }
// 页面内容里的演示导航栏,与应用自身的原生页面侧栏是两棵不同控件树。
class CampusSidebar: Column { class CampusSidebar: Column {
// 返回固定168x620期望尺寸,外层 ScrollBlock 承担超出区域的滚动。
function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(168, 620) return MeasureResult(168, 620)
} }
// 为导航区创建深红背景,位置/大小来自当前Mount,文字等子项保持独立命令。
function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
fill.kind = "roundedRect" fill.kind = "roundedRect"
@@ -38,11 +45,14 @@ class CampusSidebar: Column {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// Button 子类增加背景字段,固定导航按钮尺寸,并保留原生事件目标能力。
class CampusNav: Button { class CampusNav: Button {
background: String background: String
// 导航按钮期望144x40,确保四个入口宽高一致。
function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(144, 40) return MeasureResult(144, 40)
} }
// 取原生按钮背景命令改色/圆角;文字由后面 replace 后的子项绘制,不在此重复生成。
function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList {
fill = Button.paint(self, mount, geometry).commands.at(0) fill = Button.paint(self, mount, geometry).commands.at(0)
fill.color = self.background fill.color = self.background
@@ -50,6 +60,7 @@ class CampusNav: Button {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// 课程业务记录,grade/progress 被多个页面绑定读取,其余静态字段构建时直接读取。
class CampusCourse { class CampusCourse {
title: String title: String
code: String code: String
@@ -57,6 +68,7 @@ class CampusCourse {
grade: String grade: String
progress: String progress: String
} }
// 实验状态集中在一个对象,course 指向同一课程模型,评分因此能影响全站课程摘要。
class CampusWork { class CampusWork {
status: String status: String
score: String score: String
@@ -65,12 +77,14 @@ class CampusWork {
todo: String todo: String
result: String result: String
course: CampusCourse course: CampusCourse
// 模拟提交只改状态、文件名提示、待办和结果文字,不访问磁盘或网络。
function submit(self: CampusWork, event: Event): Unit { function submit(self: CampusWork, event: Event): Unit {
self.status = "已提交 · 等待评分" self.status = "已提交 · 等待评分"
self.submission = "lab01.endor · 已提交示例内容" self.submission = "lab01.endor · 已提交示例内容"
self.todo = "已完成本次实验提交" self.todo = "已完成本次实验提交"
self.result = "示例提交成功,可以切到作业中心查看同一条记录。" self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
} }
// 应用预设92分和反馈,并更新所属课程成绩/进度;绑定让仪表盘、课程、成绩页同步观察。
function assess(self: CampusWork, event: Event): Unit { function assess(self: CampusWork, event: Event): Unit {
self.status = "已评分" self.status = "已评分"
self.score = "92 / 100" self.score = "92 / 100"
@@ -81,6 +95,7 @@ class CampusWork {
self.course.progress = "已完成 4 / 6 个学习单元" self.course.progress = "已完成 4 / 6 个学习单元"
self.result = "已应用预设评分;仪表盘、课程主页和成绩总览同步更新。" self.result = "已应用预设评分;仪表盘、课程主页和成绩总览同步更新。"
} }
// 恢复初始实验与课程状态,保留所有原对象和绑定,便于反复演示。
function reset(self: CampusWork, event: Event): Unit { function reset(self: CampusWork, event: Event): Unit {
self.status = "未提交" self.status = "未提交"
self.score = "— / 100" self.score = "— / 100"
@@ -92,20 +107,25 @@ class CampusWork {
self.result = "已恢复实验、课程成绩和学习进度的示例状态。" self.result = "已恢复实验、课程成绩和学习进度的示例状态。"
} }
} }
// 用统一包装构造表格单元格;传入的content是已有Block引用。
function campusCell(content: Block, width: Number): Block { function campusCell(content: Block, width: Number): Block {
return CampusCell(0, 0, content, width) return CampusCell(0, 0, content, width)
} }
// 显式事件适配器把click的Event与Page.open可选Window参数分开,不能直接把Event误当窗口。
class CampusTarget { class CampusTarget {
page: Page page: Page
// Consume the click Event here; Page.open's optional argument is a Window. // 事件参数在适配器这一层消化,Page.open 只接收其声明的可选 Window。
// 消费事件但不传给Page.open;无参open表示在合适窗口打开/激活目标页。
function open(self: CampusTarget, event: Event): Unit { self.page.open() } function open(self: CampusTarget, event: Event): Unit { self.page.open() }
} }
// 为普通按钮创建持有目标Page的适配器,并把其绑定方法注册为click回调。
function campusLink(label: String, target: Page): Button { function campusLink(label: String, target: Page): Button {
button = Button(label) button = Button(label)
action = CampusTarget(target) action = CampusTarget(target)
button.on("click", action.open) button.on("click", action.open)
return button return button
} }
// 构造带指定背景的导航按钮,替换原生文字子槽为白字,然后复用事件适配器连接目标页。
function campusNav(label: String, target: Page, background: String): Button { function campusNav(label: String, target: Page, background: String): Button {
button = CampusNav(label, background) button = CampusNav(label, background)
button.replace(0, TextBlock(label, 1, "#FFFFFF")) button.replace(0, TextBlock(label, 1, "#FFFFFF"))
@@ -113,6 +133,7 @@ function campusNav(label: String, target: Page, background: String): Button {
button.on("click", action.open) button.on("click", action.open)
return button return button
} }
// 用目标Page和四个色调参数构建整块导航;不同页面复用目标资源但用不同高亮背景。
function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block { function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block {
return CampusSidebar(12, 10, return CampusSidebar(12, 10,
TextBlock("Endor", 2, "#FFFFFF"), TextBlock("Endor", 2, "#FFFFFF"),
@@ -125,6 +146,7 @@ function campusNavigation(home: Page, course: Page, assignments: Page, grades: P
TextBlock("2026 秋季学期", 1, "#E7BAC0"), TextBlock("2026 秋季学期", 1, "#E7BAC0"),
TextBlock("本地演示", 1, "#E7BAC0")) TextBlock("本地演示", 1, "#E7BAC0"))
} }
// 把演示导航与标题/正文横排,外层有限ScrollBlock启用双轴,容纳固定宽度演示内容。
function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block { function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block {
scroll = ScrollBlock(1120, 680, scroll = ScrollBlock(1120, 680,
Row(24, 28, navigation, Row(24, 28, navigation,
@@ -135,6 +157,7 @@ function campusShell(navigation: Block, title: String, subtitle: String, body: B
scroll.axis = "both" scroll.axis = "both"
return scroll return scroll
} }
// 卡片静态显示课程代码/标题/教师,grade 用绑定随评分变化,入口按钮引用真实课程Page。
function campusCourseCard(course: CampusCourse, page: Page, color: String): Block { function campusCourseCard(course: CampusCourse, page: Page, color: String): Block {
return CampusPanel(0, 0, return CampusPanel(0, 0,
RectBlock(248, 44, 8, color), RectBlock(248, 44, 8, color),
@@ -145,6 +168,7 @@ function campusCourseCard(course: CampusCourse, page: Page, color: String): Bloc
ui.textOf(course, "grade", 2, color), ui.textOf(course, "grade", 2, color),
campusLink("进入课程", page))) campusLink("进入课程", page)))
} }
// 成绩表一行复用固定列宽单元格,当前grade为响应式文字,详情链接指向指定课程页。
function campusGradeRow(course: CampusCourse, target: Page): Block { function campusGradeRow(course: CampusCourse, target: Page): Block {
return Column(0, 12, return Column(0, 12,
Row(0, 0, Row(0, 0,
@@ -154,6 +178,7 @@ function campusGradeRow(course: CampusCourse, target: Page): Block {
campusLink("课程详情", target)), campusLink("课程详情", target)),
RectBlock(752, 1, 0, "#EDF0F3")) RectBlock(752, 1, 0, "#EDF0F3"))
} }
// 组合课程概况/进度和静态学习单元;上方两个链接复用作业/成绩Page,不创建新页面。
function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block { function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block {
return Column(0, 20, return Column(0, 20,
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)), Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
@@ -171,11 +196,13 @@ function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String
RectBlock(752, 1, 0, "#EDF0F3"), RectBlock(752, 1, 0, "#EDF0F3"),
TextBlock(moduleThree, 1, "#526372"))) TextBlock(moduleThree, 1, "#526372")))
} }
// The shared object graph is created once; each Page projects those same objects. // 共享对象图只构造一次,各Page的文字绑定投影同一业务字段。
// 只创建三份课程模型和一份实验模型,后续各页读取这些对象,避免页面间状态副本不一致。
campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师 · 周二 / 周四", "86.0%", "已完成 3 / 6 个学习单元") campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师 · 周二 / 周四", "86.0%", "已完成 3 / 6 个学习单元")
campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元") campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元")
campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元") campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元")
campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming) campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming)
// 先创建全部七个Page,允许导航按钮互相引用;随后逐一render建立内容。
campusDashboard = Page() campusDashboard = Page()
campusDashboard.title = "Campus · 仪表盘" campusDashboard.title = "Campus · 仪表盘"
campusProgrammingPage = Page() campusProgrammingPage = Page()
@@ -190,10 +217,12 @@ campusGrades = Page()
campusGrades.title = "Campus · 成绩总览" campusGrades.title = "Campus · 成绩总览"
campusWorkPage = Page() campusWorkPage = Page()
campusWorkPage.title = "实验 01 · 提交详情" campusWorkPage.title = "实验 01 · 提交详情"
// 预建四种高亮导航树,课程三页共享course导航,Page切换时各自拥有独立Mount。
campusHomeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#A64350", "#852B38", "#852B38", "#852B38") campusHomeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#A64350", "#852B38", "#852B38", "#852B38")
campusCourseNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#A64350", "#852B38", "#852B38") campusCourseNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#A64350", "#852B38", "#852B38")
campusAssignmentNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#A64350", "#852B38") campusAssignmentNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#A64350", "#852B38")
campusGradeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#852B38", "#A64350") campusGradeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#852B38", "#A64350")
// 仪表盘展示课程卡和当前实验摘要,status/todo均从同一campusWork绑定读取。
campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎回来。这里是你的课程与近期学习安排。", campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎回来。这里是你的课程与近期学习安排。",
Column(0, 20, Column(0, 20,
Row(0, 16, Row(0, 16,
@@ -206,6 +235,7 @@ campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎
ui.textOf(campusWork, "todo", 1, "#7C8793"), ui.textOf(campusWork, "todo", 1, "#7C8793"),
campusLink("打开作业详情", campusWorkPage)), campusLink("打开作业详情", campusWorkPage)),
TextBlock("虚构课程与学生数据 · Endor 本地多页面演示", 1, "#8C959F")))) TextBlock("虚构课程与学生数据 · Endor 本地多页面演示", 1, "#8C959F"))))
// 程序设计课程附加实验入口;数学和英语页复用相同概况结构但读取不同课程模型。
campusProgrammingPage.render(campusShell(campusCourseNav, "计算机程序设计", "课程 / CS 101 / 主页", campusProgrammingPage.render(campusShell(campusCourseNav, "计算机程序设计", "课程 / CS 101 / 主页",
Column(0, 16, Column(0, 16,
campusCourseBody(campusProgramming, "通过实际编程理解对象、函数与交互。", "01 对象与字段 · 已完成", "02 函数与方法 · 已完成", "03 事件与可视界面 · 进行中", campusAssignments, campusGrades), campusCourseBody(campusProgramming, "通过实际编程理解对象、函数与交互。", "01 对象与字段 · 已完成", "02 函数与方法 · 已完成", "03 事件与可视界面 · 进行中", campusAssignments, campusGrades),
@@ -214,6 +244,7 @@ campusMathPage.render(campusShell(campusCourseNav, "高等数学 I", "课程 / M
campusCourseBody(campusMathematics, "学习极限、微分与积分的基本方法。", "01 函数与极限 · 已完成", "02 导数及其应用 · 已完成", "03 积分方法 · 进行中", campusAssignments, campusGrades))) campusCourseBody(campusMathematics, "学习极限、微分与积分的基本方法。", "01 函数与极限 · 已完成", "02 导数及其应用 · 已完成", "03 积分方法 · 进行中", campusAssignments, campusGrades)))
campusEnglishPage.render(campusShell(campusCourseNav, "学术英语", "课程 / ENG 102 / 主页", campusEnglishPage.render(campusShell(campusCourseNav, "学术英语", "课程 / ENG 102 / 主页",
campusCourseBody(campusEnglish, "阅读学术文本,练习清晰的论证与表达。", "01 Academic reading · 已完成", "02 Evidence and arguments · 已完成", "03 Research writing · 进行中", campusAssignments, campusGrades))) campusCourseBody(campusEnglish, "阅读学术文本,练习清晰的论证与表达。", "01 Academic reading · 已完成", "02 Evidence and arguments · 已完成", "03 Research writing · 进行中", campusAssignments, campusGrades)))
// 作业中心只有程序设计实验为共享动态模型,其余两项是固定演示文字。
campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本学期 / 全部课程 / 作业与提交", campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本学期 / 全部课程 / 作业与提交",
Column(0, 20, Column(0, 20,
CampusPanel(20, 12, CampusPanel(20, 12,
@@ -227,6 +258,7 @@ campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本
CampusPanel(20, 12, CampusPanel(20, 12,
TextBlock("Reading response", 1, "#263444"), TextBlock("Reading response", 1, "#263444"),
TextBlock("ENG 102 · 已评分 · 88 / 100", 1, "#7C8793"))))) TextBlock("ENG 102 · 已评分 · 88 / 100", 1, "#7C8793")))))
// 成绩总览复用课程grade绑定和实验score/feedback,评分无需逐页手动写UI。
campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学期 / 当前课程成绩", campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学期 / 当前课程成绩",
Column(0, 20, Column(0, 20,
CampusPanel(20, 16, CampusPanel(20, 16,
@@ -242,12 +274,14 @@ campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学
Row(0, 24, TextBlock("实验 01:对象与函数"), ui.textOf(campusWork, "score", 1, "#315E86")), Row(0, 24, TextBlock("实验 01:对象与函数"), ui.textOf(campusWork, "score", 1, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987"), ui.textOf(campusWork, "feedback", 1, "#6D7987"),
campusLink("打开作业与反馈", campusWorkPage))))) campusLink("打开作业与反馈", campusWorkPage)))))
// 三个操作按钮连接同一个实验对象的submit/assess/reset,均为真实源方法回调。
campusSubmitButton = Button("模拟提交实验") campusSubmitButton = Button("模拟提交实验")
campusSubmitButton.on("click", campusWork.submit) campusSubmitButton.on("click", campusWork.submit)
campusAssessButton = Button("应用示例评分") campusAssessButton = Button("应用示例评分")
campusAssessButton.on("click", campusWork.assess) campusAssessButton.on("click", campusWork.assess)
campusResetButton = Button("恢复演示数据") campusResetButton = Button("恢复演示数据")
campusResetButton.on("click", campusWork.reset) campusResetButton.on("click", campusWork.reset)
// 详情页组合要求、提交状态、操作按钮和反馈;所有输入仍只是预设本地状态转换。
campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情", campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情",
Column(0, 20, Column(0, 20,
Row(0, 16, campusLink("返回课程", campusProgrammingPage), campusLink("全部作业", campusAssignments), campusLink("成绩总览", campusGrades)), Row(0, 16, campusLink("返回课程", campusProgrammingPage), campusLink("全部作业", campusAssignments), campusLink("成绩总览", campusGrades)),
@@ -265,4 +299,5 @@ campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函
Row(0, 24, TextBlock("成绩与反馈"), ui.textOf(campusWork, "score", 2, "#315E86")), Row(0, 24, TextBlock("成绩与反馈"), ui.textOf(campusWork, "score", 2, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987")), ui.textOf(campusWork, "feedback", 1, "#6D7987")),
ui.textOf(campusWork, "result", 1, "#7C8793")))) ui.textOf(campusWork, "result", 1, "#7C8793"))))
// 逐页render会改变当前活动页,最后显式选择仪表盘作为打开后的首页。
campusDashboard.activate() campusDashboard.activate()
+9
View File
@@ -1,28 +1,37 @@
// 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。
// 两个 textOf 共享同一模型字段,修改一次业务值会更新两个展示位置。
// 定义业务状态和公共事件入口,Number/String 字段在构造与赋值时按声明契约检查。
class Counter { class Counter {
value: Number value: Number
lastEvent: String lastEvent: String
// 保存实际事件种类,再通过 self 调用可覆盖的方法;接收者保持具体子类身份。
function activate(self: Counter, event: Event): Unit { function activate(self: Counter, event: Event): Unit {
self.lastEvent = event.kind self.lastEvent = event.kind
self.change() self.change()
} }
// 基类默认行为把值置为 10,子类可复用入口并替换这部分策略。
function change(self: Counter): Unit { function change(self: Counter): Unit {
self.value = 10 self.value = 10
} }
} }
// 继承 Counter 的字段与 activate,仅替换 change;构造参数仍按继承字段顺序传入。
class DemoCounter: Counter { class DemoCounter: Counter {
// 覆盖把值设为 12,演示从继承方法内部发起的调用也会选中实际接收者的方法。
function change(self: DemoCounter): Unit { function change(self: DemoCounter): Unit {
self.value = 12 self.value = 12
} }
} }
// 创建唯一模型,之后所有绑定和按钮回调都指向它,无需把 GUI 状态复制到另一份存储。
model = DemoCounter(9, "等待点击") model = DemoCounter(9, "等待点击")
first = ui.textOf(model, "value", 2) first = ui.textOf(model, "value", 2)
second = ui.textOf(model, "value", 2) second = ui.textOf(model, "value", 2)
eventName = ui.textOf(model, "lastEvent") eventName = ui.textOf(model, "lastEvent")
button = Button("调用继承方法,再分派到覆盖方法") button = Button("调用继承方法,再分派到覆盖方法")
// 注册绑定方法,运行时将 click 的 Event 传给 activate;最后 Page.render 才把对象树显示出来。
button.onClick(model.activate) button.onClick(model.activate)
page = Page() page = Page()
page.render(Column(24, 16, TextBlock("Endor class:继承、self 与动态方法分派", 2), Row(0, 16, first, second), button, eventName)) page.render(Column(24, 16, TextBlock("Endor class:继承、self 与动态方法分派", 2), Row(0, 16, first, second), button, eventName))
+9
View File
@@ -1,11 +1,20 @@
// 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。
// 省略 RectBlock 的 radius/color 不应使字符串 tag 被错当成原生颜色参数。
// 在原生矩形后增加 tag 字段;位置构造仍保留 width/height 及可选圆角/颜色规则。
class TaggedRect: RectBlock { tag: String } class TaggedRect: RectBlock { tag: String }
// 在可变数量 Block 子项之后接收源 tag,子项列表与字段不混用。
class TaggedColumn: Column { tag: String } class TaggedColumn: Column { tag: String }
// 保留原生事件 kind,再追加业务字符串字段。
class TaggedEvent: Event { tag: String } class TaggedEvent: Event { tag: String }
// 原生列表成员之后追加 tag,列表元素仍可保存不同托管类型。
class TaggedList: ObjectList { tag: String } class TaggedList: ObjectList { tag: String }
// 将需要更新的矩形作为强业务引用保存,按钮回调只修改它的颜色。
class Actions { class Actions {
rect: TaggedRect rect: TaggedRect
// 写原对象的 color,挂载显示由依赖跟踪刷新,不需要重新构造整个页面。
function recolor(self: Actions): Unit { self.rect.color = "#237A45" } function recolor(self: Actions): Unit { self.rect.color = "#237A45" }
} }
// 这里刻意省略原生可选项,用末尾字符串初始化子类字段;后面的列表/Column 做对应可变参数演示。
rect = TaggedRect(180, 60, "省略 radius/color 后的字段") rect = TaggedRect(180, 60, "省略 radius/color 后的字段")
event = TaggedEvent("click", "事件字段") event = TaggedEvent("click", "事件字段")
items = TaggedList(rect, event, "列表成员之后的字段") items = TaggedList(rect, event, "列表成员之后的字段")
+7
View File
@@ -1,5 +1,10 @@
// 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。
// 这里刻意重复增量语句,方便看到暂停点;取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
// 每个实例有独立 steps 状态,两个任务不会把局部帧或计数存储混在一起。
class CooperativeWork { class CooperativeWork {
steps: Object steps: Object
// 普通零参数方法被 spawn 调度后可跨量子继续;每条 increment 更新同一实例的计数。
// 连续语句无需手工 yield,VM 按累计预算暂停,恢复时不会从第一条重放。
function run(self: CooperativeWork): Unit { function run(self: CooperativeWork): Unit {
self.steps.increment() self.steps.increment()
self.steps.increment() self.steps.increment()
@@ -67,10 +72,12 @@ class CooperativeWork {
self.steps.increment() self.steps.increment()
} }
} }
// 创建两个独立 owner,虽然 key 都叫 work,也不会相互替换;owner/key 组合才是任务身份。
left = CooperativeWork(ui.state(0)) left = CooperativeWork(ui.state(0))
right = CooperativeWork(ui.state(0)) right = CooperativeWork(ui.state(0))
leftTask = ui.spawn(left,"work",left.run) leftTask = ui.spawn(left,"work",left.run)
rightTask = ui.spawn(right,"work",right.run) rightTask = ui.spawn(right,"work",right.run)
// 状态文字绑定到 Task.status,业务进度可在 Console 查询 left/right 的 steps.value。
leftStatus = TextBlock("") leftStatus = TextBlock("")
rightStatus = TextBlock("") rightStatus = TextBlock("")
leftStatus.bind("text",leftTask.status) leftStatus.bind("text",leftTask.status)
+10
View File
@@ -1,3 +1,6 @@
// 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。
// EventLog 用一个递增序列记录实际次序;改变监听器/传播标记可对比跳过与停止的语义。
// 保存各阶段序号与最近 phase;count 是共享状态,其他字段直接写在本模型中。
class EventLog { class EventLog {
count: Object count: Object
captureStep: Number captureStep: Number
@@ -5,24 +8,29 @@ class EventLog {
targetStep: Number targetStep: Number
bubbleStep: Number bubbleStep: Number
phase: String phase: String
// 祖先捕获阶段先递增序列,再保存 captureStep 和 Event.phase。
function capture(self: EventLog, event: Event): Unit { function capture(self: EventLog, event: Event): Unit {
self.count.increment() self.count.increment()
self.captureStep = self.count.value self.captureStep = self.count.value
self.phase = event.phase self.phase = event.phase
} }
// 目标监听器的执行点;它排在目标类型 onEvent 方法之后。
function target(self: EventLog, event: Event): Unit { function target(self: EventLog, event: Event): Unit {
self.count.increment() self.count.increment()
self.targetStep = self.count.value self.targetStep = self.count.value
self.phase = event.phase self.phase = event.phase
} }
// 祖先冒泡阶段记录最后序号,未被 stopPropagation 时才到达。
function bubble(self: EventLog, event: Event): Unit { function bubble(self: EventLog, event: Event): Unit {
self.count.increment() self.count.increment()
self.bubbleStep = self.count.value self.bubbleStep = self.count.value
self.phase = event.phase self.phase = event.phase
} }
} }
// 覆盖目标方法,比额外 on(click,...) 监听器更早处理目标阶段事件。
class EventButton: Button { class EventButton: Button {
model: EventLog model: EventLog
// 把模型序列递增后写 methodStep,供断言与 capture/target/bubble 比较。
function onEvent(self: EventButton, event: Event): Unit { function onEvent(self: EventButton, event: Event): Unit {
self.model.count.increment() self.model.count.increment()
self.model.methodStep = self.model.count.value self.model.methodStep = self.model.count.value
@@ -32,6 +40,8 @@ class EventButton: Button {
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击") model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model) button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model)
root = Column(24, 16, TextBlock("同一对象系统中的事件分派", 2), button, ui.textOf(model.count, "value"), ui.textOf(model, "phase")) 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") capture = root.on("click", model.capture, "capture")
target = button.on("click", model.target) target = button.on("click", model.target)
bubble = root.on("click", model.bubble) bubble = root.on("click", model.bubble)
+7
View File
@@ -1,17 +1,24 @@
// 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。
// 退出先取消任务、关闭窗口,再进行允许的卸载回调;已停止 VM 的清理不能重新进入源代码。
// 在原生文字类型上增加共享日志,用来观察每个 Mount 的卸载通知。
class ExitText: TextBlock { class ExitText: TextBlock {
log: Object log: Object
// 每次真实卸载递增同一 log;普通重绘不调用,重复 shutdown 也不应重复调用。
function onUnmount(self: ExitText, mount: Mount): Unit { function onUnmount(self: ExitText, mount: Mount): Unit {
self.log.increment() self.log.increment()
} }
} }
log = ui.state(0) log = ui.state(0)
// 文字业务对象只创建一次,Page 与另一窗都引用它,但两边有各自的 Mount 生命周期。
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log) watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
page = Page() page = Page()
page.title = "退出生命周期" page.title = "退出生命周期"
page.render(Column(24,16,watched,ui.textOf(log,"value"))) page.render(Column(24,16,watched,ui.textOf(log,"value")))
// 创建同 VM 内的另一原生窗口;迁移 other 后仍保留 watched 的业务身份。
second = Window() second = Window()
second.setTitle("Exit lifecycle secondary") second.setTitle("Exit lifecycle secondary")
other = Page() other = Page()
other.render(watched) other.render(watched)
other.moveToWindow(second) other.moveToWindow(second)
// 应用级长定时任务用于观察退出会取消尚未完成的工作,不让退出过程等满 30 秒。
pending = ui.sleep(ui,"pending",30000) pending = ui.sleep(ui,"pending",30000)
+6
View File
@@ -1,12 +1,18 @@
// 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。
// 绑定显示读取该字段,GUI 回调与 Console 赋值受同一契约约束。
// 增加 value:Number;构造时先传原生 text,再传源字段值。
class CounterText: TextBlock { class CounterText: TextBlock {
value: Number value: Number
// 收到 click 后把已有数值改为 10,依赖文字随之更新,不修改原生 text 内容。
function change(self: CounterText, event: Event): Unit { function change(self: CounterText, event: Event): Unit {
self.value = 10 self.value = 10
} }
} }
// 声明 target 必须是 Block,可接收 CounterText,因为它是 TextBlock 的子类。
class Model { class Model {
target: Block target: Block
} }
// 构造唯一受检对象,Model、事件回调和 textOf 均引用它。
counter = CounterText("共享的原生子类对象", 9) counter = CounterText("共享的原生子类对象", 9)
model = Model(counter) model = Model(counter)
button = Button("把 Number 字段改成 10") button = Button("把 Number 字段改成 10")
+9
View File
@@ -1,11 +1,17 @@
// flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。
// 增长达到上限或收缩达到下限后,剩余空间/缺额重新分给其他尚可调整的项,固定项保持 60。
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
class Strip: Row { class Strip: Row {
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(340, 60) return MeasureResult(340, 60)
} }
} }
// 分别持有两个可调整矩形,按钮切换增长/收缩策略,而不是手动写最终 bounds。
class Actions { class Actions {
first: RectBlock first: RectBlock
second: RectBlock second: RectBlock
// 两项 basis=100,按1:3分增长;首项 max=105 触顶后,多余增长转给第二项。
function grow(self: Actions): Unit { function grow(self: Actions): Unit {
self.first.flexMin = 0 self.first.flexMin = 0
self.first.flexMax = 105 self.first.flexMax = 105
@@ -14,6 +20,7 @@ class Actions {
self.first.flex = 1 self.first.flex = 1
self.second.flex = 3 self.second.flex = 3
} }
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
function shrink(self: Actions): Unit { function shrink(self: Actions): Unit {
self.first.flexMax = 300 self.first.flexMax = 300
self.first.flexMin = 150 self.first.flexMin = 150
@@ -25,6 +32,7 @@ class Actions {
self.second.flexShrink = 1 self.second.flexShrink = 1
} }
} }
// 自然宽20/40与basis可不同;fixed不设置权重,保持原始60宽。
first = RectBlock(20, 20, 4, "#A4C8F0") first = RectBlock(20, 20, 4, "#A4C8F0")
second = RectBlock(40, 20, 4, "#A6D8B2") second = RectBlock(40, 20, 4, "#A6D8B2")
fixed = RectBlock(60, 20, 4, "#E8C990") fixed = RectBlock(60, 20, 4, "#E8C990")
@@ -33,6 +41,7 @@ grow = Button("增长:上限后重新分配")
grow.onClick(actions.grow) grow.onClick(actions.grow)
shrink = Button("收缩:最小尺寸后重新分配") shrink = Button("收缩:最小尺寸后重新分配")
shrink.onClick(actions.shrink) shrink.onClick(actions.shrink)
// 先初始化增长策略再挂载,首次发布即采用105/135/60,不需等待按钮点击。
actions.grow() actions.grow()
strip = Strip(10, 10, first, second, fixed) strip = Strip(10, 10, first, second, fixed)
page = Page() page = Page()
+7
View File
@@ -1,8 +1,14 @@
// 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
class Actions { class Actions {
filler: RectBlock filler: RectBlock
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
function balanced(self: Actions): Unit { self.filler.flex = 1 } function balanced(self: Actions): Unit { self.filler.flex = 1 }
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
function narrow(self: Actions): Unit { self.filler.flex = 3 } function narrow(self: Actions): Unit { self.filler.flex = 3 }
} }
// 共享文字的 flex=1 是业务布局策略,具体几何与自然尺寸缓存仍属于各 Mount。
paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。") paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
paragraph.flex = 1 paragraph.flex = 1
filler = RectBlock(20, 20) filler = RectBlock(20, 20)
@@ -12,6 +18,7 @@ balanced = Button("等比例分配")
balanced.onClick(actions.balanced) balanced.onClick(actions.balanced)
narrow = Button("文字只占四分之一") narrow = Button("文字只占四分之一")
narrow.onClick(actions.narrow) narrow.onClick(actions.narrow)
// 首排两个可伸缩子项,次排只有文字 flex 与固定 300px 兄弟,因此首排按钮不会改次排宽度。
first = Row(10, 10, paragraph, filler) first = Row(10, 10, paragraph, filler)
second = Row(10, 10, paragraph, RectBlock(300, 20)) second = Row(10, 10, paragraph, RectBlock(300, 20))
page = Page() page = Page()
+6
View File
@@ -1,5 +1,11 @@
// 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。
// 布局使用实际字形宽度,iiii 与 WWWW 后的色块位置应不同,不能用字符数量估算宽度。
// 字号2与独立颜色用于标题,中文是否可见取决于运行环境可用CJK字体。
let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422") let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422")
// 同一文字控件包含中英文和重音字符,用于观察字体回退与基线。
let detail = ui.text("中文 English 123 — café", 1, "#606060") let detail = ui.text("中文 English 123 — café", 1, "#606060")
// 两组相同尺寸色块跟在窄/宽字符后,12px gap 保持一致,差异来自真实文字测量。
let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF")) 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() let page = ui.page()
page.render(ui.column(24, 12, title, detail, row)) page.render(ui.column(24, 12, title, detail, row))
+9
View File
@@ -1,13 +1,22 @@
// Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。
// 参数和返回注解在真实调用边界校验,事件注册同样使用这些签名。
// Number 输入原样返回,用于说明参数类型在进入函数体前检查。
function identity(value: Number): Number { return value } function identity(value: Number): Number { return value }
// 接受 Function 值并零参数调用,结果仍需符合本函数的 Number 返回契约。
function callNumber(callback: Function): Number { return callback() } function callNumber(callback: Function): Number { return callback() }
// 接受声明 Visual 能力的对象,返回 Block;调用时两侧契约都要成立。
function visual(value: Visual): Block { return value } function visual(value: Visual): Block { return value }
// 保留共享 state,read 作为普通绑定函数使用,click 作为接收 Event 的事件回调。
class Actions { class Actions {
state: Object state: Object
// 读取模型值而不修改业务状态,保存 actions.read 时接收者已绑定。
function read(self: Actions): Number { return self.state.value } function read(self: Actions): Number { return self.state.value }
// 事件入口可忽略 Event 内容,但参数契约仍要求它是 Event,再修改同一 state。
function click(self: Actions, event: Event): Unit { self.state.increment() } function click(self: Actions, event: Event): Unit { self.state.increment() }
} }
state = ui.state(0) state = ui.state(0)
actions = Actions(state) actions = Actions(state)
// 保存绑定方法对象,之后无参调用仍使用 actions 作为 self;不是重新按字符串查找对象。
savedFunction = actions.read savedFunction = actions.read
initial = identity(callNumber(savedFunction)) initial = identity(callNumber(savedFunction))
button = Button("调用带类型检查的对象方法") button = Button("调用带类型检查的对象方法")
+10 -4
View File
@@ -1,12 +1,13 @@
// Record ui.stats().objects, call mountCycle(), then inspect phase.value and // 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。
// pending.value.status(). Close the tab: the referenced Block still waits. // 关闭临时页仍可因业务引用保留等待任务;releaseCycle 清掉外部引用后,缓存不应让整个引用环常驻。
// releaseCycle() removes both external references; the next objects query // 比较操作前后对象数时,关闭页面只解除展示根;holder/pending 外部引用也释放后才应回到基线。
// should return to the same baseline before the five-minute wait finishes.
holder = ui.state(0) holder = ui.state(0)
pending = ui.state(0) pending = ui.state(0)
phase = ui.state(0) phase = ui.state(0)
// 通过 peer 指回包含自己的 Page,配合等待协程形成包含视觉缓存读依赖的引用环。
class CycleRect: RectBlock { class CycleRect: RectBlock {
peer: Object peer: Object
// sleep 归属该 rect,await 保留协程帧;先写 phase=1,只有实际恢复后才写 2 并改 Page 标题。
function wait(self: CycleRect): Unit { function wait(self: CycleRect): Unit {
gate = ui.sleep(self,"sleep",300000) gate = ui.sleep(self,"sleep",300000)
phase.set(1) phase.set(1)
@@ -14,15 +15,18 @@ class CycleRect: RectBlock {
phase.set(2) phase.set(2)
self.peer.title = "等待已结束" self.peer.title = "等待已结束"
} }
// 故意读取 peer.title,让布局依赖包含循环中的 Page;缓存应借用观察,不增加长期 GC 根。
function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult {
observed = self.peer.title observed = self.peer.title
return MeasureResult(180,40) return MeasureResult(180,40)
} }
// 绘制也读取 peer.title,检查绘制缓存与等待帧不会共同意外保活循环。
function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList {
observed = self.peer.title observed = self.peer.title
return RectBlock.paint(self,mount,geometry) return RectBlock.paint(self,mount,geometry)
} }
} }
// 创建临时 Page 和 CycleRect,相互引用后挂载,再把 Page/Task 分别放入两个外部 holder。
function mountCycle(): Unit { function mountCycle(): Unit {
transient = Page() transient = Page()
transient.title = "临时引用环" transient.title = "临时引用环"
@@ -32,11 +36,13 @@ function mountCycle(): Unit {
phase.set(0) phase.set(0)
pending.set(ui.spawn(rect,"workflow",rect.wait)) pending.set(ui.spawn(rect,"workflow",rect.wait))
} }
// 先关闭临时页,随后去掉两个外部引用;等待依赖无需等满五分钟,失去可达性后应整体回收。
function releaseCycle(): Unit { function releaseCycle(): Unit {
holder.value.close() holder.value.close()
holder.set(0) holder.set(0)
pending.set(0) pending.set(0)
} }
// 基线页面始终存在,用它比较操作前后的托管对象数,不把临时页面当作基线。
base = Page() base = Page()
base.title = "GC 基线" base.title = "GC 基线"
base.render(TextBlock("关闭临时 Page 后应回收引用环",2)) base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
+5
View File
@@ -1,11 +1,16 @@
// 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使绘制失效。
color = "#ED6A5A" color = "#ED6A5A"
// 保持原生几何,覆盖颜色策略,使示例只观察全局读取的依赖粒度。
class GlobalPaint: RectBlock { class GlobalPaint: RectBlock {
// 调用原生基类产出新命令,读取当前 color 并写到新命令;不在只读阶段修改全局。
function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList { function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self,mount,geometry) drawing = RectBlock.paint(self,mount,geometry)
drawing.commands.at(0).color = color drawing.commands.at(0).color = color
return drawing return drawing
} }
} }
// 显示固定大小矩形和操作提示,后续通过 Console 改同一个会话的 color。
rect = GlobalPaint(480,120,12,"#DDEEFF") rect = GlobalPaint(480,120,12,"#DDEEFF")
page = Page() page = Page()
page.title = "全局名字依赖" page.title = "全局名字依赖"
+7 -7
View File
@@ -1,10 +1,10 @@
function heading(value: Text): View { function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。
return ui.text(value, 2, "#242422") return ui.text(value, 2, "#242422") // 这里只创建文字对象,具体宽高由实际字体和出现位置约束测量。
} }
function main(): View { function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。
let title = heading("Hello Endor") let title = heading("Hello Endor") // 调用上述辅助函数,局部变量持有同一托管文字对象。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") 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")) 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) return ui.column(24, 12, title, rectangle, detail) // Column 的 24px 内边距和 12px 间隔决定最终命令位置;返回后发布快照拥有所需数据。
} }
+297
View File
@@ -0,0 +1,297 @@
// All application state, motion, fire, hit reactions and scheduling live here.
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
g = ui.graphics
math = g.math
assets = "examples/assets/manuscript/"
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
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)
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 {
result = angle
while result > math.pi { result = result - math.pi * 2 }
while result < -math.pi { result = result + math.pi * 2 }
return result
}
class Segment {
x: Number
y: Number
angle: Number
image: Object
}
// Asset orientation is application data: angles are radians, clockwise in
// screen coordinates. Each PNG is calibrated against its own long axis.
class FireArt {
image: Object
axis: Number
}
class Direction { angle: Number }
class Flame {
x: Number
y: Number
vx: Number
vy: Number
size: Number
age: Number
art: FireArt
}
class Dragon {
segments: Object
flames: Object
fireImages: Object
tongue: Object
frontWing: Object
backWing: Object
targetX: Number
targetY: Number
pressed: Number
time: Number
lastMove: Number
seed: Number
function random(self: Dragon): Number {
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 {
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
self.targetY = (event.y - 30) / scale
self.lastMove = math.now()
}
function down(self: Dragon, event: Event): Unit {
self.pointer(event)
self.pressed = 1
}
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
function leave(self: Dragon, event: Event): Unit {
self.pressed = 0
self.lastMove = -10
}
function step(self: Dragon): Unit {
self.time = math.now()
if self.time - self.lastMove > 2 {
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 {
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
}
s.x = s.x + (x - s.x) * 0.12
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 {
speed = math.min(distance, math.max(12, distance * 0.15))
head.x = head.x + dx / distance * speed
head.y = head.y + dy / distance * speed
head.angle = math.atan2(dy, dx)
}
i = 1
while i < self.segments.count {
previous = self.segments.at(i - 1)
s = self.segments.at(i)
angle = math.atan2(previous.y - s.y, previous.x - s.x)
s.angle = previous.angle + math.clamp(wrappedAngle(angle - previous.angle), -0.25, 0.25)
s.x = previous.x - math.cos(s.angle) * 30
s.y = previous.y - math.sin(s.angle) * 30
i = i + 1
}
}
i = self.flames.count
while i > 0 {
i = i - 1
p = self.flames.at(i)
p.age = p.age + 1
p.x = p.x + p.vx
p.y = p.y + p.vy
p.vx = p.vx * 0.95
p.vy = p.vy * 0.95 - 0.6
if p.age >= 12 { self.flames.remove(i) }
}
if self.pressed > 0 {
head = self.segments.at(0)
i = 0
while i < 1 {
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))
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
}
}
}
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
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))
i = self.segments.count
while i > 0 {
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 p.age > 4 { drawing.tint = "#E08A30" }
if p.age > 8 { drawing.tint = "#F0C030" }
commands.append(drawing)
i = i + 1
}
return commands
}
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
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 {
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
c.angle = c.angle + weight * 0.12
c.opacity = math.max(0.3, c.opacity - weight * 0.2)
c.tint = "#C4402A"
}
}
}
}
j = j + 1
}
i = i + 1
}
}
}
class Manuscript: RectBlock {
dragon: Dragon
page: Object
prepared: Object
small: Object
initial: Object
timer: Object
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
}
function onMount(self: Manuscript, mount: Mount): Unit {
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 {
self.dragon.step()
self.timer = ui.after(self.page, "animation", 80, self.tick)
}
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
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
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)
commands.extend(footer.commands)
return DisplayList(commands)
}
}
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
x = math.float(150)
y = math.float(110)
while i < 20 {
angle = -i / 19 * math.pi * 0.7
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
image = g.loadImage(assets + "dragon-sprites/body-" + g.string(i) + ".png")
segments.append(Segment(x, y, angle, image))
i = i + 1
}
}
makeBody()
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 {
i = 2
while i <= 11 {
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 = 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())
view.flex = 1
view.boxWidth = "fill"
page.crossAlign = "stretch"
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)
page.render(view)
+12
View File
@@ -1,32 +1,44 @@
// 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。
// ViewportBand 还显式读取实际页面视口,使原生 resize 可通过依赖触发重测。
// 只负责测量需要的宽度,和绘制颜色分成两个业务对象以便观察独立失效。
class WidthModel { class WidthModel {
width: Number width: Number
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
function expand(self: WidthModel, event: Event): Unit { function expand(self: WidthModel, event: Event): Unit {
self.width = 260 self.width = 260
} }
} }
// 保存 paint 使用的颜色,不参与 CachedRect 的测量。
class Tint { class Tint {
color: String color: String
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
function blue(self: Tint, event: Event): Unit { function blue(self: Tint, event: Event): Unit {
self.color = "#526AC7" self.color = "#526AC7"
} }
} }
// 原生矩形的尺寸/颜色策略分别由源协议覆盖,业务模型通过字段持有。
class CachedRect: RectBlock { class CachedRect: RectBlock {
dimensions: WidthModel dimensions: WidthModel
tint: Tint tint: Tint
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(self.dimensions.width, 60) return MeasureResult(self.dimensions.width, 60)
} }
// 复用原生矩形命令,再改新命令的颜色;修改新结果被允许,但 paint 不能修改已有业务对象。
function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry) drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint.color drawing.commands.at(0).color = self.tint.color
return drawing return drawing
} }
} }
// 宽度来自 ui.viewport 的当前逻辑页面区域,显示窗口变化也能成为测量依赖。
class ViewportBand: RectBlock { class ViewportBand: RectBlock {
// 返回可用视口宽度,最终仍服从父容器约束;外层 padding 会减少分配宽度。
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(ui.viewport().width, 24) return MeasureResult(ui.viewport().width, 24)
} }
} }
// 创建相互独立的宽度/颜色模型并绑定两个按钮,用相同对象观察缓存与可见结果。
dimensions = WidthModel(200) dimensions = WidthModel(200)
tint = Tint("#ED6A5A") tint = Tint("#ED6A5A")
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint) rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
+12
View File
@@ -1,24 +1,34 @@
// 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。
// paragraph 被两个容器共享;每个 Mount 使用独立约束,不能把新宽度写回共享文字状态。
// 保存第一个段落出现位置的分配宽度,按钮只改策略值。
class Policy { class Policy {
width: Number width: Number
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
function narrow(self: Policy, event: Event): Unit { self.width = 280 } function narrow(self: Policy, event: Event): Unit { self.width = 280 }
// 选择 560 逻辑像素宽度,段落变矮;另一个容器宽度不跟着改变。
function wide(self: Policy, event: Event): Unit { self.width = 560 } function wide(self: Policy, event: Event): Unit { self.width = 560 }
} }
// 在原生 Column 排列基础上覆盖第一个稳定子槽的宽度。
class ArrangedColumn: Column { class ArrangedColumn: Column {
policy: Policy policy: Policy
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement { function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
result = Column.arrange(self, mount, geometry) result = Column.arrange(self, mount, geometry)
result.children.at(0).width = self.policy.width result.children.at(0).width = self.policy.width
return result return result
} }
} }
// 通过源 measure 改整个容器的期望宽度,以对比 arrange 分配路径。
class MeasuredColumn: Column { class MeasuredColumn: Column {
width: Number width: Number
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints) result = Column.measure(self, mount, constraints)
result.width = self.width result.width = self.width
return result return result
} }
} }
// 两个容器使用同一个 paragraph,分别由 Policy=280 与 measure.width=360 约束。
policy = Policy(280) policy = Policy(280)
paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。") paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。")
tail = TextBlock("这一行跟随上方段落。") tail = TextBlock("这一行跟随上方段落。")
@@ -26,10 +36,12 @@ arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360) measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360)
narrow = Button("arrange 为 280") narrow = Button("arrange 为 280")
wide = Button("arrange 为 560") wide = Button("arrange 为 560")
// 保留事件订阅引用,两个按钮调用同一 Policy 的不同方法。
narrowConnection = narrow.on("click", policy.narrow) narrowConnection = narrow.on("click", policy.narrow)
wideConnection = wide.on("click", policy.wide) wideConnection = wide.on("click", policy.wide)
page = Page() page = Page()
page.title = "约束协商" page.title = "约束协商"
page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured)) page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured))
// 成功输入结束后宿主才消费原生窗口命令;窗口实际尺寸再回传为下一次布局约束。
ui.window().resize(850, 700) ui.window().resize(850, 700)
ui.window().setTitle("测量与排列直到稳定") ui.window().setTitle("测量与排列直到稳定")
+4
View File
@@ -1,5 +1,9 @@
// 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。
// 返回 Column 视觉对象,宿主选择/挂载页面;这里的 title 和 rectangle 只在 VM 内建立业务引用。
function main(): View { function main(): View {
// 文字内容与矩形参数和 page 示例一致,最终绘图命令应等价。
let heading = ui.text("Hello Endor", 2, "#242422") let heading = ui.text("Hello Endor", 2, "#242422")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
// 布局边距与间隔保持一致;不同 VM 的对象/Mount 身份独立,命令相同不表示共享对象。
return ui.column(24, 12, heading, rectangle) return ui.column(24, 12, heading, rectangle)
} }
+5
View File
@@ -1,9 +1,14 @@
// 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。
// 两个文字绑定读同一 counter;原生点击和 Console 都能操作该对象并传播显示变化。
// state 提供可写 value 和 increment 方法,textOf 建立显式字段到文本的依赖。
counter = ui.state(9) counter = ui.state(9)
first = ui.textOf(counter, "value", 2) first = ui.textOf(counter, "value", 2)
second = ui.textOf(counter, "value", 2) second = ui.textOf(counter, "value", 2)
title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2) title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2)
button = Button("增加计数") button = Button("增加计数")
// 零参数源回调委托原生 state.increment;事件分发器根据签名调用,不强塞 Event 参数。
function increment(): Unit { counter.increment() } function increment(): Unit { counter.increment() }
// 把实际函数作为回调而非函数名文本;render 之前只创建业务对象,render 后才有出现位置。
button.onClick(increment) button.onClick(increment)
page = Page() page = Page()
page.render(Column(24, 16, title, Row(0, 16, first, second, button))) page.render(Column(24, 16, title, Row(0, 16, first, second, button)))
+10
View File
@@ -1,23 +1,33 @@
// 方法覆盖遵守参数可更宽、返回可更具体的规则。
// WideRect 复用基类原生测量后返回 Sized 子类结果;事件改变基础 width 后按同一路径重测。
// MeasureResult 的名义子类,不增加字段,用于演示返回值协变。
class Sized: MeasureResult {} class Sized: MeasureResult {}
// 用源方法显式委托原生测量,保留 Mount/Constraints 的基类契约。
class BaseRect: RectBlock { class BaseRect: RectBlock {
// 原生方法按给定出现位置与约束测量,不把几何存进共享对象。
function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult {
return RectBlock.measure(self, mount, constraints) return RectBlock.measure(self, mount, constraints)
} }
} }
// 覆盖参数放宽为 Object,返回缩窄为 Sized;不能反过来缩窄参数或放宽返回。
class WideRect: BaseRect { class WideRect: BaseRect {
// 调用基类后复制测量数值到新的 Sized;新结果仍满足 MeasureResult 协议。
function measure(self: WideRect, mount: Object, constraints: Object): Sized { function measure(self: WideRect, mount: Object, constraints: Object): Sized {
measured = BaseRect.measure(self, mount, constraints) measured = BaseRect.measure(self, mount, constraints)
return Sized(measured.width, measured.height) return Sized(measured.width, measured.height)
} }
} }
// 保存具体 WideRect 和计数器,使宽度修改和点击次数同时作为业务变化传播。
class Actions { class Actions {
rect: WideRect rect: WideRect
count: Object count: Object
// 先改 width 再递增计数,新的布局刷新会使用上述动态测量覆盖。
function widen(self: Actions): Unit { function widen(self: Actions): Unit {
self.rect.width = 260 self.rect.width = 260
self.count.increment() self.count.increment()
} }
} }
// 初始矩形宽 180;按钮事件完成后应变为 260,同时文字显示计数。
rect = WideRect(180, 60, 8, "#ED6A5A") rect = WideRect(180, 60, 8, "#ED6A5A")
count = ui.state(0) count = ui.state(0)
actions = Actions(rect, count) actions = Actions(rect, count)
+10
View File
@@ -1,3 +1,6 @@
// 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。
// 普通数据变化不会重新挂载;页面关闭时子项先卸载,父项随后卸载,旧 Mount 不因重开而复活。
// order 是统一时序,mounted/unmounted 是子项次数;latest 保存最近通知的实际 Mount。
class LifecycleLog { class LifecycleLog {
order: Object order: Object
mounted: Object mounted: Object
@@ -8,8 +11,10 @@ class LifecycleLog {
childUnmounted: Number childUnmounted: Number
latest: Object latest: Object
} }
// 共享文字用 log 记录两个出现位置,源钩子接收的是当前具体 Mount。
class WatchedText: TextBlock { class WatchedText: TextBlock {
log: LifecycleLog log: LifecycleLog
// 记录顺序和次数,保存 Mount 并修改原文字;运行时在回调后重新收敛才发布。
function onMount(self: WatchedText, mount: Mount): Unit { function onMount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.mounted.increment() self.log.mounted.increment()
@@ -17,6 +22,7 @@ class WatchedText: TextBlock {
self.log.latest.set(mount) self.log.latest.set(mount)
self.setText("同一个对象,两个独立挂载位置") self.setText("同一个对象,两个独立挂载位置")
} }
// 记录已经脱离的出现位置;保留 Mount 可查询 mounted,但不能再当有效 bounds 使用。
function onUnmount(self: WatchedText, mount: Mount): Unit { function onUnmount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.unmounted.increment() self.log.unmounted.increment()
@@ -24,17 +30,21 @@ class WatchedText: TextBlock {
self.log.latest.set(mount) self.log.latest.set(mount)
} }
} }
// 父容器也记录钩子序号,便于对比挂载与子先父后的卸载顺序。
class WatchedColumn: Column { class WatchedColumn: Column {
log: LifecycleLog log: LifecycleLog
// 记录父挂载顺序,不修改容器结构;子项随后各自执行通知。
function onMount(self: WatchedColumn, mount: Mount): Unit { function onMount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.parentMounted = self.log.order.value self.log.parentMounted = self.log.order.value
} }
// 父卸载时子项卸载已完成,序列号应位于所有子项通知之后。
function onUnmount(self: WatchedColumn, mount: Mount): Unit { function onUnmount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.parentUnmounted = self.log.order.value self.log.parentUnmounted = self.log.order.value
} }
} }
// 多个 state 容器分别保存计数/最后 Mount,root 最后参数是源 log 字段,前面才是可变视觉子项。
log = LifecycleLog(ui.state(0), ui.state(0), ui.state(0), 0, 0, 0, 0, ui.state(0)) log = LifecycleLog(ui.state(0), ui.state(0), ui.state(0), 0, 0, 0, 0, ui.state(0))
child = WatchedText("等待挂载", 2, log) child = WatchedText("等待挂载", 2, log)
root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log) root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log)
+7
View File
@@ -1,12 +1,19 @@
// 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。
// 同一事件注册机制按实际签名调用;label 绑定再把事件 kind 转成可显示的字符串。
// 保留最后一次收到的 Event 对象,初值 kind=waiting;不是只存一份格式化事件文本。
received = ui.state(Event("waiting")) received = ui.state(Event("waiting"))
count = ui.state(0) count = ui.state(0)
// 只读计算提取当前事件种类,作为 label.text 绑定的依赖。
function eventKind(): String { return received.value.kind } function eventKind(): String { return received.value.kind }
label = TextBlock("waiting") label = TextBlock("waiting")
subscription = label.bind("text", eventKind) subscription = label.bind("text", eventKind)
withEvent = Button("传递 Event 给原生 set") withEvent = Button("传递 Event 给原生 set")
// received.set 是一参数原生方法,分发器传入实际 click Event。
firstConnection = withEvent.on("click", received.set) firstConnection = withEvent.on("click", received.set)
withoutEvent = Button("调用零参数原生 increment") withoutEvent = Button("调用零参数原生 increment")
// count.increment 的签名为零参数,分发器调用时不传多余 Event。
secondConnection = withoutEvent.on("click", count.increment) secondConnection = withoutEvent.on("click", count.increment)
// 把已绑定状态与两种回调放到同一页面,便于对比输入效果。
page = Page() page = Page()
page.title = "原生方法签名" page.title = "原生方法签名"
page.render(Column(24, 16, TextBlock("源码和原生回调共用参数契约"), label, ui.textOf(count, "value"), withEvent, withoutEvent)) page.render(Column(24, 16, TextBlock("源码和原生回调共用参数契约"), label, ui.textOf(count, "value"), withEvent, withoutEvent))
+10
View File
@@ -1,33 +1,43 @@
// 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。
// label 在树中出现多次但仍是同一对象,原生方法和源字段写入共同更新各出现位置。
// 扩展 TextBlock 的业务字段;其文本/颜色仍由原生父类存储与绘制。
class StatusText: TextBlock { class StatusText: TextBlock {
activeColor: String activeColor: String
// 用继承的 setText/setColor 改原对象;activeColor 是构造时追加的源字段。
function activate(self: StatusText, event: Event): Unit { function activate(self: StatusText, event: Event): Unit {
self.setText("继承的原生文本已更新") self.setText("继承的原生文本已更新")
self.setColor(self.activeColor) self.setColor(self.activeColor)
} }
} }
// 继续继承源子类,添加 message 并覆盖事件入口,保留 TextBlock 的原生能力。
class CustomStatus: StatusText { class CustomStatus: StatusText {
message: String message: String
// 显式调用基类 setText 以示范原生未绑定方法的接收者传递,再用继承方法改颜色。
function activate(self: CustomStatus, event: Event): Unit { function activate(self: CustomStatus, event: Event): Unit {
TextBlock.setText(self, self.message) TextBlock.setText(self, self.message)
self.setColor(self.activeColor) self.setColor(self.activeColor)
} }
} }
// 按钮保存目标 Block,事件到来时把同一 Event 转交给目标的动态 activate。
class ActionButton: Button { class ActionButton: Button {
target: Block target: Block
// 不复制目标或创建新展示对象,直接调用目标业务方法,选择实际类型的覆盖实现。
function activate(self: ActionButton, event: Event): Unit { function activate(self: ActionButton, event: Event): Unit {
self.target.activate(event) self.target.activate(event)
} }
} }
// Page 也可增加源字段;caption 用于构建页内标题,不替代原生窗口标题。
class CustomPage: Page { class CustomPage: Page {
caption: String caption: String
} }
// 前面位置参数供原生 TextBlock 构造,后面的颜色/消息供继承字段;所有引用都保留托管类型。
label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作") label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作")
button = ActionButton("调用源码子类方法", label) button = ActionButton("调用源码子类方法", label)
button.onClick(button.activate) button.onClick(button.activate)
+8
View File
@@ -1,15 +1,22 @@
// owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。
// 每个 owner 下同 key 的重复点击替换旧定时器;任务生命周期不由画面是否可见推断。
// 把 owner 与要修改的 state 分开持有,使同一个调度方法可用于三个作用域。
class Actions { class Actions {
owner: Object owner: Object
state: Object state: Object
// 定时任务成功交付后修改共享计数,零参数签名符合 timer 回调要求。
function complete(self: Actions): Unit { self.state.increment() } function complete(self: Actions): Unit { self.state.increment() }
// 每次启动 1500ms 的 load 定时器;相同 owner/key 只有最新任务继续等待。
function schedule(self: Actions, event: Event): Unit { function schedule(self: Actions, event: Event): Unit {
ui.after(self.owner, "load", 1500, self.complete) ui.after(self.owner, "load", 1500, self.complete)
} }
} }
state = ui.state(0) state = ui.state(0)
// 该 Block 从未进入页面树,但全局引用仍持有它,因此它拥有的任务可执行。
hidden = TextBlock("这个 Block 没有挂载,也能拥有任务") hidden = TextBlock("这个 Block 没有挂载,也能拥有任务")
page = Page() page = Page()
page.title = "任务生命周期" page.title = "任务生命周期"
// 三个 Action 共享一个 state,却分别归属 Page、Block、应用,便于比较关闭页面的取消边界。
pageActions = Actions(page, state) pageActions = Actions(page, state)
blockActions = Actions(hidden, state) blockActions = Actions(hidden, state)
appActions = Actions(ui, state) appActions = Actions(ui, state)
@@ -20,5 +27,6 @@ pageConnection = pageButton.on("click", pageActions.schedule)
blockConnection = blockButton.on("click", blockActions.schedule) blockConnection = blockButton.on("click", blockActions.schedule)
appConnection = appButton.on("click", appActions.schedule) appConnection = appButton.on("click", appActions.schedule)
page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton)) page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton))
// 额外应用级任务延迟 500ms,用于展示没有点击也可按 worker 空闲轮询交付。
initial = ui.after(ui, "initial", 500, appActions.complete) initial = ui.after(ui, "initial", 500, appActions.complete)
ui.window().setTitle("Owned Tasks") ui.window().setTitle("Owned Tasks")
+4 -4
View File
@@ -1,4 +1,4 @@
let heading = ui.text("Hello Endor", 2, "#242422") let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 创建固定逻辑尺寸矩形;12 为圆角半径,业务对象尚未出现在任何页面中。
let myui = ui.page() let myui = ui.page() // 创建托管 Page 资源;它负责持有展示根和页面生命周期。
myui.render(ui.column(24, 12, heading, rectangle)) myui.render(ui.column(24, 12, heading, rectangle)) // 把文字和矩形放入 Column,padding=24、gap=12;render 选择此页并生成出现位置及发布帧。
+11
View File
@@ -1,27 +1,38 @@
// 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。
// 生成的视图保存 source 指向业务模型,绑定和按钮继续使用原对象,展示不是业务数据副本。
// name/color 是业务字段,零参读取方法提供只读绑定,update 提供事件修改。
class Product { class Product {
name: String name: String
color: String color: String
// 返回名称并记录实际属性读取,后续 name 修改会使文本绑定重算。
function label(self: Product): String { return self.name } function label(self: Product): String { return self.name }
// 只读返回颜色,绑定到新建色块的 color。
function shade(self: Product): String { return self.color } function shade(self: Product): String { return self.color }
// 点击更新原业务对象,现有绑定会同步文字和矩形,不重新调用整个 present 构造树。
function update(self: Product, event: Event): Unit { function update(self: Product, event: Event): Unit {
self.name = "原业务对象已更新" self.name = "原业务对象已更新"
self.color = "#237A45" self.color = "#237A45"
} }
// 在指定 Window 上下文创建文字、色块和按钮,连接原 self 的方法后返回组合根。
function present(self: Product, window: Window): Block { function present(self: Product, window: Window): Block {
title = TextBlock(self.name) title = TextBlock(self.name)
swatch = RectBlock(180,48,8,self.color) swatch = RectBlock(180,48,8,self.color)
// 绑定令牌本身不承载另一份业务状态,绑定计算仍调用同一 Product。
titleBinding = title.bind("text",self.label) titleBinding = title.bind("text",self.label)
colorBinding = swatch.bind("color",self.shade) colorBinding = swatch.bind("color",self.shade)
button = Button("调用业务对象方法") button = Button("调用业务对象方法")
connection = button.on("click",self.update) connection = button.on("click",self.update)
result = Column(24,16,title,swatch,button) result = Column(24,16,title,swatch,button)
// 保留原始业务身份,便于代码从展示根回到来源;contextWindow 仅记录创建时的窗口编号。
result.source = self result.source = self
result.contextWindow = window.id() result.contextWindow = window.id()
return result return result
} }
} }
// 显式声明能力并验证方法契约;只有同名 present 不会自动成为 Presentable。
ui.implement(Product,Presentable) ui.implement(Product,Presentable)
product = Product("普通对象的显式展示能力", "#526AC7") product = Product("普通对象的显式展示能力", "#526AC7")
// 先获得视觉根,再由 ui.open 把它挂载到页面;最后设置 Page 标题。
view = ui.present(product) view = ui.present(product)
page = ui.open(view) page = ui.open(view)
page.title = "Presentable" page.title = "Presentable"
+9
View File
@@ -1,10 +1,17 @@
// 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。
// 绑定在目标隐藏时也会更新;两个页面共享同一组 Block,不需要两份模型。
// width/color/label 是唯一业务来源,绑定计算只读,expand 是显式事件写入入口。
class Model { class Model {
width: Number width: Number
color: String color: String
label: String label: String
// 返回 Number,目标 width 属性同时做类型/尺寸验证。
function getWidth(self: Model): Number { return self.width } function getWidth(self: Model): Number { return self.width }
// 返回颜色文本;依赖只包含实际读取的模型属性。
function getColor(self: Model): String { return self.color } function getColor(self: Model): String { return self.color }
// 返回当前说明文字,供 TextBlock.text 绑定使用。
function getLabel(self: Model): String { return self.label } function getLabel(self: Model): String { return self.label }
// 一个事件同时改三个属性,随后绑定传播,布局与绘制在完整刷新后发布。
function expand(self: Model, event: Event): Unit { function expand(self: Model, event: Event): Unit {
self.width = 260 self.width = 260
self.color = "#237A45" self.color = "#237A45"
@@ -14,6 +21,7 @@ class Model {
model = Model(120, "#526AC7", "点击更新共享对象") model = Model(120, "#526AC7", "点击更新共享对象")
rect = RectBlock(120, 40, 8, "#526AC7") rect = RectBlock(120, 40, 8, "#526AC7")
label = TextBlock("初始化") label = TextBlock("初始化")
// 保存三个可断开的绑定令牌;令牌不强持有隐藏目标,目标仍需由业务对象图保留。
widthBinding = rect.bind("width", model.getWidth) widthBinding = rect.bind("width", model.getWidth)
colorBinding = rect.bind("color", model.getColor) colorBinding = rect.bind("color", model.getColor)
labelBinding = label.bind("text", model.getLabel) labelBinding = label.bind("text", model.getLabel)
@@ -23,6 +31,7 @@ root = Column(24, 16, label, rect, button)
page = Page() page = Page()
page.title = "通用属性绑定" page.title = "通用属性绑定"
page.render(root) page.render(root)
// 第二页面使用同一根容器,切页只更换 Mount 展示上下文;最后 activate 回第一页面。
second = Page() second = Page()
second.title = "同一组 Block" second.title = "同一组 Block"
second.render(root) second.render(root)
+9 -2
View File
@@ -1,7 +1,10 @@
// The scene has world positions; each finite viewport has its own camera. // 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。
// 内层视口也可作为世界子项;HUD 在外层 Overlay 中,所以不会随世界相机变换。
// 场景保存世界对象,视口负责有限窗口与相机变换。
count = ui.state(0) count = ui.state(0)
increment = Button("共享计数 +1") increment = Button("共享计数 +1")
increment.onClick(count.increment) increment.onClick(count.increment)
// 业务卡片带世界 x/y 和边框,count 文字与按钮共同保留同一 state。
card = Column(20, 12, TextBlock("世界里的 UI", 2), ui.textOf(count, "value", 3), increment) card = Column(20, 12, TextBlock("世界里的 UI", 2), ui.textOf(count, "value", 3), increment)
card.boxWidth = 220 card.boxWidth = 220
card.x = -60 card.x = -60
@@ -9,17 +12,21 @@ card.y = 40
card.borderLeft = 2 card.borderLeft = 2
card.borderColor = "#8CA5B8" card.borderColor = "#8CA5B8"
// 有限内层视口看自己的小场景,再以世界坐标放进外层 Scene2D。
inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9"))) inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9")))
inner.x = 260 inner.x = 260
inner.y = 40 inner.y = 40
// 两个外层视口引用同一 world,卡片/内层视口因此各产生两个出现位置。
world = Scene2D(card, inner) world = Scene2D(card, inner)
// 左右视口尺寸和相机平移/缩放不同;显式赋同一 camera 才会共享导航状态。
left = CanvasViewport(460, 320, world) left = CanvasViewport(460, 320, world)
left.camera.x = -100 left.camera.x = -100
right = CanvasViewport(320, 320, world) right = CanvasViewport(320, 320, world)
right.camera.x = -80 right.camera.x = -80
right.camera.zoom = 0.65 right.camera.zoom = 0.65
// Set right.camera = left.camera to share a view explicitly. // 如需联动,在 Console 将 right.camera 赋为 left.camera;共享场景本身不会自动共享相机。
// 先横排两个视图,再把固定 HUD 叠在它们之上,输入命中也使用组合后的几何。
views = Row(0, 20, left, right) views = Row(0, 20, left, right)
hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放")) hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放"))
hud.alignSelf = "end" hud.alignSelf = "end"
+11
View File
@@ -1,19 +1,27 @@
// 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。
// 三维内容与普通 Endor 二维控件一起排版,viewport 有限尺寸不限制场景世界坐标。
// 保存左侧视口和共享立方体;动作可改相机位置或真实网格变换。
class SceneActions { class SceneActions {
view: Viewport3D view: Viewport3D
cube: Mesh3D cube: Mesh3D
// 把左相机放到 z=6 正面,保持相机既有 target。
function front(self: SceneActions): Unit { function front(self: SceneActions): Unit {
self.view.camera.x = 0 self.view.camera.x = 0
self.view.camera.y = 0 self.view.camera.y = 0
self.view.camera.z = 6 self.view.camera.z = 6
} }
// 把同一相机移到侧上方,右侧正交相机保持原设置。
function side(self: SceneActions): Unit { function side(self: SceneActions): Unit {
self.view.camera.x = 6 self.view.camera.x = 6
self.view.camera.y = 2 self.view.camera.y = 2
self.view.camera.z = 0 self.view.camera.z = 0
} }
// 修改共享 cube 的欧拉旋转,因此两个视口都看到物体变化。
function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) } function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) }
// 示范 Event.hit.mesh 的真实托管网格材质写入;此文件尚未把 select 注册到视口。
function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" } function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" }
} }
// 先构造并变换两个 Box3D,再由 Group3D 组成场景节点;几何与相机是不同对象。
cube = Box3D(1.6, 1.6, 1.6, "#6687AB") cube = Box3D(1.6, 1.6, 1.6, "#6687AB")
cube.rotationY = 25 cube.rotationY = 25
cube.rotationX = 15 cube.rotationX = 15
@@ -21,11 +29,13 @@ small = Box3D(0.7, 0.7, 0.7, "#DBA36E")
small.x = 1.8 small.x = 1.8
small.z = -0.8 small.z = -0.8
group = Group3D(cube, small) group = Group3D(cube, small)
// 薄盒作地面,方向光与 ambient 一起影响材质颜色,不是二维背景矩形。
floor = Box3D(6, 0.08, 4, "#D8DEE6") floor = Box3D(6, 0.08, 4, "#D8DEE6")
floor.y = -1.1 floor.y = -1.1
light = DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9) light = DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9)
scene = Scene3D(group, floor, light) scene = Scene3D(group, floor, light)
scene.ambient = 0.35 scene.ambient = 0.35
// 左视口使用默认透视相机,右视口显式换成 OrthographicCamera 并设置可见高度。
perspective = Viewport3D(480, 360, scene) perspective = Viewport3D(480, 360, scene)
perspective.camera.z = 6 perspective.camera.z = 6
orthographic = Viewport3D(320, 360, scene) orthographic = Viewport3D(320, 360, scene)
@@ -34,6 +44,7 @@ orthographic.camera.height = 5
orthographic.camera.x = 4 orthographic.camera.x = 4
orthographic.camera.y = 2 orthographic.camera.y = 2
orthographic.camera.z = 5 orthographic.camera.z = 5
// 按钮绑定零参数方法;页面文字说明原生默认控制器的交互,代码可继续替换 controller。
actions = SceneActions(perspective, cube) actions = SceneActions(perspective, cube)
front = Button("正面") front = Button("正面")
front.onClick(actions.front) front.onClick(actions.front)
+10
View File
@@ -1,21 +1,31 @@
// 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。
// 同一 paragraph 出现两次,文字内容共享,换行/偏移/范围在各 Mount 中独立。
// 通过具体出现位置的 Mount 操作偏移;ScrollBlock.axis 则是对象级策略,可影响其所有出现位置。
class Actions { class Actions {
vertical: ScrollBlock vertical: ScrollBlock
horizontal: ScrollBlock horizontal: ScrollBlock
// 只对 vertical 第一个 Mount 累加纵偏移,运行时按当前范围夹取。
function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) } function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) }
// 只对 horizontal 第一个 Mount 累加横偏移,禁止轴仍保持零。
function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) } function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) }
// 分别把两个出现位置移回原点,不修改共享段落文字。
function reset(self: Actions): Unit { function reset(self: Actions): Unit {
self.vertical.mounts().at(0).scrollTo(0, 0) self.vertical.mounts().at(0).scrollTo(0, 0)
self.horizontal.mounts().at(0).scrollTo(0, 0) self.horizontal.mounts().at(0).scrollTo(0, 0)
} }
// 把左对象策略改为双轴,下一次求解重新计算约束和范围。
function both(self: Actions): Unit { self.vertical.axis = "both" } function both(self: Actions): Unit { self.vertical.axis = "both" }
} }
paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。") paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。")
// 纵向区域padding后内容宽为244,长文字按此有限宽换行。
vertical = ScrollBlock(260, 140, paragraph) vertical = ScrollBlock(260, 140, paragraph)
vertical.padding = 8 vertical.padding = 8
vertical.axis = "vertical" vertical.axis = "vertical"
// 横向区域沿滚动轴允许内容自然展开,所以同一文字可以比左侧更宽、更矮。
horizontal = ScrollBlock(260, 140, paragraph) horizontal = ScrollBlock(260, 140, paragraph)
horizontal.padding = 8 horizontal.padding = 8
horizontal.axis = "horizontal" horizontal.axis = "horizontal"
// 绑定四个零参数按钮回调;最终把两视图与控制区放在普通 Column/Row 中。
actions = Actions(vertical, horizontal) actions = Actions(vertical, horizontal)
down = Button("向下滚动") down = Button("向下滚动")
down.onClick(actions.down) down.onClick(actions.down)
+8
View File
@@ -1,10 +1,16 @@
// 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。
// 滚动只改变投影/裁剪,不复制业务计数;被裁剪内容不能通过原始矩形接受点击。
// 保留一个源 paint 委托,检验源命令与原生命令应用相同滚动裁剪。
class Painted: RectBlock { class Painted: RectBlock {
// 使用当前 geometry 交给原生基类;外层滚动变换/clip 由展示流程统一组合。
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList { function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
return RectBlock.paint(self,mount,geometry) return RectBlock.paint(self,mount,geometry)
} }
} }
// 三个按钮复用同一个 Action,任意可见按钮点击都递增同一 count。
class Actions { class Actions {
count: Object count: Object
// 签名接受 Event,业务逻辑只需修改计数,不直接处理滚动位置。
function click(self: Actions, event: Event): Unit { self.count.increment() } function click(self: Actions, event: Event): Unit { self.count.increment() }
} }
count = ui.state(0) count = ui.state(0)
@@ -15,12 +21,14 @@ last = Button("底部按钮")
firstConnection = first.on("click",actions.click) firstConnection = first.on("click",actions.click)
middleConnection = middle.on("click",actions.click) middleConnection = middle.on("click",actions.click)
lastConnection = last.on("click",actions.click) lastConnection = last.on("click",actions.click)
// 高内容夹有三个按钮与矩形,使固定320x180 scroller产生可观察的横纵滚动范围。
body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last) body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last)
scroller = ScrollBlock(320,180,body) scroller = ScrollBlock(320,180,body)
root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller) root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller)
page = Page() page = Page()
page.title = "滚动示例" page.title = "滚动示例"
page.render(root) page.render(root)
// 另一窗口复用 root/scroller;它们的第二个 Mount 偏移与主窗口第一个 Mount 无关。
other = Window() other = Window()
other.setTitle("Scroll Secondary") other.setTitle("Scroll Secondary")
second = Page() second = Page()
+8
View File
@@ -1,23 +1,31 @@
// 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。
// 圆外部分可落到父容器监听器;按下与释放须经相同合法目标复核。
// 扩展矩形的绘制、命中和事件方法,用同一个 hits 状态观察交互结果。
class CircleTarget: RectBlock { class CircleTarget: RectBlock {
hits: Object hits: Object
// point 已转换到该出现位置局部坐标;使用圆心(40,40)、半径40,而不是整个 160x80 外框。
function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool { function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool {
return point.inCircle(40, 40, 40) return point.inCircle(40, 40, 40)
} }
// 把原生命令宽度缩为 80、圆角半径设为 40,得到与 hitTest 对应的圆形区域。
function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry) drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).width = 80 drawing.commands.at(0).width = 80
drawing.commands.at(0).radius = 40 drawing.commands.at(0).radius = 40
return drawing return drawing
} }
// 圆内点击递增自身计数并停止向祖先传播;父容器不会为同一次点击再递增。
function onEvent(self: CircleTarget, event: Event): Unit { function onEvent(self: CircleTarget, event: Event): Unit {
self.hits.increment() self.hits.increment()
event.stopPropagation() event.stopPropagation()
} }
} }
hits = ui.state(0) hits = ui.state(0)
// 父监听器单独计数;它和圆形目标显示在同页,便于比较穿过透明区的输入。
background = ui.state(0) background = ui.state(0)
circle = CircleTarget(160, 80, 0, "#237A45", hits) circle = CircleTarget(160, 80, 0, "#237A45", hits)
root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value")) root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value"))
// 订阅在父 Column 上,圆外有效区域可回退到父目标;源命中只读检查不执行这些业务回调。
connection = root.on("click", background.increment) connection = root.on("click", background.increment)
page = Page() page = Page()
page.render(root) page.render(root)
+10
View File
@@ -1,25 +1,35 @@
// 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
// 保存宽度与左偏移;两字段只在事件回调中修改。
class Metrics { class Metrics {
width: Number width: Number
left: Number left: Number
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
function expand(self: Metrics, event: Event): Unit { function expand(self: Metrics, event: Event): Unit {
self.width = 260 self.width = 260
self.left = 68 self.left = 68
} }
} }
// 复用原生矩形载荷,仅覆盖尺寸策略;同一 rect 放在不同父容器可得到不同测量。
class MeasuredRect: RectBlock { class MeasuredRect: RectBlock {
// 首次测量前结构 Mount 链接已建立,因此可从 parent.block 读取当前出现位置的父策略。
// 此阶段还不能查询最终 bounds,返回值使用 MeasureResult 名义类型。
function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult {
parent = mount.parent() parent = mount.parent()
return MeasureResult(parent.block.metrics.width, 60) return MeasureResult(parent.block.metrics.width, 60)
} }
} }
// 在原生排列结果上改第一个子槽 x,其他子项仍用 Column 的堆叠规则。
class OffsetColumn: Column { class OffsetColumn: Column {
metrics: Metrics metrics: Metrics
// Arrangement 里的 x 是当前排列使用的坐标;读取 metrics.left 成为排列依赖。
function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement { function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement {
positions = Column.arrange(self, mount, geometry) positions = Column.arrange(self, mount, geometry)
positions.children.at(0).x = self.metrics.left positions.children.at(0).x = self.metrics.left
return positions return positions
} }
} }
// 将原生参数与源字段传给具体子类,button 事件更新模型,Page.render 建立完整展示关系。
metrics = Metrics(200, 44) metrics = Metrics(200, 44)
rect = MeasuredRect(80, 20, 0, "#526AC7") rect = MeasuredRect(80, 20, 0, "#526AC7")
button = Button("更新布局") button = Button("更新布局")
+11
View File
@@ -1,15 +1,22 @@
// 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。
// 同一 rect 出现两次,两份命令随同一 tint 更新;业务身份共享,几何按 Mount 分开。
// 原生矩形测量不变,仅将绘制颜色切换为源字段 tint。
class TintRect: RectBlock { class TintRect: RectBlock {
tint: String tint: String
// 从原生基类取得当前出现位置的矩形命令,再修改本次返回列表中的颜色并交回。
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList { function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry) drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint drawing.commands.at(0).color = self.tint
return drawing return drawing
} }
} }
// 在原生文字前增加圆角底色,背景大小使用实际分配 geometry 而不是固定猜测值。
class Badge: TextBlock { class Badge: TextBlock {
background: String background: String
// 先完整填写 roundedRect 命令,再取原生文字命令改成白色;列表顺序决定先画背景后画文字。
function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList { function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
// 命令 kind 决定宿主解码所需字段,坐标/宽高来自本次 Mount,radius/color 由业务策略提供。
fill.kind = "roundedRect" fill.kind = "roundedRect"
fill.x = geometry.x fill.x = geometry.x
fill.y = geometry.y fill.y = geometry.y
@@ -17,17 +24,21 @@ class Badge: TextBlock {
fill.height = geometry.height fill.height = geometry.height
fill.radius = 6 fill.radius = 6
fill.color = self.background fill.color = self.background
// 复用原生文字塑形和换行所需参数,避免手工文字命令漏掉边界。
text = TextBlock.paint(self, mount, geometry).commands.at(0) text = TextBlock.paint(self, mount, geometry).commands.at(0)
text.color = "#FFFFFF" text.color = "#FFFFFF"
return DisplayList(ObjectList(fill, text)) return DisplayList(ObjectList(fill, text))
} }
} }
// 保存目标 TintRect,onEvent 是托管事件目标方法覆盖,不额外创建另一份状态。
class RecolorButton: Button { class RecolorButton: Button {
target: TintRect target: TintRect
// 事件阶段可修改已有业务字段;依赖跟踪检测 tint 变化后重绘两个出现位置。
function onEvent(self: RecolorButton, event: Event): Unit { function onEvent(self: RecolorButton, event: Event): Unit {
self.target.tint = "#526AC7" self.target.tint = "#526AC7"
} }
} }
// 两次使用 rect 是同一对象,Badge 与按钮独立;根容器只组合它们,不复制业务数据。
rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A") rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A")
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45") badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
button = RecolorButton("通过对象方法改变绘制颜色", rect) button = RecolorButton("通过对象方法改变绘制颜色", rect)
+20 -2
View File
@@ -1,14 +1,19 @@
// Student grades demo. Fictional records; in-memory only. // 学生成绩中心为纯内存演示,记录均虚构,不连接学校数据库。
// Paste the entire file into Tessera Console, then press Enter. // 按钮操作 GradeCenter/Student 原对象;成绩行与摘要用 textOf 绑定,同一学生在全部/复核视图中复用业务身份。
// 整个文件作为一次完整源码输入提交;状态只存在本次 VM,会话重置后不保留。
// 为 Column 增加期望宽度字段,使手工表格各列对齐,而非让文字自然宽度决定列边界。
class GradeCell: Column { class GradeCell: Column {
width: Number width: Number
// 先保留 Column 的自然高度,再把新测量结果宽度设为字段值,最终仍由父约束协商。
function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult {
size = Column.measure(self, mount, constraints) size = Column.measure(self, mount, constraints)
size.width = self.width size.width = self.width
return size return size
} }
} }
// 背景绘制由源覆盖,子项仍按普通 Column 排列与绘制。
class GradePanel: Column { class GradePanel: Column {
// 按本次 geometry 生成白色圆角底板;不重绘或复制子项命令,后代由统一遍历处理。
function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList { function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
fill.kind = "roundedRect" fill.kind = "roundedRect"
@@ -21,6 +26,7 @@ class GradePanel: Column {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// 姓名/学号和各科成绩/状态是业务字段;数值修改受 Number 声明检查。
class Student { class Student {
name: String name: String
number: String number: String
@@ -29,9 +35,11 @@ class Student {
english: Number english: Number
status: String status: String
} }
// 用统一无 padding/gap 的单格容器包装任意 Block,末尾 width 是源字段构造参数。
function gradeCell(content: Block, width: Number): Block { function gradeCell(content: Block, width: Number): Block {
return GradeCell(0, 0, content, width) return GradeCell(0, 0, content, width)
} }
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与固定列宽保持表格结构。
function gradeRow(student: Student): Block { function gradeRow(student: Student): Block {
return Column(0, 12, return Column(0, 12,
Row(0, 0, Row(0, 0,
@@ -43,6 +51,7 @@ function gradeRow(student: Student): Block {
gradeCell(ui.textOf(student, "status", 1, "#526C83"), 160)), gradeCell(ui.textOf(student, "status", 1, "#526C83"), 160)),
RectBlock(730, 1, 0, "#EDF0F3")) RectBlock(730, 1, 0, "#EDF0F3"))
} }
// 保存全部/复核两棵现成子树与可替换 table,同时持有要复核的真实 Student。
class GradeCenter { class GradeCenter {
table: Column table: Column
all: Column all: Column
@@ -50,20 +59,24 @@ class GradeCenter {
student: Student student: Student
pending: Number pending: Number
message: String message: String
// 替换稳定槽0的内容为全部行,复用已有学生,不重新请求/重建数据。
function showAll(self: GradeCenter, event: Event): Unit { function showAll(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.all) self.table.replace(0, self.all)
self.message = "正在查看全部 6 位学生" self.message = "正在查看全部 6 位学生"
} }
// 切换到预先构造的复核行;这是特定示例子树,不是动态过滤算法。
function showAttention(self: GradeCenter, event: Event): Unit { function showAttention(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.attention) self.table.replace(0, self.attention)
self.message = "林沐的数学成绩有一条复核记录" self.message = "林沐的数学成绩有一条复核记录"
} }
// 写预设数学成绩68及状态,清待复核数;相关绑定在两个视图都读同一 studentLin。
function review(self: GradeCenter, event: Event): Unit { function review(self: GradeCenter, event: Event): Unit {
self.student.math = 68 self.student.math = 68
self.student.status = "已复核" self.student.status = "已复核"
self.pending = 0 self.pending = 0
self.message = "已录入复核结果:林沐 · 数学 58 → 68" self.message = "已录入复核结果:林沐 · 数学 58 → 68"
} }
// 恢复初始学生值/计数并显示全部列表,便于重复观察同一操作。
function reset(self: GradeCenter, event: Event): Unit { function reset(self: GradeCenter, event: Event): Unit {
self.student.math = 58 self.student.math = 58
self.student.status = "待复核" self.student.status = "待复核"
@@ -72,6 +85,7 @@ class GradeCenter {
self.message = "已恢复示例数据" self.message = "已恢复示例数据"
} }
} }
// 下面六条数据均本地构造;allGradeRows 与 attentionGradeRows 对 studentLin 建立不同视觉行但共享其业务值。
studentLin = Student("林沐", "2026001", 86, 58, 91, "待复核") studentLin = Student("林沐", "2026001", 86, 58, 91, "待复核")
studentChen = Student("陈知夏", "2026002", 94, 96, 93, "已确认") studentChen = Student("陈知夏", "2026002", 94, 96, 93, "已确认")
studentXu = Student("许星河", "2026003", 82, 88, 85, "已确认") studentXu = Student("许星河", "2026003", 82, 88, 85, "已确认")
@@ -81,15 +95,18 @@ studentLu = Student("陆景行", "2026006", 79, 84, 87, "已确认")
allGradeRows = Column(0, 14, gradeRow(studentLin), gradeRow(studentChen), gradeRow(studentXu), gradeRow(studentZhou), gradeRow(studentSu), gradeRow(studentLu)) allGradeRows = Column(0, 14, gradeRow(studentLin), gradeRow(studentChen), gradeRow(studentXu), gradeRow(studentZhou), gradeRow(studentSu), gradeRow(studentLu))
attentionGradeRows = Column(0, 14, gradeRow(studentLin)) attentionGradeRows = Column(0, 14, gradeRow(studentLin))
gradeTable = Column(0, 0, allGradeRows) gradeTable = Column(0, 0, allGradeRows)
// table 最初含一个稳定子槽,后续 replace(0,...) 正是切换这个槽。
gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生") gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生")
showAllGrades = Button("全部学生") showAllGrades = Button("全部学生")
showAttentionGrades = Button("查看复核记录") showAttentionGrades = Button("查看复核记录")
reviewGrade = Button("录入复核成绩") reviewGrade = Button("录入复核成绩")
resetGrades = Button("恢复示例数据") resetGrades = Button("恢复示例数据")
// 四个回调均接受 Event,绑定到同一 GradeCenter 接收者。
showAllGrades.on("click", gradeCenter.showAll) showAllGrades.on("click", gradeCenter.showAll)
showAttentionGrades.on("click", gradeCenter.showAttention) showAttentionGrades.on("click", gradeCenter.showAttention)
reviewGrade.on("click", gradeCenter.review) reviewGrade.on("click", gradeCenter.review)
resetGrades.on("click", gradeCenter.reset) resetGrades.on("click", gradeCenter.reset)
// 表头复用与数据行完全相同的列宽,摘要仅 pending 响应式,6/3是固定示例统计。
gradeHeader = Row(0, 0, gradeHeader = Row(0, 0,
gradeCell(TextBlock("学生", 1, "#7A8390"), 150), gradeCell(TextBlock("学生", 1, "#7A8390"), 150),
gradeCell(TextBlock("学号", 1, "#7A8390"), 120), gradeCell(TextBlock("学号", 1, "#7A8390"), 120),
@@ -101,6 +118,7 @@ gradeSummary = Row(0, 40,
Column(0, 6, TextBlock("学生人数", 1, "#7A8390"), TextBlock("6", 2)), Column(0, 6, TextBlock("学生人数", 1, "#7A8390"), TextBlock("6", 2)),
Column(0, 6, TextBlock("考试科目", 1, "#7A8390"), TextBlock("3", 2)), Column(0, 6, TextBlock("考试科目", 1, "#7A8390"), TextBlock("3", 2)),
Column(0, 6, TextBlock("待复核", 1, "#7A8390"), ui.textOf(gradeCenter, "pending", 2))) Column(0, 6, TextBlock("待复核", 1, "#7A8390"), ui.textOf(gradeCenter, "pending", 2)))
// 最终一次 render 组合摘要、控制区和表格面板;message 绑定显示最近业务操作结果。
gradesPage = Page() gradesPage = Page()
gradesPage.title = "学生成绩管理中心" gradesPage.title = "学生成绩管理中心"
gradesPage.render(Column(32, 20, gradesPage.render(Column(32, 20,
+10
View File
@@ -1,16 +1,23 @@
// 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。
// 当前侧栏提供选择/关闭,文案中的拖放场景也可经版本化事件 API 测试;不要把它理解成已实现的侧栏拖动手势。
// 继承 Page 后观察 moveToWindow/moveTab 的真实方法调用,保留原生迁移/重排行为。
class TrackedPage: Page { class TrackedPage: Page {
calls: Object calls: Object
// 先递增 calls,再显式调用基类执行跨窗迁移;Page 身份保留,旧 Mount 上下文会替换。
function moveToWindow(self: TrackedPage, window: Window): Page { function moveToWindow(self: TrackedPage, window: Window): Page {
self.calls.increment() self.calls.increment()
return Page.moveToWindow(self, window) return Page.moveToWindow(self, window)
} }
// position 是所属窗口内最终零基位置,重排不等于创建新的业务 Page。
function moveTab(self: TrackedPage, position: Number): Page { function moveTab(self: TrackedPage, position: Number): Page {
self.calls.increment() self.calls.increment()
return Page.moveTab(self, position) return Page.moveTab(self, position)
} }
} }
// 保存容器业务对象,按钮插入只改变其子槽顺序,已有 child 的槽与 Mount 尽量保留。
class Actions { class Actions {
list: Column list: Column
// 在索引 1 前插入新 TextBlock,返回的稳定槽可供 remove 等后续操作使用。
function insert(self: Actions, event: Event): Unit { function insert(self: Actions, event: Event): Unit {
self.list.insert(1, TextBlock("插入的新对象")) self.list.insert(1, TextBlock("插入的新对象"))
} }
@@ -20,6 +27,7 @@ list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"
actions = Actions(list) actions = Actions(list)
button = Button("在位置 1 插入 Block") button = Button("在位置 1 插入 Block")
connection = button.on("click", actions.insert) connection = button.on("click", actions.insert)
// A/C/目标页共享同一根及 list;各页激活时使用独立展示上下文,业务插入结果共享。
shared = Column(24, 16, TextBlock("拖动 Tab 重排、移入另一个窗口,或拖出独立窗口"), TextBlock("Page 方法调用次数"), ui.textOf(calls,"value"), button, list) shared = Column(24, 16, TextBlock("拖动 Tab 重排、移入另一个窗口,或拖出独立窗口"), TextBlock("Page 方法调用次数"), ui.textOf(calls,"value"), button, list)
first = TrackedPage(calls) first = TrackedPage(calls)
first.title = "A · 共享内容" first.title = "A · 共享内容"
@@ -30,6 +38,7 @@ second.render(Column(24, 16, TextBlock("这是 B,Page 身份随 Tab 移动保
third = TrackedPage(calls) third = TrackedPage(calls)
third.title = "C · 同一对象" third.title = "C · 同一对象"
third.render(shared) third.render(shared)
// 建立目标窗口并指定桌面位置,MoveTo 使用物理桌面坐标且可能受窗口管理器调整。
other = Window() other = Window()
other.resize(720,520) other.resize(720,520)
other.setTitle("Tab Target") other.setTitle("Tab Target")
@@ -38,6 +47,7 @@ target = TrackedPage(calls)
target.title = "目标窗口" target.title = "目标窗口"
target.render(shared) target.render(shared)
target.open(other) target.open(other)
// 恢复源窗口首个页面,最后排出 resize/title/move/focus,便于手动或 API 验证。
first.activate() first.activate()
ui.window().resize(800,600) ui.window().resize(800,600)
ui.window().setTitle("Tab Source") ui.window().setTitle("Tab Source")
+7 -1
View File
@@ -1,4 +1,7 @@
// Code and tab chrome operate the same Page objects. // 代码与原生侧栏都操作真实 Page 对象;activate/close/open 改变展示关系,业务对象可继续被全局引用保留。
// 三个页面共存于同一 VM,最后 first.activate 选择初始展示页。
// 当前宿主通过侧栏管理页面;Page 的代码接口保持同一业务身份。
// 创建持有计数器与按钮的页面;切到其他页不等于销毁 count。
first = Page() first = Page()
first.title = "共享计数" first.title = "共享计数"
count = ui.state(0) count = ui.state(0)
@@ -6,12 +9,15 @@ increment = Button("加一")
connection = increment.on("click", count.increment) connection = increment.on("click", count.increment)
first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment)) first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment))
// 另一页演示 Page 生命周期与快捷键;界面提示是示例文案,实际导航位于当前宿主侧栏。
second = Page() second = Page()
second.title = "另一个页面" second.title = "另一个页面"
second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开"))) second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开")))
// 独立内容页面仍用同一个运行时的原生类型和资源注册表。
third = Page() third = Page()
third.title = "窗口与布局" third.title = "窗口与布局"
third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45"))) third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45")))
// 重新激活第一页面,原生侧栏和源查询可观察相同 active 状态。
first.activate() first.activate()
ui.window().setTitle("Endor Pages") ui.window().setTitle("Endor Pages")
+8
View File
@@ -1,3 +1,6 @@
// 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。
// onMount 作为业务阶段收集结果;实际布局/绘制计算仍受只读规则约束。
// 把协议探测结果保存到普通业务字段,textOf 将其中一部分显示出来。
class VisualResults { class VisualResults {
width: Number width: Number
height: Number height: Number
@@ -5,8 +8,10 @@ class VisualResults {
childY: Number childY: Number
color: String color: String
} }
// 继承原生矩形,在自身挂载后用该 Mount 显式调用 measure/paint。
class ProbeRect: RectBlock { class ProbeRect: RectBlock {
results: VisualResults results: VisualResults
// 有限最大宽 120 约束自然宽 200;另用自定义 Bounds 查询绘制颜色,把返回结果写进共享 results。
function onMount(self: ProbeRect, mount: Mount): Unit { function onMount(self: ProbeRect, mount: Mount): Unit {
measured = self.measure(mount, Constraints(0, 0, 120, 80)) measured = self.measure(mount, Constraints(0, 0, 120, 80))
self.results.width = measured.width self.results.width = measured.width
@@ -15,14 +20,17 @@ class ProbeRect: RectBlock {
self.results.color = drawing.commands.at(0).color self.results.color = drawing.commands.at(0).color
} }
} }
// 在挂载回调中探测原生排列,复用当前结构的稳定子槽。
class ProbeColumn: Column { class ProbeColumn: Column {
results: VisualResults results: VisualResults
// 给定绝对起点(20,30)及容器范围,原生 padding=24 使首子项位于(44,54)。
function onMount(self: ProbeColumn, mount: Mount): Unit { function onMount(self: ProbeColumn, mount: Mount): Unit {
positions = self.arrange(mount, Bounds(20, 30, 500, 300)) positions = self.arrange(mount, Bounds(20, 30, 500, 300))
self.results.childX = positions.children.at(0).x self.results.childX = positions.children.at(0).x
self.results.childY = positions.children.at(0).y self.results.childY = positions.children.at(0).y
} }
} }
// 先创建结果容器,再将它作为源子类字段共享;root 的最后一个参数是该字段,不是视觉子项。
results = VisualResults(0, 0, 0, 0, "等待挂载") results = VisualResults(0, 0, 0, 0, "等待挂载")
rect = ProbeRect(200, 60, 12, "#DDEEFF", results) 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) root = ProbeColumn(24, 16, TextBlock("对象的原生视觉协议", 2), rect, TextBlock("约束测量宽度"), ui.textOf(results, "width"), TextBlock("绘制颜色"), ui.textOf(results, "color"), results)
+8
View File
@@ -1,17 +1,25 @@
// 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。
// 实际可用视口再返回布局约束,原生窗口尺寸不是业务对象上凭空写入的一份几何副本。
// 保存目标 Window,两个按钮对同一资源请求不同尺寸。
class WindowActions { class WindowActions {
window: Window window: Window
// 请求 900x600 逻辑尺寸,最终大小仍由窗口管理器决定。
function compact(self: WindowActions, event: Event): Unit { function compact(self: WindowActions, event: Event): Unit {
self.window.resize(900, 600) self.window.resize(900, 600)
} }
// 请求 1120x720;同次输入多次 resize 可由命令队列合并到最后值。
function wide(self: WindowActions, event: Event): Unit { function wide(self: WindowActions, event: Event): Unit {
self.window.resize(1120, 720) self.window.resize(1120, 720)
} }
} }
// 原生矩形的测量覆盖展示父约束如何随窗口变化。
class WindowBand: RectBlock { class WindowBand: RectBlock {
// 取当前出现位置 maxWidth 作为期望宽度,固定高 32;不读取其他窗口的尺寸。
function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(constraints.maxWidth, 32) return MeasureResult(constraints.maxWidth, 32)
} }
} }
// ui.window 返回当前主窗口托管资源;page.open 指定同一资源,后面的 title/resize 为原生命令。
window = ui.window() window = ui.window()
actions = WindowActions(window) actions = WindowActions(window)
band = WindowBand(80, 32, 0, "#237A45") band = WindowBand(80, 32, 0, "#237A45")
+8 -1
View File
@@ -1,18 +1,25 @@
// Both native windows use one Aster VM and one shared business object. // 两个原生窗口共用一个 Aster VM 和 count 业务对象,各自拥有独立 Page 与 Mount。
// 任一窗口点击都修改同一计数,两个 textOf 显示随之更新。关闭窗口解除 Page 归属,持有的业务对象仍可重开。
// 跨窗共享的是业务对象,不是某个窗口中的具体 Mount。
// 唯一计数来源;两个按钮直接使用同一零参数原生 increment 方法。
count = ui.state(0) count = ui.state(0)
leftButton = Button("共享计数 +1") leftButton = Button("共享计数 +1")
leftConnection = leftButton.on("click",count.increment) leftConnection = leftButton.on("click",count.increment)
// 先在主窗口建立页面,render 默认选择该页并挂载它的内容。
left = Page() left = Page()
left.title = "主窗口页面" left.title = "主窗口页面"
left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton)) left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton))
// Window() 分配同 VM 中的独立窗口资源,不创建第二个运行时。
other = Window() other = Window()
rightButton = Button("另一个窗口 +1") rightButton = Button("另一个窗口 +1")
rightConnection = rightButton.on("click",count.increment) rightConnection = rightButton.on("click",count.increment)
right = Page() right = Page()
right.title = "独立窗口页面" right.title = "独立窗口页面"
right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton)) right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton))
// 把页面放到 other,上下文更换将重建出现位置,业务 count 仍不变。
right.open(other) right.open(other)
// 窗口命令针对各自资源编号排队;成功输入后再交 GUI,另一窗口设置自己的尺寸。
ui.window().setTitle("Endor Main") ui.window().setTitle("Endor Main")
other.setTitle("Endor Shared") other.setTitle("Endor Shared")
other.resize(960,640) other.resize(960,640)
+13 -2
View File
@@ -1,6 +1,8 @@
// Paste the complete file into Console in Script mode, then Run. // 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。
// All numbers, progress values and activity entries below are demo data. // 辅助函数返回视觉子树,最后统一 Page.render;它们是普通 Endor 函数,不是宿主特殊组件语法。
// 将整个文件作为完整输入提交;这里没有实时指标提供方,全部数据由下面的常量生成。
// 把标签、大数值、说明和色条纵向组合,参数控制文字与强调颜色。
function metric(label: Text, value: Text, note: Text, color: Text): View { function metric(label: Text, value: Text, note: Text, color: Text): View {
return ui.column(0, 6, return ui.column(0, 6,
ui.text(label, 1, "#637082"), ui.text(label, 1, "#637082"),
@@ -10,11 +12,14 @@ function metric(label: Text, value: Text, note: Text, color: Text): View {
) )
} }
// 生成项目标题和状态,再用两个相邻矩形表示已完成/剩余长度;filled/remaining 是逻辑像素。
function project(name: Text, state: Text, detail: Text, filled: Number, remaining: Number, color: Text): View { function project(name: Text, state: Text, detail: Text, filled: Number, remaining: Number, color: Text): View {
// 项目名和状态横排,状态颜色与进度前景相同。
let caption = ui.row(0, 12, let caption = ui.row(0, 12,
ui.text(name, 1, "#243047"), ui.text(name, 1, "#243047"),
ui.text(state, 1, color) ui.text(state, 1, color)
) )
// 两块矩形间 gap=0 合成连续条,条宽由调用方传入的演示数值决定。
let progress = ui.row(0, 0, let progress = ui.row(0, 0,
ui.rect(filled, 6, 3, color), ui.rect(filled, 6, 3, color),
ui.rect(remaining, 6, 3, "#E5EAF2") ui.rect(remaining, 6, 3, "#E5EAF2")
@@ -26,6 +31,7 @@ function project(name: Text, state: Text, detail: Text, filled: Number, remainin
) )
} }
// 组合小圆点、时间戳、标题与说明;spacer 仅作视觉对齐,不代表真实事件时间线。
function activity(time: Text, title: Text, detail: Text, color: Text): View { function activity(time: Text, title: Text, detail: Text, color: Text): View {
let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color)) let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color))
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0")) let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
@@ -36,6 +42,7 @@ function activity(time: Text, title: Text, detail: Text, color: Text): View {
) )
} }
// 头部三个文本使用字号/颜色层级,后续 statistics 复用 metric 返回三组指标。
let heading = ui.column(0, 6, let heading = ui.column(0, 6,
ui.text("ENDOR STUDIO / WORKSPACE", 1, "#5263D6"), ui.text("ENDOR STUDIO / WORKSPACE", 1, "#5263D6"),
ui.text("项目工作台", 2, "#243047"), ui.text("项目工作台", 2, "#243047"),
@@ -48,6 +55,7 @@ let statistics = ui.row(0, 20,
metric("页面草稿", "08", "等待下一次灵感", "#D9A34A") metric("页面草稿", "08", "等待下一次灵感", "#D9A34A")
) )
// 纵向复用 project,示例名称与比例是常量,不做进度计算或持久化。
let projects = ui.column(0, 14, let projects = ui.column(0, 14,
ui.text("项目进度", 1, "#243047"), ui.text("项目进度", 1, "#243047"),
project("Aster", "进行中", "语言解析 / 编译 / 执行", 240, 60, "#6574E8"), project("Aster", "进行中", "语言解析 / 编译 / 执行", 240, 60, "#6574E8"),
@@ -55,6 +63,7 @@ let projects = ui.column(0, 14,
project("设计文档", "草稿", "Block / Page / 对象模型", 108, 192, "#D9A34A") project("设计文档", "草稿", "Block / Page / 对象模型", 108, 192, "#D9A34A")
) )
// 三条 activity 组成静态活动区,再与项目区横排成 body。
let timeline = ui.column(0, 14, let timeline = ui.column(0, 14,
ui.text("最近活动", 1, "#243047"), ui.text("最近活动", 1, "#243047"),
activity("10:24", "更新字体", "中英文混排预览", "#6574E8"), activity("10:24", "更新字体", "中英文混排预览", "#6574E8"),
@@ -64,6 +73,7 @@ let timeline = ui.column(0, 14,
let body = ui.row(0, 20, projects, timeline) let body = ui.row(0, 20, projects, timeline)
// 底部提示以细色块和文字组合,明确说明页面还未接业务交互。
let note = ui.row(0, 12, let note = ui.row(0, 12,
ui.rect(4, 46, 2, "#6574E8"), ui.rect(4, 46, 2, "#6574E8"),
ui.column(0, 6, ui.column(0, 6,
@@ -72,6 +82,7 @@ let note = ui.row(0, 12,
) )
) )
// 最终根统一设置 24px padding 与20px间距;创建辅助子树本身不会提前发布页面。
let page = ui.page() let page = ui.page()
page.render(ui.column(24, 20, page.render(ui.column(24, 20,
heading, heading,
+13 -1
View File
@@ -1,8 +1,12 @@
// A normal Endor UI tree rendered on a transformed plane in the 3D world. // 世界空间 UI:普通 Endor 树先在独立 UI 分辨率中布局/绘制,再作为 Panel3D 平面的纹理参与三维深度。
// 面板内还有 CanvasViewport,输入需经外层射线、面板 UV 和内层二维相机逐层转换。
// 面板内容仍是完整 Endor UI,可共享对象、绑定、源视觉协议与嵌套视口。
// 分别持有外层三维视口、面板变换和面板内二维画布,避免一次动作同时驱动多层相机。
class WorldActions { class WorldActions {
view: Viewport3D view: Viewport3D
panel: Panel3D panel: Panel3D
canvas: CanvasViewport canvas: CanvasViewport
// 重置三维相机位置与观察目标,不改变面板或内层画布业务状态。
function reset(self: WorldActions): Unit { function reset(self: WorldActions): Unit {
self.view.camera.x = 0 self.view.camera.x = 0
self.view.camera.y = 0.5 self.view.camera.y = 0.5
@@ -11,19 +15,24 @@ class WorldActions {
self.view.camera.targetY = 0 self.view.camera.targetY = 0
self.view.camera.targetZ = 0 self.view.camera.targetZ = 0
} }
// 只旋转 Panel3D 平面,面板内 UI 树仍用自己的逻辑宽高。
function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) } function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) }
// 找到画布当前 Mount,按该出现位置的有限尺寸 fitView,而非用外层三维视口大小。
function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() } function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() }
} }
count = ui.state(0) count = ui.state(0)
increment = Button("点击:世界里的真实按钮") increment = Button("点击:世界里的真实按钮")
increment.onClick(count.increment) increment.onClick(count.increment)
// 二维场景卡片使用带符号世界坐标;canvas 再提供有限可见区域和独立 camera。
card = Column(10, 6, TextBlock("CanvasViewport", 1), RectBlock(180, 26, 6, "#9CB8A8")) card = Column(10, 6, TextBlock("CanvasViewport", 1), RectBlock(180, 26, 6, "#9CB8A8"))
card.boxWidth = 240 card.boxWidth = 240
card.x = 10 card.x = 10
card.y = 4 card.y = 4
canvas = CanvasViewport(280, 80, Scene2D(card)) canvas = CanvasViewport(280, 80, Scene2D(card))
// 普通 Column、文字、按钮和二维画布组成 UI 树,按钮仍修改同一个 count。
ui_root = Column(8, 8, TextBlock("世界空间 UI", 2), ui.textOf(count, "value", 2), increment, canvas) ui_root = Column(8, 8, TextBlock("世界空间 UI", 2), ui.textOf(count, "value", 2), increment, canvas)
ui_root.crossAlign = "stretch" ui_root.crossAlign = "stretch"
// 4x3 是三维世界尺寸,320x240 是 UI 布局/纹理分辨率;两者独立控制显示比例与清晰度。
panel = Panel3D(4, 3, ui_root) panel = Panel3D(4, 3, ui_root)
panel.uiWidth = 320 panel.uiWidth = 320
panel.uiHeight = 240 panel.uiHeight = 240
@@ -34,8 +43,10 @@ cube.x = 2
cube.rotationY = 25 cube.rotationY = 25
floor = Box3D(10, 0.08, 5, "#D8DEE6") floor = Box3D(10, 0.08, 5, "#D8DEE6")
floor.y = -1.8 floor.y = -1.8
// 面板与普通网格/地面共处一个深度场景,实体可遮挡面板,也阻挡被遮住的 UI 命中。
scene = Scene3D(panel, cube, floor, DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9)) scene = Scene3D(panel, cube, floor, DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9))
scene.ambient = 0.35 scene.ambient = 0.35
// 外层 Viewport3D 作为二维布局里的 flex 子项;相机控制外部观察,而非 UI 内部滚动。
view = Viewport3D(960, 520, scene) view = Viewport3D(960, 520, scene)
view.flex = 1 view.flex = 1
view.camera.y = 0.5 view.camera.y = 0.5
@@ -47,6 +58,7 @@ turn = Button("转动 UI 面板")
turn.onClick(actions.turn) turn.onClick(actions.turn)
fit = Button("适配面板内的画布") fit = Button("适配面板内的画布")
fit.onClick(actions.fit) fit.onClick(actions.fit)
// 控制按钮保持在二维页面空间,外层三维内容作为一个区域随布局伸展。
body = Column(24, 16, TextBlock("UI → 3D 世界 → UI → Canvas", 2), view, Row(0, 12, reset, turn, fit), TextBlock("空白处左键拖动旋转 3D · 中键/空格平移 · 滚轮缩放\n面板里的按钮、文本、布局和画布都由同一份 Endor 对象驱动。")) body = Column(24, 16, TextBlock("UI → 3D 世界 → UI → Canvas", 2), view, Row(0, 12, reset, turn, fit), TextBlock("空白处左键拖动旋转 3D · 中键/空格平移 · 滚轮缩放\n面板里的按钮、文本、布局和画布都由同一份 Endor 对象驱动。"))
body.flex = 1 body.flex = 1
body.crossAlign = "stretch" body.crossAlign = "stretch"
+12
View File
@@ -1,18 +1,28 @@
// 多窗口共享长段落:同一文字对象在每个 Mount 按当前宽度独立换行,后继位置跟随实际高度。
// 源子类显式委托原生 measure/paint,保留系统字体塑形、组合字符和 emoji 的行为。
// 没有独立宽度副本;约束与换行结果属于出现位置,文本内容属于共享对象。
class Paragraph: TextBlock { class Paragraph: TextBlock {
// 将当前 Mount 与 Constraints 交给原生文字测量,宽度变化触发重测高度。
function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult {
return TextBlock.measure(self, mount, constraints) return TextBlock.measure(self, mount, constraints)
} }
// 使用本次 geometry 画文字,和原生测量采用相同换行区域。
function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList { function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList {
return TextBlock.paint(self, mount, geometry) return TextBlock.paint(self, mount, geometry)
} }
} }
// 保存主窗口、共享文字和原全文,四个按钮分别改窗口尺寸或改文字内容。
class Actions { class Actions {
window: Window window: Window
paragraph: TextBlock paragraph: TextBlock
full: String full: String
// 请求主窗口变窄;真正的 viewport 由原生尺寸回传后影响换行。
function narrow(self: Actions, event: Event): Unit { self.window.resize(800, 650) } function narrow(self: Actions, event: Event): Unit { self.window.resize(800, 650) }
// 请求主窗口变宽,文字高度随之减小,不改另一窗口尺寸。
function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) } function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) }
// 用短文本替换同一对象内容,两个窗口的文字都重新测量。
function short(self: Actions, event: Event): Unit { self.paragraph.setText("短文本:同一个对象、同一个 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) } 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 = "GUI 和代码操控同一套 Aster 内存对象。这个段落同时出现在两个窗口:对象内容相同,每个 Mount 根据自己的可用宽度换行。窗口变窄,段落高度增加,后面的对象向下移动;窗口变宽,段落高度减小,后面的对象向上移动。对象身份和已有 Mount 身份保持稳定。English words wrap at word boundaries; a_long_unbroken_identifier_also_wraps_when_it_cannot_fit_on_one_line. 中文、组合字符 é 与 emoji 👩‍💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
@@ -30,6 +40,7 @@ restoreConnection = restore.on("click", actions.restore)
page = Page() page = Page()
page.title = "约束下的文字换行" page.title = "约束下的文字换行"
page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail)) page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail))
// 新窗口有独立可用宽度,但 second.render 仍用同一 paragraph,所以两个 Mount 可有不同高度。
other = Window() other = Window()
other.resize(720, 500) other.resize(720, 500)
other.setTitle("同一个段落 · 另一组宽度") other.setTitle("同一个段落 · 另一组宽度")
@@ -38,6 +49,7 @@ second = Page()
second.title = "共享 Paragraph" second.title = "共享 Paragraph"
second.render(Column(24, 16, paragraph)) second.render(Column(24, 16, paragraph))
second.open(other) second.open(other)
// 恢复主页面活动状态并设置原生窗口尺寸/标题/桌面位置,focus 命令在成功输入后交宿主执行。
page.activate() page.activate()
ui.window().resize(800, 650) ui.window().resize(800, 650)
ui.window().setTitle("文字换行与相邻布局") ui.window().setTitle("文字换行与相邻布局")
+8 -1
View File
@@ -1,16 +1,21 @@
#!/bin/sh #!/bin/sh
# Native macOS build and app bundle. No Docker, mirror configuration, or tests. # macOS 应用打包入口:构建 release 可执行文件并组装最小 Tessera.app;脚本本身不运行测试或部署。
# 执行会产生构建产物,应在已确认的 macOS 构建环境中调用。
# 任一命令失败或使用未定义变量即退出,避免在失败编译后继续打包旧文件。
set -eu set -eu
# 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。 # 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。
if [ "$(uname -s)" != Darwin ]; then if [ "$(uname -s)" != Darwin ]; then
echo "Build this app bundle on macOS." >&2 echo "Build this app bundle on macOS." >&2
exit 1 exit 1
fi fi
# 以脚本所在目录的父目录作为仓库根;清空这一次 cd 的 CDPATH,防止用户 shell 配置改变目录解析。
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
# 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。 # 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。
cargo build --release --locked "$@" cargo build --release --locked "$@"
# 从 Cargo 包元数据读取首个 version 值,空值由 test 拒绝,不生成缺版本的 bundle。
version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml) version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml)
test -n "$version" test -n "$version"
# macOS bundle 约定:Contents/MacOS 保存入口二进制,Contents/Info.plist 保存系统识别信息。
bundle="$(pwd)/target/release/Tessera.app" bundle="$(pwd)/target/release/Tessera.app"
mkdir -p "$bundle/Contents/MacOS" mkdir -p "$bundle/Contents/MacOS"
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。 # 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
@@ -31,5 +36,7 @@ cat > "$bundle/Contents/Info.plist.new" <<PLIST
<key>NSHighResolutionCapable</key><true/> <key>NSHighResolutionCapable</key><true/>
</dict></plist> </dict></plist>
PLIST PLIST
# 元数据完整写入后再替换正式文件;这只是单文件替换,不表示整个 bundle 的构建具有事务回滚。
mv -f "$bundle/Contents/Info.plist.new" "$bundle/Contents/Info.plist" mv -f "$bundle/Contents/Info.plist.new" "$bundle/Contents/Info.plist"
# 标准输出返回最终 app 路径,便于构建调用方定位产物。
printf '%s\n' "$bundle" printf '%s\n' "$bundle"
+16 -8
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
// Documentation-only tool. Install pinned packages in a separate temporary directory; // 文档 UML 批量渲染入口:读取仓库内 .puml,在 rendered 中写入 SVG/PNG。
// UML_RENDER_TOOLS points there. No app dependencies or remote rendering service. // 固定版本的工具包安装到独立临时目录,由 UML_RENDER_TOOLS 指定;不会修改应用依赖。
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises'; import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -9,13 +9,14 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
const tools = process.env.UML_RENDER_TOOLS; 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'); if (!tools) throw new Error('Set UML_RENDER_TOOLS to the documentation tool directory; see docs/architecture/uml/README.md');
// 从工具目录的 package.json 创建专用 require,所有渲染依赖均从该目录解析,不依赖运行时 cwd。
const require = createRequire(path.join(path.resolve(tools), 'package.json')); const require = createRequire(path.join(path.resolve(tools), 'package.json'));
const { createCanvas } = require('@napi-rs/canvas'); const { createCanvas } = require('@napi-rs/canvas');
const { DOMImplementation, XMLSerializer, DOMParser } = require('@xmldom/xmldom'); const { DOMImplementation, XMLSerializer, DOMParser } = require('@xmldom/xmldom');
const sharp = require('sharp'); const sharp = require('sharp');
// PlantUML's official JS engine uses DOM construction and Canvas text metrics. // 官方 PlantUML JS 引擎依赖 DOM、Graphviz 和 Canvas 文本度量。
// Supply a real XML DOM and native Canvas metrics, not approximate text widths. // 在导入引擎前补齐全局环境,让 Node 进程可执行原本面向浏览器的布局过程。
globalThis.PLANTUML_VIZ = require('@viz-js/viz'); globalThis.PLANTUML_VIZ = require('@viz-js/viz');
globalThis.window = globalThis; globalThis.window = globalThis;
globalThis.XMLSerializer = XMLSerializer; globalThis.XMLSerializer = XMLSerializer;
@@ -23,15 +24,17 @@ globalThis.DOMParser = DOMParser;
const document = new DOMImplementation().createDocument(null, 'root', null); const document = new DOMImplementation().createDocument(null, 'root', null);
const element = document.createElement.bind(document); const element = document.createElement.bind(document);
const elementNS = document.createElementNS.bind(document); const elementNS = document.createElementNS.bind(document);
// 创建只用于文本度量的 1x1 Canvas。此对象不承载最终图片像素,字体实际尺寸由原生引擎返回。
function measurementCanvas() { function measurementCanvas() {
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。 // PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
const canvas = createCanvas(1, 1); const canvas = createCanvas(1, 1);
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
const measure = context.measureText.bind(context); const measure = context.measureText.bind(context);
// 包装测量结果,修正 PlantUML 用于行距的 ascent/descent;保留其他真实 metrics 字段。
context.measureText = text => { context.measureText = text => {
const metrics = measure(text); const metrics = measure(text);
// PlantUML uses this height for line spacing. Font-wide ascent/descent // 优先使用整套字体的上下界,而非当前字符串字形界;
// keeps adjacent class fields from colliding when glyphs lack descenders. // 否则不带下伸部的文字可能使相邻类字段的行距过小而重叠。
return { return {
...metrics, ...metrics,
width: metrics.width, width: metrics.width,
@@ -41,11 +44,13 @@ function measurementCanvas() {
}; };
return canvas; return canvas;
} }
// 普通标签交给 XML DOM,只有 canvas 替换为可实际 measureText 的原生对象。
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag); document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
document.createElementNS = (namespace, tag) => { document.createElementNS = (namespace, tag) => {
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。 // 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
const node = elementNS(namespace, tag); const node = elementNS(namespace, tag);
node.style = {}; node.style = {};
// 无浏览器布局树时只模拟当前文字节点的包围盒:字体来自 SVG 属性,y 从基线向上偏移。
node.getBBox = () => { node.getBBox = () => {
const context = measurementCanvas().getContext('2d'); const context = measurementCanvas().getContext('2d');
context.font = `${node.getAttribute('font-size') || 14}px ${node.getAttribute('font-family') || 'Arial'}`; context.font = `${node.getAttribute('font-size') || 14}px ${node.getAttribute('font-family') || 'Arial'}`;
@@ -63,20 +68,23 @@ document.body = element('body');
document.documentElement.appendChild(document.body); document.documentElement.appendChild(document.body);
globalThis.document = document; globalThis.document = document;
const { renderToString } = await import(pathToFileURL(require.resolve('@plantuml/core'))); const { renderToString } = await import(pathToFileURL(require.resolve('@plantuml/core')));
// 仓库位置由脚本 URL 确定,与调用者工作目录无关;输出目录不存在时创建。
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = path.join(root, 'docs/architecture/uml'); const source = path.join(root, 'docs/architecture/uml');
const output = path.join(source, 'rendered'); const output = path.join(source, 'rendered');
await mkdir(output, { recursive: true }); await mkdir(output, { recursive: true });
// 按文件名排序使处理顺序/日志稳定,只处理本目录的 .puml,不递归扫描产物目录。
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) { for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。 // 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
const text = await readFile(path.join(source, filename), 'utf8'); const text = await readFile(path.join(source, filename), 'utf8');
// 将引擎 resolve/reject 回调适配为 Promise,逐图等待;渲染失败直接终止,不覆盖该图产物。
const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject)); const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject));
const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml'); const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml');
const labels = Array.from(xml.getElementsByTagName('text'), node => node.textContent).join('\n'); 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)) { 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}`); throw new Error(`${filename}: renderer returned an error diagram\n${labels}`);
} }
// Explicit opaque paper keeps arrows/labels legible in dark Markdown viewers. // 在最前方加入不透明白纸背景,保证深色 Markdown 查看器中的箭头与文字仍可辨认。
const paper = xml.createElementNS('http://www.w3.org/2000/svg', 'rect'); 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); 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); xml.documentElement.insertBefore(paper, xml.documentElement.firstChild);
@@ -84,7 +92,7 @@ for (const filename of (await readdir(source)).filter(name => name.endsWith('.pu
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。 // 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
const stem = filename.slice(0, -5); const stem = filename.slice(0, -5);
await writeFile(path.join(output, `${stem}.svg`), svg); await writeFile(path.join(output, `${stem}.svg`), svg);
// High-resolution PNG is the universally visible Markdown preview; SVG keeps zoomable text. // 以 192 DPI 光栅化为 PNG 供通用预览;SVG 同时保留矢量文字和缩放能力。
const png = sharp(Buffer.from(svg), { density: 192 }); const png = sharp(Buffer.from(svg), { density: 192 });
await png.png().toFile(path.join(output, `${stem}.png`)); await png.png().toFile(path.join(output, `${stem}.png`));
const metadata = await sharp(path.join(output, `${stem}.png`)).metadata(); const metadata = await sharp(path.join(output, `${stem}.png`)).metadata();
+673
View File
@@ -0,0 +1,673 @@
//! General retained graphics resources for source paint: images, fonts, prepared
//! text, geometric exclusions and numerical functions. No application behaviour.
use crate::{
document::{RenderCommand, parse_color},
geometry::{Point, Rect},
render::{
image::Image,
text_flow::{Cursor, Prepared},
},
text::{TextFamily, TextStyle},
};
use endor_core::{
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
ObjectStore, Value, Vm, VmError,
};
use std::{any::Any, sync::Arc};
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
fn object(v: &Value) -> Result<ObjectRef, VmError> {
if let Value::Object(o) = v {
Ok(*o)
} else {
Err(invalid("expected Object"))
}
}
fn number(v: &Value) -> Result<f32, VmError> {
let Value::Number(v) = v else {
return Err(invalid("expected Number"));
};
let n = v.to_f64()?;
if n.abs() > 1_000_000. {
return Err(invalid("graphics number exceeds bounds"));
}
Ok(n as f32)
}
fn text(v: &Value) -> Result<&str, VmError> {
if let Value::Text(s) = v {
Ok(s)
} else {
Err(invalid("expected String"))
}
}
fn n(v: f32) -> Value {
Value::Number((v as f64).into())
}
#[derive(Debug)]
pub(crate) struct ImageResource(pub Arc<Image>);
#[derive(Debug)]
struct FontResource(TextFamily);
#[derive(Debug)]
struct TextResource {
prepared: Arc<Prepared>,
line: f32,
}
#[derive(Debug)]
struct Exclusion {
circle: Option<(Point, f32)>,
polygon: Vec<Point>,
padding: f32,
}
macro_rules! resource {
($t:ty,$name:literal) => {
impl ObjectPayload for $t {
fn as_any(&self) -> &dyn Any {
self
}
fn kind_name(&self) -> &'static str {
$name
}
fn allows_attributes(&self) -> bool {
true
}
fn reference_assignment_only(&self) -> bool {
true
}
}
};
}
resource!(ImageResource, "image-resource");
resource!(FontResource, "font-resource");
resource!(TextResource, "prepared-text");
resource!(Exclusion, "layout-exclusion");
fn attach(cx: &mut NativeContext<'_>, o: ObjectRef, desktop: ObjectRef) -> Result<(), VmError> {
crate::ui_bridge::attach_desktop_type(cx, o, "Object", desktop)
}
fn file(path: &str, limit: u64) -> Result<Vec<u8>, VmError> {
use std::io::Read;
if path.len() > 8192 {
return Err(invalid("resource path too long"));
}
let f = std::fs::File::open(path).map_err(|_| invalid("resource file cannot be opened"))?;
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"));
}
let mut data = Vec::new();
f.take(limit + 1)
.read_to_end(&mut data)
.map_err(|_| invalid("resource read failed"))?;
if data.len() as u64 > limit {
return Err(invalid("resource exceeds byte limit"));
}
Ok(data)
}
fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result<f32, VmError> {
number(&s.get_attr(o, key)?)
}
pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result<RenderCommand, VmError> {
let resource = object(&s.get_attr(c, "image")?)?;
let image = s
.payload::<ImageResource>(resource, "ImageResource")?
.0
.clone();
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")),
};
let c = RenderCommand::Image {
x: field(s, c, "x")?,
y: field(s, c, "y")?,
width: field(s, c, "width")?,
height: field(s, c, "height")?,
offset_x: field(s, c, "offsetX")?,
offset_y: field(s, c, "offsetY")?,
angle: field(s, c, "angle")?,
opacity: field(s, c, "opacity")?,
tint,
image,
};
c.validate()
.map_err(|_| invalid("invalid image geometry"))?;
Ok(c)
}
fn command(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
image: ObjectRef,
origin: Point,
rect: Rect,
angle: f32,
opacity: f32,
) -> Result<ObjectRef, VmError> {
let c = crate::native_visual::record(
cx,
desktop,
"PaintCommand",
vec![
("kind", Value::Text("image".into())),
("image", Value::Object(image)),
("x", n(origin.x)),
("y", n(origin.y)),
("width", n(rect.width)),
("height", n(rect.height)),
("offsetX", n(rect.x)),
("offsetY", n(rect.y)),
("angle", n(angle)),
("opacity", n(opacity)),
],
)?;
Ok(c)
}
#[derive(Debug, Clone, Copy)]
enum Op {
LoadImage,
LoadFont,
ReadText,
Slice,
Stringify,
Floor,
Now,
Prepare,
Sprite,
ImageExclusion,
Circle,
Rectangle,
Flow,
Float,
Sin,
Cos,
Sqrt,
Atan2,
Abs,
Min,
Max,
Clamp,
}
#[derive(Debug)]
struct Function {
op: Op,
desktop: ObjectRef,
}
impl NativeCallable for Function {
fn arity(&self) -> Option<endor_core::CallArity> {
use Op::*;
Some(endor_core::CallArity::exact(match self.op {
Now => 0,
LoadImage | ReadText | Float | Sin | Cos | Sqrt | Abs | Stringify | Floor => 1,
LoadFont | Atan2 | Min | Max | ImageExclusion => 2,
Circle | Clamp | Slice => 3,
Rectangle => 4,
Prepare => 6,
Flow => 7,
Sprite => 9,
}))
}
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Op::*;
let desktop = self.desktop;
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",
));
}
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
Value::Number(
START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs_f64()
.into(),
)
}
LoadImage => {
if cx.store().is_read_only() {
return Err(invalid("load resources outside paint/layout"));
}
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);
let resource = cx.allocate(ImageResource(image))?;
attach(cx, resource, desktop)?;
cx.set_attr(resource, "width", n(w as f32))?;
cx.set_attr(resource, "height", n(h as f32))?;
Value::Object(resource)
}
LoadFont => {
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"))?;
let resource = cx.allocate(FontResource(font))?;
attach(cx, resource, desktop)?;
Value::Object(resource)
}
ReadText => {
if cx.store().is_read_only() {
return Err(invalid("load resources outside paint/layout"));
}
Value::Text(
String::from_utf8(file(text(&args[0])?, 16_384)?)
.map_err(|_| invalid("resource is not UTF-8"))?,
)
}
Slice => {
let value = text(&args[0])?;
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"));
}
let lo = value
.char_indices()
.nth(start as usize)
.map_or(value.len(), |(i, _)| i);
let hi = value
.char_indices()
.nth(end as usize)
.map_or(value.len(), |(i, _)| i);
Value::Text(value[lo..hi].into())
}
Stringify => Value::Text(match &args[0] {
Value::Text(s) => s.clone(),
Value::Number(v) => v.to_string(),
Value::Bool(v) => v.to_string(),
Value::Nil => "nil".into(),
_ => return Err(invalid("string expects scalar value")),
}),
Prepare => {
let value = text(&args[0])?;
if value.len() > 16_384 {
return Err(invalid("prepared text exceeds 16 KiB"));
}
let family = cx
.store()
.payload::<FontResource>(object(&args[1])?, "FontResource")?
.0;
let size = number(&args[2])?;
let line = number(&args[3])?;
let scale = number(&args[5])?;
if !(8. ..=96.).contains(&size)
|| !(size..=192.).contains(&line)
|| !(0.5..=4.).contains(&scale)
{
return Err(invalid("invalid text metrics"));
}
let color =
parse_color(text(&args[4])?).map_err(|_| invalid("invalid text color"))?;
let prepared = Prepared::new(
value,
TextStyle {
font_size: size,
line_height: line,
family,
},
scale,
color,
);
let prepared = prepared.map_err(invalid)?;
let resource = cx.allocate(TextResource {
prepared: Arc::new(prepared),
line,
})?;
attach(cx, resource, desktop)?;
Value::Object(resource)
}
Sprite => {
let image = object(&args[0])?;
cx.store()
.payload::<ImageResource>(image, "ImageResource")?;
let x = number(&args[1])?;
let y = number(&args[2])?;
let w = number(&args[3])?;
let h = number(&args[4])?;
let a = number(&args[5])?;
let ax = number(&args[6])?;
let ay = number(&args[7])?;
let opacity = number(&args[8])?;
let c = command(
cx,
desktop,
image,
Point::new(x, y),
Rect::new(-w * ax, -h * ay, w, h),
a,
opacity,
)?;
decode_image(cx.store(), c)?;
Value::Object(c)
}
ImageExclusion => {
let RenderCommand::Image {
x,
y,
width,
height,
offset_x,
offset_y,
angle,
image,
..
} = decode_image(cx.store(), object(&args[0])?)?
else {
unreachable!()
};
let padding = number(&args[1])?;
if !(0. ..=1000.).contains(&padding) {
return Err(invalid("invalid exclusion padding"));
}
let (sin, cos) = angle.sin_cos();
let polygon = image
.outline
.iter()
.map(|p| {
let px = offset_x + p.x / image.width as f32 * width;
let py = offset_y + p.y / image.height as f32 * height;
Point::new(x + cos * px - sin * py, y + sin * px + cos * py)
})
.collect();
let e = cx.allocate(Exclusion {
circle: None,
polygon,
padding,
})?;
attach(cx, e, desktop)?;
Value::Object(e)
}
Circle | Rectangle => {
let x = number(&args[0])?;
let y = number(&args[1])?;
let size = number(&args[2])?;
if size < 0. {
return Err(invalid("negative exclusion extent"));
}
let e = if matches!(self.op, Circle) {
Exclusion {
circle: Some((Point::new(x, y), size)),
polygon: vec![],
padding: 0.,
}
} else {
let h = number(&args[3])?;
if h < 0. {
return Err(invalid("negative exclusion height"));
}
Exclusion {
circle: None,
polygon: vec![
Point::new(x, y),
Point::new(x + size, y),
Point::new(x + size, y + h),
Point::new(x, y + h),
],
padding: 0.,
}
};
let e = cx.allocate(e)?;
attach(cx, e, desktop)?;
Value::Object(e)
}
Flow => flow(cx, desktop, args)?,
_ => {
let real = |v: &Value| -> Result<f64, VmError> {
if let Value::Number(n) = v {
n.to_f64()
} else {
Err(invalid("math expects Number"))
}
};
let x = real(&args[0])?;
if matches!(self.op, Floor) {
if x.abs() > 1_000_000_000. {
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(),
Cos => x.cos(),
Sqrt => x.sqrt(),
Atan2 => x.atan2(real(&args[1])?),
Abs => x.abs(),
Min => x.min(real(&args[1])?),
Max => x.max(real(&args[1])?),
Clamp => {
let lo = real(&args[1])?;
let hi = real(&args[2])?;
if lo > hi {
return Err(invalid("clamp minimum exceeds maximum"));
}
x.clamp(lo, hi)
}
_ => unreachable!(),
};
if !v.is_finite() {
return Err(invalid("non-finite math result"));
}
Value::Number(v.into())
}
};
Ok(NativeResult::Return(result))
}
}
fn ranges(left: f32, right: f32, y: f32, line: f32, exclusions: &[&Exclusion]) -> Vec<(f32, f32)> {
let mut blocked = Vec::new();
for e in exclusions {
if let Some((p, r)) = e.circle {
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));
}
}
let (top, bottom) = (y - e.padding, y + line + e.padding);
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) {
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));
let mut x = left;
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()
}
fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Result<Value, VmError> {
let resource = object(&args[0])?;
let text = cx
.store()
.payload::<TextResource>(resource, "PreparedText")?;
let prepared = text.prepared.clone();
let line = text.line;
let x = number(&args[1])?;
let y = number(&args[2])?;
let width = number(&args[3])?;
let height = number(&args[4])?;
let letters = number(&args[6])? != 0.;
if width < 0. || height < 0. || height / line > 1024. {
return Err(invalid("text flow bounds exceed budget"));
}
let items = crate::object_list::items(cx.store(), object(&args[5])?)?;
if items.len() > 512 {
return Err(invalid("text flow exceeds 512 exclusions"));
}
let exclusions = items
.iter()
.map(|&o| cx.store().payload::<Exclusion>(o, "Exclusion"))
.collect::<Result<Vec<_>, _>>()?;
let mut placements = Vec::new();
let mut cursor = Cursor::default();
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;
}
let mut commands = Vec::new();
for p in placements {
let word = &prepared.words[p.word];
let inks = if let Some(i) = p.letter {
std::slice::from_ref(&word.letters[i])
} else if letters {
&word.letters[..]
} else {
std::slice::from_ref(&word.ink)
};
let mut px = p.x;
for ink in inks {
let image = cx.allocate(ImageResource(ink.image.clone()))?;
attach(cx, image, desktop)?;
commands.push(command(
cx,
desktop,
image,
Point::new(px, p.y),
ink.rect,
0.,
1.,
)?);
px += ink.width;
if commands.len() > 10_000 {
return Err(invalid("text flow command budget exceeded"));
}
}
}
let commands = crate::object_list::collection(cx, commands, desktop)?;
Ok(Value::Object(crate::native_visual::record(
cx,
desktop,
"DisplayList",
vec![("commands", Value::Object(commands))],
)?))
}
pub(crate) fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
ui: ObjectRef,
desktop: ObjectRef,
) -> Result<(), VmError> {
let graphics = vm.new_object()?;
roots.push(graphics);
attach_vm(vm, graphics, "Object", desktop)?;
let math = vm.new_object()?;
roots.push(math);
attach_vm(vm, math, "Object", desktop)?;
for (owner, name, op) in [
(graphics, "loadImage", Op::LoadImage),
(graphics, "loadFont", Op::LoadFont),
(graphics, "readText", Op::ReadText),
(graphics, "textSlice", Op::Slice),
(graphics, "string", Op::Stringify),
(math, "floor", Op::Floor),
(math, "now", Op::Now),
(graphics, "prepareText", Op::Prepare),
(graphics, "sprite", Op::Sprite),
(graphics, "imageExclusion", Op::ImageExclusion),
(graphics, "circleExclusion", Op::Circle),
(graphics, "rectExclusion", Op::Rectangle),
(graphics, "flowText", Op::Flow),
(math, "float", Op::Float),
(math, "sin", Op::Sin),
(math, "cos", Op::Cos),
(math, "sqrt", Op::Sqrt),
(math, "atan2", Op::Atan2),
(math, "abs", Op::Abs),
(math, "min", Op::Min),
(math, "max", Op::Max),
(math, "clamp", Op::Clamp),
] {
let f = vm.allocate(ContextNativeFunction::new(
name,
Function { op, desktop },
vec![desktop],
))?;
roots.push(f);
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(())
}
fn attach_vm(vm: &mut Vm, o: ObjectRef, name: &str, desktop: ObjectRef) -> Result<(), VmError> {
let t = crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)?;
vm.attach_type(o, t)
}
#[derive(Debug)]
pub(crate) struct PointerDispatch {
pub owner: ObjectRef,
pub mount: ObjectRef,
pub desktop: ObjectRef,
pub kind: String,
pub point: Point,
}
impl NativeCallable for PointerDispatch {
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
let data = cx
.store()
.payload::<crate::mount::Mount>(self.mount, "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);
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
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))?;
crate::event_dispatch::dispatch(cx, self.owner, event, Some(self.mount), self.desktop)
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。 //! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
//! //!
// Module ownership follows the directory; crate-root aliases keep internal call sites stable. // 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 注册托管类型/宿主方法,验证参数并连接业务对象、Mount、视觉协议与窗口/任务资源。
pub(crate) mod ui_bridge; pub(crate) mod ui_bridge;
pub(crate) mod graphics;
+120 -8
View File
@@ -30,6 +30,7 @@ pub const MAX_UI_NODES: usize = 4096;
pub const MAX_PAGES: usize = 64; pub const MAX_PAGES: usize = 64;
pub const MAX_DIMENSION: f64 = 1_000_000.0; pub const MAX_DIMENSION: f64 = 1_000_000.0;
// 编译器已知宿主全局类型/能力名,同时用于 install_globals;仅列名不代表绕过实际 VM 名义类型查找。
pub const TYPE_NAMES: &[&str] = &[ pub const TYPE_NAMES: &[&str] = &[
"Object", "Object",
"Type", "Type",
@@ -102,7 +103,9 @@ struct Session {
current: u64, current: u64,
master: ObjectRef, master: ObjectRef,
next_window: u64, next_window: u64,
// 跨窗口共用 Mount 高水位,避免某窗口取消/重建后与其他窗口已暴露出现 ID 重合。
next_mount: u64, next_mount: u64,
// 正常关闭副窗口后暂存尚待发布的 Close 等命令;Presentation 已移除也不能丢掉最后的原生退役请求。
retired_commands: Vec<crate::window::Command>, retired_commands: Vec<crate::window::Command>,
refresh_yields: usize, refresh_yields: usize,
refresh_restarts: usize, refresh_restarts: usize,
@@ -127,6 +130,7 @@ impl std::ops::Deref for Session {
type Target = crate::presentation::Presentation; type Target = crate::presentation::Presentation;
/// 将包装类型的只读访问转交给内部当前上下文。 /// 将包装类型的只读访问转交给内部当前上下文。
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
// Session 解引用始终指向 current Presentation;调用源回调前切换窗口,结束后恢复主窗口,不能把它当全局唯一展示。
self.presentations self.presentations
.get(&self.current) .get(&self.current)
.expect("selected presentation") .expect("selected presentation")
@@ -211,9 +215,10 @@ enum Operation {
} }
impl Operation { impl Operation {
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 /// 返回该操作显式参数数量范围;这里不计接收者、Type 首参和源子类字段。
fn arity(self) -> endor_core::CallArity { fn arity(self) -> endor_core::CallArity {
use Operation::*; use Operation::*;
// 此 arity 只含显式参数;TypeMethod 另加 receiver,NativeFactory 另加 Type 和子类字段外壳。
let (minimum, maximum) = match self { let (minimum, maximum) = match self {
IsInstance | IsSubtype | Bind | Implement | Implements | Supports => (2, 2), IsInstance | IsSubtype | Bind | Implement | Implements | Supports => (2, 2),
Stats | Viewport | Window | PageWindow | Mounts => (0, 0), Stats | Viewport | Window | PageWindow | Mounts => (0, 0),
@@ -254,6 +259,7 @@ struct Function {
operation: Operation, operation: Operation,
owner: Option<ObjectRef>, owner: Option<ObjectRef>,
session: Shared, session: Shared,
// 原生构造的实际源子类身份;自由 ui 工厂为 None,类型构造器显式传入 Some。
requested_type: Option<ObjectRef>, requested_type: Option<ObjectRef>,
} }
@@ -291,7 +297,7 @@ impl NativeCallable for TypeMethod {
fn signature(&self) -> Option<CallSignature> { fn signature(&self) -> Option<CallSignature> {
Some(self.signature.clone()) Some(self.signature.clone())
} }
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 /// 在 Operation 的显式参数范围两端各加一,计入 VM 传入的隐式接收者。
fn arity(&self) -> Option<endor_core::CallArity> { fn arity(&self) -> Option<endor_core::CallArity> {
let arity = self.operation.arity(); let arity = self.operation.arity();
Some(endor_core::CallArity::range( Some(endor_core::CallArity::range(
@@ -300,8 +306,9 @@ impl NativeCallable for TypeMethod {
)) ))
} }
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 /// 校验首参是预期名义类型实例,再剥离接收者,复用 Function 分派执行同一 Operation。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 名义方法统一存在类型上,VM 把 receiver 放首参;校验后剥离 receiver 再交 Function 的显式参数分派。
let owner = object(args.first())?; let owner = object(args.first())?;
if !cx.store().is_instance(owner, self.expected)? { if !cx.store().is_instance(owner, self.expected)? {
return Err(invalid("incorrect receiver type for visual method")); return Err(invalid("incorrect receiver type for visual method"));
@@ -333,7 +340,7 @@ impl NativeCallable for NativeFactory {
fn signature(&self) -> Option<CallSignature> { fn signature(&self) -> Option<CallSignature> {
self.signature.clone() self.signature.clone()
} }
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 /// 要求原生必填参数再加一个请求 Type;上界暂开放,子类字段与原生选项在 invoke 内分别校验。
fn arity(&self) -> Option<endor_core::CallArity> { fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::range( Some(endor_core::CallArity::range(
self.operation.arity().required + 1, self.operation.arity().required + 1,
@@ -341,7 +348,7 @@ impl NativeCallable for NativeFactory {
)) ))
} }
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 /// 把请求类型的字段后缀与原生选项前缀分开验证,分配真实原生子类并初始化字段,避免非法字段先触发构造副作用。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let requested = object(args.first())?; let requested = object(args.first())?;
let fields = cx let fields = cx
@@ -350,12 +357,14 @@ impl NativeCallable for NativeFactory {
.fields() .fields()
.to_vec(); .to_vec();
let values = &args[1..]; let values = &args[1..];
// 构造实参末尾是类型声明的继承/自有字段,前缀才是原生构造选项;checked_sub 防止缺字段造成下溢。
let native_count = values let native_count = values
.len() .len()
.checked_sub(fields.len()) .checked_sub(fields.len())
.ok_or(invalid("missing subclass field arguments"))?; .ok_or(invalid("missing subclass field arguments"))?;
cx.store() cx.store()
.check_class_field_arguments(requested, &values[native_count..])?; .check_class_field_arguments(requested, &values[native_count..])?;
// 外壳允许宽变参,但原生前缀仍按具体 Operation 完整签名检查,不能因子类存在绕过类型或 arity。
self.options self.options
.as_ref() .as_ref()
.ok_or(invalid("native factory is not initialized"))? .ok_or(invalid("native factory is not initialized"))?
@@ -375,6 +384,7 @@ impl NativeCallable for NativeFactory {
let NativeResult::Return(Value::Object(instance)) = result else { let NativeResult::Return(Value::Object(instance)) = result else {
return Err(invalid("native constructor did not return an instance")); return Err(invalid("native constructor did not return an instance"));
}; };
// 分配器必须返回精确 requested 类型,不能只返回基类实例;之后才初始化子类字段。
if cx.store().type_of(instance)? != Some(requested) { if cx.store().type_of(instance)? != Some(requested) {
return Err(invalid( return Err(invalid(
"native constructor returned an incorrect nominal type", "native constructor returned an incorrect nominal type",
@@ -429,6 +439,7 @@ fn dimension(value: Option<&Value>) -> Result<f32, VmError> {
let Some(Value::Number(number)) = value else { let Some(Value::Number(number)) = value else {
return Err(invalid("expected a finite nonnegative dimension")); return Err(invalid("expected a finite nonnegative dimension"));
}; };
// 分别支持大整数、十进制和二进制实数表示,不接受非实值;转 f64 后检查有限与范围,再降 f32。
let value = match number.as_real() { let value = match number.as_real() {
Some(EndorReal::Integer(v)) => v.to_string().parse::<f64>().ok(), Some(EndorReal::Integer(v)) => v.to_string().parse::<f64>().ok(),
Some(EndorReal::Decimal(v)) => v.literal().parse::<f64>().ok(), Some(EndorReal::Decimal(v)) => v.literal().parse::<f64>().ok(),
@@ -461,6 +472,7 @@ fn bind(
session: session.clone(), session: session.clone(),
requested_type: None, requested_type: None,
}, },
// 绑定方法显式捕获 owner 与 desktop,让托管 GC 识别函数的真实强边,Rust Shared 会话本身不隐式保活 VM 对象。
vec![owner, desktop], vec![owner, desktop],
))?; ))?;
attach_desktop_type(cx, function, "Function", desktop)?; attach_desktop_type(cx, function, "Function", desktop)?;
@@ -498,6 +510,7 @@ fn new_camera2d(
.unwrap_or(fallback); .unwrap_or(fallback);
cx.set_attr(camera, name, crate::native_visual::value(value))?; cx.set_attr(camera, name, crate::native_visual::value(value))?;
} }
// 初始化完所有相机属性后走完整相机校验,尤其 zoom 必须处于 0.01..100,不能只通过一般坐标范围。
crate::viewport::camera(cx.store(), camera)?; crate::viewport::camera(cx.store(), camera)?;
Ok(camera) Ok(camera)
} }
@@ -530,6 +543,7 @@ fn new_typed_block(
.desktop; .desktop;
let expected = desktop_type(cx.store(), type_name, desktop)?; let expected = desktop_type(cx.store(), type_name, desktop)?;
let type_object = requested.unwrap_or(expected); let type_object = requested.unwrap_or(expected);
// 叶/容器原生种类对应不同类型基类,禁止用 TextBlock 分配器创建并非其子类的名义对象。
if !cx.store().is_subtype(type_object, expected)? { if !cx.store().is_subtype(type_object, expected)? {
return Err(invalid("native constructor cannot allocate this subclass")); return Err(invalid("native constructor cannot allocate this subclass"));
} }
@@ -537,6 +551,7 @@ fn new_typed_block(
cx.attach_type(block, type_object)?; cx.attach_type(block, type_object)?;
{ {
let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?;
// 节点弱表受宿主预算限制;实际死亡弱项通过后续 reclaim 压缩,不能把弱索引当永不增长的免费结构。
if state.nodes.len() >= MAX_UI_NODES { if state.nodes.len() >= MAX_UI_NODES {
return Err(VmError::ExecutionLimit("4096 node budget")); return Err(VmError::ExecutionLimit("4096 node budget"));
} }
@@ -572,6 +587,7 @@ fn create_window(
if state.exiting { if state.exiting {
return Err(invalid("runtime is exiting; window creation is forbidden")); return Err(invalid("runtime is exiting; window creation is forbidden"));
} }
// 同一 VM 最多16窗口(含主壳);ID 单调分配并限制到 i64 正范围,关闭后不复用旧资源身份。
if state.presentations.len() >= 16 { if state.presentations.len() >= 16 {
return Err(VmError::ExecutionLimit("16 window budget")); return Err(VmError::ExecutionLimit("16 window budget"));
} }
@@ -582,6 +598,7 @@ fn create_window(
.ok_or(invalid("window identities exhausted"))?; .ok_or(invalid("window identities exhausted"))?;
let (window, desktop, tabs) = crate::window::create(cx, state.master, id, definition)?; let (window, desktop, tabs) = crate::window::create(cx, state.master, id, definition)?;
let mut presentation = crate::presentation::Presentation::new(desktop, tabs); let mut presentation = crate::presentation::Presentation::new(desktop, tabs);
// 新窗口即使暂无内容也需发布有限空帧,让 GUI 根据投影创建 OS 窗口,而不是在 VM 中直接创建。
presentation.force_frame = true; presentation.force_frame = true;
state.presentations.insert(id, presentation); state.presentations.insert(id, presentation);
state.next_window = id; state.next_window = id;
@@ -610,6 +627,7 @@ fn remove_page(
) -> Result<(), VmError> { ) -> Result<(), VmError> {
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
crate::window::unregister_page(cx, window, page)?; crate::window::unregister_page(cx, window, page)?;
// 双层 pending 区分无请求与请求清空;移除当前/待选页时改选剩余第一页,或显式 pending=None页面。
if view.pending.unwrap_or(view.active) == Some(page) { if view.pending.unwrap_or(view.active) == Some(page) {
let next = crate::window::pages(cx.store(), window)?.first().copied(); let next = crate::window::pages(cx.store(), window)?.first().copied();
cx.set_attr( cx.set_attr(
@@ -631,6 +649,7 @@ fn show_in(
let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?;
let old = page_window(cx.store(), &state, page)?; let old = page_window(cx.store(), &state, page)?;
let id = match target { let id = match target {
// 显式目标窗口既要未关闭,也要对应本会话 Presentation 的同一 desktop,拒绝其他 VM 的窗口身份。
Some(window) => { Some(window) => {
let data = cx let data = cx
.store() .store()
@@ -648,11 +667,13 @@ fn show_in(
} }
id id
} }
// 不指定窗口时优先保留 Page 现有归属;未打开过的新页才进入 current,而非每次 activate 都迁移到主窗口。
None => old.unwrap_or(state.current), None => old.unwrap_or(state.current),
}; };
if let Some(old) = old if let Some(old) = old
&& old != id && old != id
{ {
// 跨窗口移动先撤源窗口登记与必要活动页选择,再在目标登记同一 Page,业务内容对象不复制。
remove_page( remove_page(
cx, cx,
state state
@@ -669,6 +690,7 @@ fn show_in(
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?; let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
crate::window::register_page(cx, window, page)?; crate::window::register_page(cx, window, page)?;
cx.set_attr(view.desktop, "pending", Value::Object(page))?; cx.set_attr(view.desktop, "pending", Value::Object(page))?;
// 仅记录展示请求并由 desktop.pending 强持有 Page;实际 active、Mount、布局和 published_version 留待刷新提交。
view.pending = Some(Some(page)); view.pending = Some(Some(page));
Ok(()) Ok(())
} }
@@ -684,17 +706,18 @@ struct PresentResult {
session: Shared, session: Shared,
} }
impl NativeCallable for PresentResult { impl NativeCallable for PresentResult {
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 /// 接收 present 委托返回的唯一视觉对象,作为后续展示处理的输入。
fn arity(&self) -> Option<endor_core::CallArity> { fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1)) Some(endor_core::CallArity::exact(1))
} }
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 /// 验证 present 结果为 Block;open 模式必要时包装 Page 并展示,否则只返回转换得到的视觉对象。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Object(root)] = args else { let [Value::Object(root)] = args else {
return Err(invalid("Presentable.present must return a Block")); return Err(invalid("Presentable.present must return a Block"));
}; };
let is_page = cx.store().payload::<block::Block>(*root, "Block")?.page; let is_page = cx.store().payload::<block::Block>(*root, "Block")?.page;
// present 委托返回值必须为 Block;open=true 必要时包 Page 并展示,open=false 只返回转换结果。
let result = if self.open { let result = if self.open {
let page = if is_page { let page = if is_page {
*root *root
@@ -714,7 +737,7 @@ impl NativeCallable for PresentResult {
} }
} }
/// 根据 Presentable 能力把任意源值转换成可展示内容,避免只允许某个原生 Block 类型。 /// 返回已有 Block,或把其他值的可读表示包装成 TextBlock;Presentable 委托由外层调用分派负责。
fn present_value( fn present_value(
cx: &mut NativeContext<'_>, cx: &mut NativeContext<'_>,
value: &Value, value: &Value,
@@ -730,6 +753,7 @@ fn present_value(
{ {
return Ok(*block); return Ok(*block);
} }
// 非 Block 的标量/对象转可读文字,Presentable 方法委托在 calls.rs 的更外层处理,本函数不自己调用 present。
let value = match value { let value = match value {
Value::Nil => "nil".into(), Value::Nil => "nil".into(),
Value::Bool(v) => v.to_string(), Value::Bool(v) => v.to_string(),
@@ -771,7 +795,9 @@ struct LayoutContext {
full_viewport: Option<crate::layout::Size>, full_viewport: Option<crate::layout::Size>,
desktop: ObjectRef, desktop: ObjectRef,
window: u64, window: u64,
// 布局同步返回的生命周期序列拥有根,暂留到安全提交边界执行/释放,取消时也必须清理。
hooks: Option<ObjectRef>, hooks: Option<ObjectRef>,
// 布局准备可能暂改 Mount payload 与 desktop.mounts,检查点保证失败或尺寸重定向能恢复未发布关系。
checkpoint: crate::mount::Checkpoint, checkpoint: crate::mount::Checkpoint,
} }
@@ -796,6 +822,7 @@ fn invoke_member(
arguments: &[Value], arguments: &[Value],
instruction_budget: usize, instruction_budget: usize,
) -> Result<Value> { ) -> Result<Value> {
// 使用 VM 正常绑定规则创建临时方法值;无论 call 成功/失败,先保存 result 再释放 bound 根。
let bound = vm.bind_attr(owner, method)?; let bound = vm.bind_attr(owner, method)?;
let result = match &bound { let result = match &bound {
Value::Object(function) => vm Value::Object(function) => vm
@@ -821,11 +848,13 @@ fn project_tabs(
) -> Result<Vec<crate::tabs::Tab>> { ) -> Result<Vec<crate::tabs::Tab>> {
let window = object(Some(&vm.get_attr(state.desktop, "window")?))?; let window = object(Some(&vm.get_attr(state.desktop, "window")?))?;
let pages = crate::window::pages(vm.object_store(), window)?; let pages = crate::window::pages(vm.object_store(), window)?;
// 标签选中反映待提交页面(若有请求),否则沿用 active;页面标题和顺序仍从真实窗口注册表提取。
let active = state.pending.unwrap_or(state.active); let active = state.pending.unwrap_or(state.active);
let mut live = Vec::new(); let mut live = Vec::new();
let mut tabs = Vec::new(); let mut tabs = Vec::new();
for (index, page) in pages.iter().copied().enumerate() { for (index, page) in pages.iter().copied().enumerate() {
let weak = vm.object_store().downgrade(page)?; let weak = vm.object_store().downgrade(page)?;
// 同一 Page 弱身份复用该窗口标签 ID,新增页用高水位;移出后 live 更新会移除旧映射,不靠标题匹配。
let id = if let Some((_, id)) = state.tab_ids.iter().find(|(w, _)| *w == weak) { let id = if let Some((_, id)) = state.tab_ids.iter().find(|(w, _)| *w == weak) {
*id *id
} else { } else {
@@ -836,6 +865,7 @@ fn project_tabs(
state.next_tab state.next_tab
}; };
live.push((weak, id)); live.push((weak, id));
// 标题显示过滤控制字符并限制120个 Unicode 字符,缺失/空标题用默认序号;不修改原 Page.title。
let title = match vm.get_attr(page, "title") { let title = match vm.get_attr(page, "title") {
Ok(Value::Text(title)) if !title.is_empty() => title Ok(Value::Text(title)) if !title.is_empty() => title
.chars() .chars()
@@ -863,6 +893,7 @@ impl UiBridge {
/// already closed before source unmount hooks run, so they cannot remount. /// already closed before source unmount hooks run, so they cannot remount.
/// 按窗口和展示生命周期执行有界退出清理,允许调用方根据故障状态决定是否执行回调。 /// 按窗口和展示生命周期执行有界退出清理,允许调用方根据故障状态决定是否执行回调。
pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> { pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> {
// 退出先关闭新任务入口并取消旧任务,再标 session.exiting,避免卸载回调重新创建任务/窗口。
let tasks = self.tasks.quiesce(vm)?; let tasks = self.tasks.quiesce(vm)?;
let mut drivers = Vec::new(); let mut drivers = Vec::new();
let mut mounts = 0; let mut mounts = 0;
@@ -877,6 +908,7 @@ impl UiBridge {
let view = state.presentations.get_mut(id).expect("known window"); let view = state.presentations.get_mut(id).expect("known window");
let desktop = view.desktop; let desktop = view.desktop;
mounts += view.mounts.active.len(); mounts += view.mounts.active.len();
// 正常域用空结构 synchronize 安排孩子先卸载的 hooks;故障域直接 retire,不能再次执行用户代码。
if callbacks { if callbacks {
if let Some(hooks) = view.mounts.synchronize( if let Some(hooks) = view.mounts.synchronize(
vm, vm,
@@ -892,6 +924,7 @@ impl UiBridge {
view.mounts.retire(vm, desktop)?; view.mounts.retire(vm, desktop)?;
} }
let window = object(Some(&vm.get_attr(desktop, "window")?))?; let window = object(Some(&vm.get_attr(desktop, "window")?))?;
// 所有窗口先标 closed 并断 Page 拥有关系,然后才在下面执行用户卸载 hooks,阻止回调重新打开已关闭窗口。
crate::window::retire(vm, window)?; crate::window::retire(vm, window)?;
vm.set_attr(desktop, "pending", Value::Nil)?; vm.set_attr(desktop, "pending", Value::Nil)?;
vm.set_attr(desktop, "focus", Value::Nil)?; vm.set_attr(desktop, "focus", Value::Nil)?;
@@ -902,6 +935,7 @@ impl UiBridge {
Ok(ids.len()) Ok(ids.len())
})(); })();
let mut result = prepared.map(|windows| (windows, mounts, tasks)); let mut result = prepared.map(|windows| (windows, mounts, tasks));
// 若之前清理/回调失败,余下 hooks 不再执行,但所有驱动根仍逐项 release,避免失败路径泄漏。
for (id, hooks) in drivers { for (id, hooks) in drivers {
if result.is_ok() { if result.is_ok() {
let call = (|| -> Result<()> { let call = (|| -> Result<()> {
@@ -951,6 +985,7 @@ impl UiBridge {
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
let mut ids: Vec<_> = state.presentations.keys().copied().collect(); let mut ids: Vec<_> = state.presentations.keys().copied().collect();
// 优先处理切离旧 active 页的窗口,使跨窗口迁移先退旧出现再挂新出现;同优先级按窗口 ID 稳定排序。
ids.sort_by_key(|id| { ids.sort_by_key(|id| {
let view = &state.presentations[id]; let view = &state.presentations[id];
( (
@@ -1009,6 +1044,7 @@ impl UiBridge {
vm.set_attr(desktop, "focus", Value::Nil)?; vm.set_attr(desktop, "focus", Value::Nil)?;
vm.set_attr(desktop, "focusRequested", Value::Bool(false))?; vm.set_attr(desktop, "focusRequested", Value::Bool(false))?;
// Draining is not publication: failed-domain effects are discarded. // Draining is not publication: failed-domain effects are discarded.
// 故障宿主退役的命令只丢弃不发布,防止失败域遗留 Resize/Focus 等影响当前原生状态。
let _ = crate::window::take_commands(vm, window)?; let _ = crate::window::take_commands(vm, window)?;
let windows = object(Some(&vm.get_attr(state.master, "windows")?))?; let windows = object(Some(&vm.get_attr(state.master, "windows")?))?;
vm.dict_delete(windows, &Value::Text(id.to_string()))?; vm.dict_delete(windows, &Value::Text(id.to_string()))?;
@@ -1023,6 +1059,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 正常 finish_closed 只移除已标 closing 的副窗口,主壳保持到 OS 退出;其最后命令转存 retired_commands 待发布。
let ids: Vec<_> = state let ids: Vec<_> = state
.presentations .presentations
.iter() .iter()
@@ -1048,6 +1085,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 先取已移除窗口的最后命令,再合并仍活动窗口 outbox;按窗口/窗口内序号排序,不丢失正常 Close 请求。
let mut commands = std::mem::take(&mut state.retired_commands); let mut commands = std::mem::take(&mut state.retired_commands);
for view in state.presentations.values() { for view in state.presentations.values() {
let window = object(Some(&vm.get_attr(view.desktop, "window")?))?; let window = object(Some(&vm.get_attr(view.desktop, "window")?))?;
@@ -1071,6 +1109,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 尺寸比较在分配新输入对象前完成,避免相同 resize 通知改变 VM 版本而触发自我失效。
if state.layout.viewport == viewport { if state.layout.viewport == viewport {
return Ok(false); return Ok(false);
} }
@@ -1081,6 +1120,7 @@ impl UiBridge {
vm.set_attr(input, "height", Value::Number((size.height as f64).into()))?; vm.set_attr(input, "height", Value::Number((size.height as f64).into()))?;
vm.set_attr(state.desktop, "viewport", Value::Object(input)) vm.set_attr(state.desktop, "viewport", Value::Object(input))
})(); })();
// desktop 已建立 viewport 强边后释放局部根;字段/安装失败也先释放 input,再传播保存的错误。
vm.release(input)?; vm.release(input)?;
result?; result?;
} else { } else {
@@ -1089,6 +1129,7 @@ impl UiBridge {
state.layout.viewport = viewport; state.layout.viewport = viewport;
// A fresh Console domain can own window inputs without replacing the // A fresh Console domain can own window inputs without replacing the
// preserved displayed page until it actually opens/renders something. // preserved displayed page until it actually opens/renders something.
// 空的新 Console 域可以记录逻辑尺寸,但未展示任何 Page 时不应覆盖用户仍看到的旧域画面。
state.force_frame |= state.active.is_some() || state.pending.flatten().is_some(); state.force_frame |= state.active.is_some() || state.pending.flatten().is_some();
Ok(true) Ok(true)
} }
@@ -1102,6 +1143,7 @@ impl UiBridge {
Value::Text("View".into()), Value::Text("View".into()),
vm.get_attr(self.ui, "Block")?, vm.get_attr(self.ui, "Block")?,
)?; )?;
// 全局名引用 ui 中同一批名义类型/函数对象,模块和 Console 不会各复制一份类型体系。
for name in TYPE_NAMES.iter().chain(QUERY_NAMES) { for name in TYPE_NAMES.iter().chain(QUERY_NAMES) {
let value = vm.get_attr(self.ui, name)?; let value = vm.get_attr(self.ui, name)?;
vm.dict_set(globals, Value::Text((*name).into()), value)?; vm.dict_set(globals, Value::Text((*name).into()), value)?;
@@ -1126,6 +1168,7 @@ impl UiBridge {
let Ok(state) = self.session.lock() else { let Ok(state) = self.session.lock() else {
return false; return false;
}; };
// 拖放源与目标都必须匹配各自发布版本,且真实 Page 注册顺序仍对应标签弱身份表,防止用陈旧列表下标重排。
for (id, version) in [ for (id, version) in [
(input.window, input.layout_version), (input.window, input.layout_version),
(target.window, target.layout_version), (target.window, target.layout_version),
@@ -1173,6 +1216,7 @@ impl UiBridge {
if state.published_version != input.layout_version { if state.published_version != input.layout_version {
bail!("event targets an obsolete presentation"); bail!("event targets an obsolete presentation");
} }
// input.mount 在标签路径表示 Tab ID,先映回弱 Page,再确认仍在当前窗口 pages 集合,不是 Mount 几何下标。
let weak = state let weak = state
.tab_ids .tab_ids
.iter() .iter()
@@ -1215,12 +1259,14 @@ impl UiBridge {
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
page_window(vm.object_store(), &state, page)? page_window(vm.object_store(), &state, page)?
}; };
// 源 moveToWindow 可覆盖并拒绝/改投其他窗口;只有确认实际归属仍为预期目标才继续重排与激活。
if destination == Some(target.window) { if destination == Some(target.window) {
let pages = crate::window::pages(vm.object_store(), window)?; let pages = crate::window::pages(vm.object_store(), window)?;
let from = pages let from = pages
.iter() .iter()
.position(|candidate| *candidate == page) .position(|candidate| *candidate == page)
.ok_or_else(|| anyhow::anyhow!("Page is no longer open"))?; .ok_or_else(|| anyhow::anyhow!("Page is no longer open"))?;
// 同窗口拖动的 index 是移除前插入缝位置,向后拖时先减一;跨窗口新加入则不用这一偏移修正。
let position = if input.window == target.window && from < target.index { let position = if input.window == target.window && from < target.index {
target.index - 1 target.index - 1
} else { } else {
@@ -1248,6 +1294,7 @@ impl UiBridge {
_ => "activate", _ => "activate",
}; };
let value = invoke_member(vm, page, method, &[], 1_000_000)?; let value = invoke_member(vm, page, method, &[], 1_000_000)?;
// detach 返回值可能被源覆盖成其他值,仅确为 Window 且带屏幕位置才调用 moveTo;最后统一释放原返回值。
let placed = (|| -> Result<()> { let placed = (|| -> Result<()> {
if input.kind == "tab-detach" if input.kind == "tab-detach"
&& let Some([x, y]) = input.screen_position && let Some([x, y]) = input.screen_position
@@ -1289,6 +1336,7 @@ impl UiBridge {
{ {
bail!("event belongs to another window"); bail!("event belongs to another window");
} }
// 窗口关闭通过名义 close 方法,保留源覆盖语义;直接调用宿主原生退役只用于故障清理路径。
if input.kind == "window-close" { if input.kind == "window-close" {
let desktop = self let desktop = self
.session .session
@@ -1312,6 +1360,7 @@ impl UiBridge {
vm.release_value(result?)?; vm.release_value(result?)?;
return Ok(()); return Ok(());
} }
// 内置工具打开走同一 VM 的 developerTools 能力,返回值只作临时根使用,不跨到 GUI。
if let Some(name) = input.kind.strip_prefix("developer-open:") { if let Some(name) = input.kind.strip_prefix("developer-open:") {
let function = object(Some(&vm.get_attr(self.ui, "developerTools")?))?; let function = object(Some(&vm.get_attr(self.ui, "developerTools")?))?;
let page = vm.call(function, &[Value::Text(name.into())])?; let page = vm.call(function, &[Value::Text(name.into())])?;
@@ -1336,6 +1385,7 @@ impl UiBridge {
) { ) {
return self.dispatch_tab(vm, input); return self.dispatch_tab(vm, input);
} }
// 普通指针输入取同一锁内的版本/目标/完整出现候选快照;随后不持 session 锁执行源 hitTest 或事件回调。
let (weak, desktop, candidates, viewport, surface) = { let (weak, desktop, candidates, viewport, surface) = {
let state = self let state = self
.session .session
@@ -1365,6 +1415,7 @@ impl UiBridge {
let point = input let point = input
.point .point
.ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?; .ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?;
// scroll 不只校验 Mount 身份,还要指针仍在页面视口和出现裁剪中;相机/面板映射后的增量才可交给 scrollBy。
if !data.mounted if !data.mounted
|| data.version != input.layout_version || data.version != input.layout_version
|| !surface.contains(point, viewport) || !surface.contains(point, viewport)
@@ -1402,6 +1453,7 @@ impl UiBridge {
vm.release(mount)?; vm.release(mount)?;
return result; return result;
} }
// 坐标点击重新执行源协议选择按下和释放目标,二者同出现才分发;无坐标输入使用已捕获的弱目标。
let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) { let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) {
if [press, release] if [press, release]
.iter() .iter()
@@ -1420,6 +1472,7 @@ impl UiBridge {
}; };
let result = (|| { let result = (|| {
let data = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone(); let data = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
// 3D 视口点击先生成 RayHit3D 并附到 Event.hit,再沿视口出现传播,不能只按二维外框假定击中某网格。
if data.geometry.scene3d_viewport if data.geometry.scene3d_viewport
&& input.kind == "click" && input.kind == "click"
&& let Some(point) = input.point && let Some(point) = input.point
@@ -1451,6 +1504,34 @@ impl UiBridge {
if !data.geometry.interactive { if !data.geometry.interactive {
bail!("Mount does not accept input"); bail!("Mount does not accept input");
} }
if input.kind.starts_with("pointer") {
let p = input
.point
.ok_or_else(|| anyhow::anyhow!("pointer needs point"))?;
let f = vm.allocate(ContextNativeFunction::new(
"native pointer event",
crate::bridge::graphics::PointerDispatch {
owner: data.block,
mount,
desktop,
kind: input.kind.clone(),
point: crate::geometry::Point::new(p[0], p[1]),
},
vec![data.block, mount, desktop],
))?;
let result = vm.call_with_limits(
f,
&[],
endor_core::ExecutionLimits {
max_frames: 128,
max_instructions: 1_000_000,
},
);
vm.release(f)?;
vm.release_value(result?)?;
return Ok(());
}
// 普通点击/焦点先登记展示焦点请求,再按 kind 调 Block.click 或 Mount.requestFocus;生效仍需后续完整刷新。
vm.set_attr( vm.set_attr(
desktop, desktop,
"focus", "focus",
@@ -1483,6 +1564,7 @@ impl UiBridge {
} }
/// 整理不再有效的弱索引与展示根,并配合 VM 回收,避免派生缓存延长业务对象生命周期。 /// 整理不再有效的弱索引与展示根,并配合 VM 回收,避免派生缓存延长业务对象生命周期。
pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec<ObjectRef>) -> Result<()> { pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec<ObjectRef>) -> Result<()> {
// 先回收托管环,再扫描弱节点表,只保留能升级的身份;render_cache.prune 同步清除死亡业务索引。
vm.collect_cycles(); vm.collect_cycles();
let mut state = self let mut state = self
.session .session
@@ -1511,6 +1593,7 @@ impl UiBridge {
let window = object(Some(&vm.get_attr(presentation.desktop, "window")?))?; let window = object(Some(&vm.get_attr(presentation.desktop, "window")?))?;
// A failed first render must not become visible on the next input // A failed first render must not become visible on the next input
// merely because show() already registered its Page with the Window. // merely because show() already registered its Page with the Window.
// 第一次 render 失败时撤销尚未发布的 Page 登记,避免下一次无关输入把失败页意外显示出来。
if presentation.active.is_none() && presentation.last_tabs.is_empty() { if presentation.active.is_none() && presentation.last_tabs.is_empty() {
let unpublished = crate::window::pages(vm.object_store(), window)?.to_vec(); let unpublished = crate::window::pages(vm.object_store(), window)?.to_vec();
for page in unpublished { for page in unpublished {
@@ -1518,6 +1601,7 @@ impl UiBridge {
} }
} }
let pages = crate::window::pages(vm.object_store(), window)?; let pages = crate::window::pages(vm.object_store(), window)?;
// 失败输入已成功执行的关闭/迁移语句不回滚;重新选择仍在真实窗口注册表中的页,修复 pending 与拥有关系。
let next = if presentation.closing { let next = if presentation.closing {
None None
} else { } else {
@@ -1554,6 +1638,7 @@ impl UiBridge {
} }
/// 重新评估已失效的属性绑定,并把结果计入会话统计。 /// 重新评估已失效的属性绑定,并把结果计入会话统计。
pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> { pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> {
// 只在锁内快照弱节点与宿主桌面集合,实际绑定回调在锁外执行,避免回调访问 UI 时死锁。
let (nodes, desktops) = { let (nodes, desktops) = {
let state = self let state = self
.session .session
@@ -1571,11 +1656,13 @@ impl UiBridge {
} }
/// 排空一次 VM 变化记录并通知各窗口,提取活动页面快照,判断本次是否需要布局与发布。 /// 排空一次 VM 变化记录并通知各窗口,提取活动页面快照,判断本次是否需要布局与发布。
pub fn prepare_frame(&self, vm: &mut Vm) -> Result<Option<Option<Arc<UiNode>>>> { pub fn prepare_frame(&self, vm: &mut Vm) -> Result<Option<Option<Arc<UiNode>>>> {
// 一次排空变更必须广播所有 Presentation,不能让先刷新的窗口吃掉其他窗口共享业务数据的失效通知。
let changes = vm.take_changes(); let changes = vm.take_changes();
let mut state = self let mut state = self
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 本轮目标优先 pending,包括显式清空;快照提取返回 None 代表目标无页面,不等于无需刷新。
let active = state.pending.unwrap_or(state.active); let active = state.pending.unwrap_or(state.active);
// One VM change drain feeds every presentation before selecting a root. // One VM change drain feeds every presentation before selecting a root.
// Otherwise a window refreshed first could consume another window's work. // Otherwise a window refreshed first could consume another window's work.
@@ -1585,6 +1672,7 @@ impl UiBridge {
let root = active let root = active
.map(|v| state.render_cache.snapshot(vm.object_store(), v)) .map(|v| state.render_cache.snapshot(vm.object_store(), v))
.transpose()?; .transpose()?;
// 视觉缓存用 Arc 身份快速判断根未变;即使根相同,焦点、滚动、标签或源依赖仍可能要求重绘。
let same = match (&root, &state.last_snapshot) { let same = match (&root, &state.last_snapshot) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b), (Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true, (None, None) => true,
@@ -1612,6 +1700,7 @@ impl UiBridge {
state.snapshot_visited = state.render_cache.visited; state.snapshot_visited = state.render_cache.visited;
state.snapshot_reused = state.render_cache.reused; state.snapshot_reused = state.render_cache.reused;
} }
// 双层 Option 三态:外 None 无工作;Some(None) 需提交空帧;Some(Some(root)) 需布局/绘制根。
Ok(changed.then_some(root)) Ok(changed.then_some(root))
} }
/// 提交已经完成的出现几何与快照,并处理展示生命周期;不把挂起或失败的布局当成已发布帧。 /// 提交已经完成的出现几何与快照,并处理展示生命周期;不把挂起或失败的布局当成已发布帧。
@@ -1628,6 +1717,7 @@ impl UiBridge {
let page = state.pending.unwrap_or(state.active); let page = state.pending.unwrap_or(state.active);
let desktop = state.desktop; let desktop = state.desktop;
let nodes = state.nodes.clone(); let nodes = state.nodes.clone();
// 提交几何同步清除临时 layout Context,并产生挂载/卸载序列;提交桥接状态不等于已写 GUI 共享文档。
let hooks = state let hooks = state
.mounts .mounts
.synchronize(vm, desktop, page, nodes, frame, false)?; .synchronize(vm, desktop, page, nodes, frame, false)?;
@@ -1645,6 +1735,7 @@ impl UiBridge {
state.last_snapshot = snapshot; state.last_snapshot = snapshot;
state.last_tabs = frame.tabs.clone(); state.last_tabs = frame.tabs.clone();
state.surface = frame.surface.clone(); state.surface = frame.surface.clone();
// 让随后源回调看到本轮几何与版本,但真正对外原子发布要等整个刷新循环稳定,回调可能继续触发下一轮。
state.published_version = frame.layout_version; state.published_version = frame.layout_version;
// Hooks may call Page/container APIs; never hold the session mutex // Hooks may call Page/container APIs; never hold the session mutex
// across user code. Publish only after the refresh loop settles. // across user code. Publish only after the refresh loop settles.
@@ -1682,6 +1773,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 按最终帧绘制顺序查活动 Mount 弱身份,每条都必须存在;不按 HashMap 顺序调用源 paint。
let mounts = frame let mounts = frame
.mounts .mounts
.iter() .iter()
@@ -1721,6 +1813,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 完成后过滤所有窗口私有 desktop 读取,再拆对象版本与名称依赖;挂起切片不覆盖上一份完成的 paint 依赖。
let desktops = state.host_desktops(); let desktops = state.host_desktops();
let reads = draft let reads = draft
.reads .reads
@@ -1743,6 +1836,7 @@ impl UiBridge {
window: u64, window: u64,
work: crate::source_paint::Work, work: crate::source_paint::Work,
) -> Result<()> { ) -> Result<()> {
// 先尝试取消 VM 续体,再尝试选原窗口并强制下一帧;前一操作失败也执行必要的宿主失效准备。
let cancelled = work.cancel(vm); let cancelled = work.cancel(vm);
let invalidated = self.select_window(window).and_then(|()| { let invalidated = self.select_window(window).and_then(|()| {
self.session self.session
@@ -1765,6 +1859,7 @@ impl UiBridge {
!vm.has_suspended_call() && !vm.object_store().is_read_only(), !vm.has_suspended_call() && !vm.object_store().is_read_only(),
"cannot prepare another frame during suspended layout" "cannot prepare another frame during suspended layout"
); );
// 显式空页面无需源布局,清测量/布局读取并推进空帧版本,再同步退役旧 Mount;不是无工作返回。
let Some(root) = root else { let Some(root) = root else {
self.session self.session
.lock() .lock()
@@ -1821,6 +1916,7 @@ impl UiBridge {
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
project_tabs(vm, &mut state)? project_tabs(vm, &mut state)?
}; };
// 记录真实原生页面视口供页面相机 clamp;兼容 Page.mode 只改变根约束,不永久篡改保存的窗口尺寸。
let full_viewport = layout.viewport; let full_viewport = layout.viewport;
let surface = root.surface.clone().unwrap_or_default(); let surface = root.surface.clone().unwrap_or_default();
layout.page_mode = root.surface.as_ref().map(|surface| surface.mode); layout.page_mode = root.surface.as_ref().map(|surface| surface.mode);
@@ -1829,6 +1925,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 按全会话高水位分配结构出现 ID,取消后也不回收已暴露过的数值身份。
layout.reserve_identity_floor(state.next_mount); layout.reserve_identity_floor(state.next_mount);
} }
let mut frame = layout.structure(&root)?; let mut frame = layout.structure(&root)?;
@@ -1845,6 +1942,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 在准备态 synchronize 改写 Mount 前保留旧字典与 payload;准备失败立即恢复,不能留下半套父子关系。
let checkpoint = state.mounts.checkpoint(vm, desktop)?; let checkpoint = state.mounts.checkpoint(vm, desktop)?;
let hooks = match state let hooks = match state
.mounts .mounts
@@ -1858,6 +1956,7 @@ impl UiBridge {
}; };
(hooks, state.mounts.ordered(), checkpoint) (hooks, state.mounts.ordered(), checkpoint)
}; };
// 检查点、暂存 hooks、所属窗口与根随源布局 Work 一起保留,跨量子不能只留下 VM 续体。
let context = LayoutContext { let context = LayoutContext {
surface, surface,
root: root.clone(), root: root.clone(),
@@ -1918,6 +2017,7 @@ impl UiBridge {
checkpoint, checkpoint,
.. ..
} = context; } = context;
// 恢复必须在原窗口;若窗口或 session 锁已不可用,至少释放检查点拥有根,不能尝试恢复到另一个窗口。
let selected = self.select_window(window); let selected = self.select_window(window);
let restored = if selected.is_ok() { let restored = if selected.is_ok() {
match self.session.lock() { match self.session.lock() {
@@ -1934,6 +2034,7 @@ impl UiBridge {
} else { } else {
checkpoint.release(vm).map_err(anyhow::Error::from) checkpoint.release(vm).map_err(anyhow::Error::from)
}; };
// 无论选择/恢复是否失败都先尝试释放延迟 hooks;保存结果后再按优先级传播错误。
let released = hooks.map(|hooks| vm.release(hooks)).transpose(); let released = hooks.map(|hooks| vm.release(hooks)).transpose();
selected?; selected?;
restored?; restored?;
@@ -1944,6 +2045,7 @@ impl UiBridge {
/// 取消源布局续体并清理对应上下文,保持已发布帧可用。 /// 取消源布局续体并清理对应上下文,保持已发布帧可用。
pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> { pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> {
let PendingLayout { work, context } = pending; let PendingLayout { work, context } = pending;
// 取消只读执行与恢复 Mount 是两件事,均先尝试;没有继续执行原续体时也不能漏掉检查点恢复。
let cancelled = work.cancel(vm); let cancelled = work.cancel(vm);
let restored = self.discard_layout_context(vm, context); let restored = self.discard_layout_context(vm, context);
cancelled?; cancelled?;
@@ -1962,6 +2064,7 @@ impl UiBridge {
pending: PendingLayout, pending: PendingLayout,
) -> Result<LayoutProgress> { ) -> Result<LayoutProgress> {
let PendingLayout { work, context } = pending; let PendingLayout { work, context } = pending;
// 恢复时原窗口不存在则取消 owning VM 调用并清上下文,不重新建窗口或重放源布局。
if let Err(error) = self.select_window(context.window) { if let Err(error) = self.select_window(context.window) {
let cancelled = work.cancel(vm); let cancelled = work.cancel(vm);
let restored = self.discard_layout_context(vm, context); let restored = self.discard_layout_context(vm, context);
@@ -1981,6 +2084,7 @@ impl UiBridge {
progress: Result<crate::source_layout::Progress>, progress: Result<crate::source_layout::Progress>,
) -> Result<LayoutProgress> { ) -> Result<LayoutProgress> {
match progress { match progress {
// Pending 原样捆绑 Work 与 Context;Complete 才投影、提交,Error 则撤销准备态上下文。
Ok(crate::source_layout::Progress::Pending(work)) => { Ok(crate::source_layout::Progress::Pending(work)) => {
Ok(LayoutProgress::Pending(Box::new(PendingLayout { Ok(LayoutProgress::Pending(Box::new(PendingLayout {
work, work,
@@ -2027,11 +2131,13 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 先在布局坐标中处理滚动,再叠二维视口/面板投影;绘制与输入共享最终 Frame 几何。
crate::scroll::project(vm, &state.mounts, &root, &mut frame)?; crate::scroll::project(vm, &state.mounts, &root, &mut frame)?;
crate::viewport::project(&root, &mut frame)?; crate::viewport::project(&root, &mut frame)?;
} }
// Native layout may replace the structural Frame; attach chrome only // Native layout may replace the structural Frame; attach chrome only
// after either layout path has produced the final page geometry. // after either layout path has produced the final page geometry.
// 小原生快速路径可能返回新的 Frame,标签/窗口与页面相机必须在最终布局之后重新附加。
frame.tabs = tabs; frame.tabs = tabs;
frame.window = window; frame.window = window;
if let Some(root) = frame.mounts.first() { if let Some(root) = frame.mounts.first() {
@@ -2053,6 +2159,7 @@ impl UiBridge {
// Shared intrinsic functions capture the primary desktop even when // Shared intrinsic functions capture the primary desktop even when
// invoked from another window. Desktop bookkeeping is private host // invoked from another window. Desktop bookkeeping is private host
// state; Window, viewport input and business dependencies remain. // state; Window, viewport input and business dependencies remain.
// 源布局实际业务读取版本在生命周期 hooks 前保存,hooks 改写依赖后下一轮才能识别失效并重算。
reads.retain(|read| !desktops.contains(&read.object())); reads.retain(|read| !desktops.contains(&read.object()));
cache.exclude_host_desktops(&desktops); cache.exclude_host_desktops(&desktops);
let (reads, names) = crate::presentation::split_reads(reads); let (reads, names) = crate::presentation::split_reads(reads);
@@ -2060,6 +2167,7 @@ impl UiBridge {
Ok(Value::Number(id)) => id.as_i64().and_then(|n| u64::try_from(n).ok()), Ok(Value::Number(id)) => id.as_i64().and_then(|n| u64::try_from(n).ok()),
_ => None, _ => None,
}; };
// 焦点 ID 仅保留仍存在且可交互的出现,切页/树编辑移除目标后自动清掉失效焦点。
frame.focus = frame frame.focus = frame
.focus .focus
.filter(|id| frame.mounts.iter().any(|m| m.id == *id && m.interactive)); .filter(|id| frame.mounts.iter().any(|m| m.id == *id && m.interactive));
@@ -2074,6 +2182,7 @@ impl UiBridge {
state.layout_reads = reads; state.layout_reads = reads;
state.layout_names = names; state.layout_names = names;
} }
// 几何先提交到桥接状态,再执行准备阶段延后的挂载 hooks;其变化由全窗口刷新循环再收敛。
self.commit_frame(vm, Some(root), &frame)?; self.commit_frame(vm, Some(root), &frame)?;
if let Some(hooks) = hooks { if let Some(hooks) = hooks {
let value = vm let value = vm
@@ -2090,6 +2199,7 @@ impl UiBridge {
} }
Ok(frame) Ok(frame)
})(); })();
// 收尾计算无论成功/失败,都先释放 hooks 与检查点的拥有根;只有所有必要清理通过才返回帧。
let released_hooks = hooks.map(|hooks| vm.release(hooks)).transpose(); let released_hooks = hooks.map(|hooks| vm.release(hooks)).transpose();
let released_checkpoint = checkpoint.release(vm); let released_checkpoint = checkpoint.release(vm);
let frame = result?; let frame = result?;
@@ -2104,6 +2214,7 @@ impl UiBridge {
.session .session
.lock() .lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?; .map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
// 此兼容提取接口只读取待展示 Page,不回退 active;持久增量路径使用 prepare_frame 的 active/pending 选择。
state state
.pending .pending
.flatten() .flatten()
@@ -2144,6 +2255,7 @@ pub(crate) fn desktop_type(
name: &str, name: &str,
desktop: ObjectRef, desktop: ObjectRef,
) -> Result<ObjectRef, VmError> { ) -> Result<ObjectRef, VmError> {
// 查当前 desktop 的实际字典对象和精确类型条目;不是按名字创建新类型,缺条目沿 object 解码报错。
let types = object(Some(&store.get_attr(desktop, "types")?))?; let types = object(Some(&store.get_attr(desktop, "types")?))?;
object(store.dict_get(types, &Value::Text(name.into()))?.as_ref()) object(store.dict_get(types, &Value::Text(name.into()))?.as_ref())
} }
+21
View File
@@ -6,6 +6,7 @@ use super::*;
/// 把本模块的类型、方法或能力注册到所属运行时上下文。 /// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> { pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
// 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。
let desktop = vm.new_object()?; let desktop = vm.new_object()?;
roots.push(desktop); roots.push(desktop);
vm.set_attr(desktop, "viewport", Value::Nil)?; vm.set_attr(desktop, "viewport", Value::Nil)?;
@@ -14,6 +15,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(desktop, "tabs", Value::Object(tabs))?; vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
let ui = vm.new_object()?; let ui = vm.new_object()?;
roots.push(ui); roots.push(ui);
// 原生 Session 借用托管身份,主窗口先拥有独立 Presentation;业务对象仍全部位于传入的单一 VM。
let session = Arc::new(Mutex::new(Session { let session = Arc::new(Mutex::new(Session {
nodes: Vec::new(), nodes: Vec::new(),
presentations: std::collections::BTreeMap::from([( presentations: std::collections::BTreeMap::from([(
@@ -33,6 +35,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
binding_evaluations: 0, binding_evaluations: 0,
exiting: false, exiting: false,
})); }));
// 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。
let mut public_functions = Vec::new(); let mut public_functions = Vec::new();
let mut factories = Vec::new(); let mut factories = Vec::new();
for (name, operation) in [ for (name, operation) in [
@@ -75,6 +78,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(ui, name, Value::Object(function))?; vm.set_attr(ui, name, Value::Object(function))?;
public_functions.push((name, function, operation)); public_functions.push((name, function, operation));
} }
// Object/Type 来自 VM 类型引导,不能用普通类代替;之后所有宿主名义类型从此根体系建立。
let root_types = vm.bootstrap_types()?; let root_types = vm.bootstrap_types()?;
roots.extend([root_types.object, root_types.type_type]); roots.extend([root_types.object, root_types.type_type]);
vm.set_attr(ui, "Object", Value::Object(root_types.object))?; vm.set_attr(ui, "Object", Value::Object(root_types.object))?;
@@ -83,6 +87,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Object", root_types.object), ("Object", root_types.object),
("Type", root_types.type_type), ("Type", root_types.type_type),
]; ];
// 类型按基类先于子类的顺序创建。None 表示抽象能力/宿主结果类型,不开放普通源构造器。
for (name, base_name, constructor_name) in [ for (name, base_name, constructor_name) in [
("Trait", "Object", None), ("Trait", "Object", None),
("Function", "Object", None), ("Function", "Object", None),
@@ -124,6 +129,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
] { ] {
let base = object(Some(&vm.get_attr(ui, base_name)?))?; let base = object(Some(&vm.get_attr(ui, base_name)?))?;
let constructor = match constructor_name { let constructor = match constructor_name {
// ObjectList 与 Event 有特殊的“类型首参+子类字段”构造规则,单独原生适配;其他类型共用 NativeFactory。
Some("object-list") => { Some("object-list") => {
let factory = vm.allocate(ContextNativeFunction::new( let factory = vm.allocate(ContextNativeFunction::new(
"object-list", "object-list",
@@ -150,6 +156,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
TypeConstructor::NativeTyped(factory) TypeConstructor::NativeTyped(factory)
} }
Some(constructor_name) => { Some(constructor_name) => {
// 把构造标签映射到宿主 Operation;3D 族委托其元数据,未知标签立即失败,不静默创建错误类型。
let operation = match constructor_name { let operation = match constructor_name {
"text" => Operation::Text, "text" => Operation::Text,
"rect" => Operation::Rect, "rect" => Operation::Rect,
@@ -186,6 +193,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
} }
None => TypeConstructor::Abstract, None => TypeConstructor::Abstract,
}; };
// 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。
if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor) if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor)
{ {
factories.push((tag, factory)); factories.push((tag, factory));
@@ -195,6 +203,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(ui, name, Value::Object(definition))?; vm.set_attr(ui, name, Value::Object(definition))?;
definitions.push((name, definition)); definitions.push((name, definition));
} }
// 协议值采用普通托管类:Constraints/Bounds/Point 等字段有类型注解,PaintCommand/CameraGesture 后续再初始化属性。
for (name, fields) in [ for (name, fields) in [
( (
"Constraints", "Constraints",
@@ -212,6 +221,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
.into_iter() .into_iter()
.map(|name| endor_core::ClassField { .map(|name| endor_core::ClassField {
name: name.into(), name: name.into(),
// children/commands 先声明 Object 接口,后续各协议解码器再要求受控列表;几何字段统一为 Number。
type_name: if matches!(name, "children" | "commands") { type_name: if matches!(name, "children" | "commands") {
"Object" "Object"
} else { } else {
@@ -232,6 +242,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
} }
// The desktop owns the registry through ordinary traced attributes. The // The desktop owns the registry through ordinary traced attributes. The
// Rust session only borrows handles and adds no hidden lifetime roots. // Rust session only borrows handles and adds no hidden lifetime roots.
// 桌面通过普通属性拥有类型字典;后续 nominal 查询必须使用同一 desktop 中注册的精确类型身份。
let types = vm.new_dict()?; let types = vm.new_dict()?;
roots.push(types); roots.push(types);
for (name, definition) in definitions { for (name, definition) in definitions {
@@ -241,8 +252,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.attach_type(desktop, root_types.object)?; vm.attach_type(desktop, root_types.object)?;
vm.attach_type(ui, root_types.object)?; vm.attach_type(ui, root_types.object)?;
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
// 让后续 VM 创建的函数使用宿主 Function 名义类型,并打开源类型检查,再安装完整本机签名。
vm.set_function_type(function_type)?; vm.set_function_type(function_type)?;
vm.set_source_type_checks(true)?; vm.set_source_type_checks(true)?;
// 以同一函数身份补完 operation_signature,不改变先前 ui 属性引用的函数对象。
for (name, function, operation) in public_functions { for (name, function, operation) in public_functions {
vm.replace_payload( vm.replace_payload(
function, function,
@@ -284,6 +297,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
}; };
if let Some(operation) = operation { if let Some(operation) = operation {
let options = operation_signature(vm.object_store(), desktop, operation)?; let options = operation_signature(vm.object_store(), desktop, operation)?;
// 外层 NativeTyped 工厂接收 Type 首参和任意候选尾参,允许子类字段;NativeFactory 用 options 再核对基构造参数。
let envelope = CallSignature { let envelope = CallSignature {
arity: endor_core::CallArity::range(options.arity.required + 1, usize::MAX), arity: endor_core::CallArity::range(options.arity.required + 1, usize::MAX),
parameters: vec![AnnotationType::Type.into()], parameters: vec![AnnotationType::Type.into()],
@@ -292,6 +306,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
effect: options.effect, effect: options.effect,
}; };
let mut captures = vec![desktop]; let mut captures = vec![desktop];
// 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。
captures.extend(options.references()); captures.extend(options.references());
vm.replace_payload( vm.replace_payload(
function, function,
@@ -307,6 +322,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
), ),
)?; )?;
} else { } else {
// 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。
let kind = if name == "event" { let kind = if name == "event" {
"Event" "Event"
} else { } else {
@@ -377,6 +393,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let function = object(Some(&vm.get_attr(ui, name)?))?; let function = object(Some(&vm.get_attr(ui, name)?))?;
vm.attach_type(function, function_type)?; vm.attach_type(function, function_type)?;
} }
// 实例方法安装到对应名义类型;继承复用基类方法,TypeMethod 会验证实际接收者及参数契约。
for (type_name, name, operation) in [ for (type_name, name, operation) in [
("Block", "bind", Operation::Bind), ("Block", "bind", Operation::Bind),
("OrbitController", "update", Operation::CameraControl), ("OrbitController", "update", Operation::CameraControl),
@@ -429,12 +446,14 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
expected, expected,
session: session.clone(), session: session.clone(),
}, },
// 方法 wrapper 捕获所属类型与桌面,保证分发和签名引用在方法仍存活时有效。
vec![expected, desktop], vec![expected, desktop],
))?; ))?;
roots.push(method); roots.push(method);
vm.attach_type(method, function_type)?; vm.attach_type(method, function_type)?;
vm.define_method(expected, name, method)?; vm.define_method(expected, name, method)?;
} }
// 类型与基础方法就绪后再装窗口、默认视觉、事件方法和能力契约,顺序满足各模块查类型/方法的前提。
crate::window::install(vm, roots, desktop, tabs)?; crate::window::install(vm, roots, desktop, tabs)?;
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?; let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?; crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
@@ -442,7 +461,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?; let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?;
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?; crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
crate::protocols::install(vm, roots, desktop, ui)?; crate::protocols::install(vm, roots, desktop, ui)?;
// 每个桥接域配套自己的任务 Scheduler,再接工具入口及托管起始页工厂,最后返回可使用的 UiBridge。
let tasks = crate::tasks::install(vm, roots, ui, desktop)?; let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
crate::bridge::graphics::install(vm, roots, ui, desktop)?;
developer_pages::install(vm, ui, &session)?; developer_pages::install(vm, ui, &session)?;
let bridge = UiBridge { ui, session, tasks }; let bridge = UiBridge { ui, session, tasks };
developer_pages::install_start_page(vm, &bridge)?; developer_pages::install_start_page(vm, &bridge)?;
+43 -2
View File
@@ -9,16 +9,17 @@ impl NativeCallable for Function {
fn signature(&self) -> Option<CallSignature> { fn signature(&self) -> Option<CallSignature> {
self.signature.clone() self.signature.clone()
} }
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 /// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。
fn arity(&self) -> Option<endor_core::CallArity> { fn arity(&self) -> Option<endor_core::CallArity> {
Some(self.operation.arity()) Some(self.operation.arity())
} }
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 /// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Operation::*; use Operation::*;
let arity = self.operation.arity(); let arity = self.operation.arity();
let (minimum, maximum) = (arity.required, arity.maximum); let (minimum, maximum) = (arity.required, arity.maximum);
// Function 接收已剥离 receiver 的显式参数;先校验数量,后面索引 args 才安全。
if !(minimum..=maximum).contains(&args.len()) { if !(minimum..=maximum).contains(&args.len()) {
return Err(invalid("incorrect UI method argument count")); return Err(invalid("incorrect UI method argument count"));
} }
@@ -26,6 +27,7 @@ impl NativeCallable for Function {
let camera = new_camera2d(cx, &self.session, self.requested_type, args)?; let camera = new_camera2d(cx, &self.session, self.requested_type, args)?;
return Ok(NativeResult::Return(Value::Object(camera))); return Ok(NativeResult::Return(Value::Object(camera)));
} }
// 3D 构造与普通 Block 分开,requested_type 保留源子类名义身份,否则从本 desktop 取默认类型。
if let SceneObject(kind) = self.operation { if let SceneObject(kind) = self.operation {
let desktop = self let desktop = self
.session .session
@@ -58,6 +60,7 @@ impl NativeCallable for Function {
)?; )?;
return Ok(NativeResult::Return(Value::Nil)); return Ok(NativeResult::Return(Value::Nil));
} }
// implement 修改显式能力关系;implements 查类型,supports 查对象实际能力,不靠同名字段鸭子类型推断。
if matches!(self.operation, Implement | Implements | Supports) { if matches!(self.operation, Implement | Implements | Supports) {
let capability = object(args.get(1))?; let capability = object(args.get(1))?;
let value = match self.operation { let value = match self.operation {
@@ -92,6 +95,7 @@ impl NativeCallable for Function {
}; };
return Ok(NativeResult::Return(Value::Bool(value))); return Ok(NativeResult::Return(Value::Bool(value)));
} }
// 显式失效属于模型变更,只读阶段拒绝;paint/layout/content 精度不同,后续分别清理对应缓存。
if matches!(self.operation, Invalidate) { if matches!(self.operation, Invalidate) {
if cx.store().is_read_only() { if cx.store().is_read_only() {
return Err(invalid( return Err(invalid(
@@ -115,6 +119,7 @@ impl NativeCallable for Function {
.lock() .lock()
.map_err(|_| invalid("UI session poisoned"))?; .map_err(|_| invalid("UI session poisoned"))?;
let mut affected = std::collections::HashSet::new(); let mut affected = std::collections::HashSet::new();
// 找出解析到同一底层 Data 的所有别名,失效共享数据时不能只刷新调用者包装身份。
for weak in &session.nodes { for weak in &session.nodes {
if let Some(block) = cx.upgrade(*weak)? if let Some(block) = cx.upgrade(*weak)?
&& block::resolve(cx.store(), block)?.0 == data && block::resolve(cx.store(), block)?.0 == data
@@ -147,6 +152,7 @@ impl NativeCallable for Function {
} }
} }
} }
// 跨窗口 Mount 查询按窗口/出现 ID 稳定排序,不依赖 HashMap 遍历顺序;返回每个具体出现而非几何副本。
positions.sort_unstable_by_key(|(window, id, _)| (*window, *id)); positions.sort_unstable_by_key(|(window, id, _)| (*window, *id));
let set = crate::mount::collection( let set = crate::mount::collection(
cx, cx,
@@ -179,6 +185,7 @@ impl NativeCallable for Function {
.session .session
.lock() .lock()
.map_err(|_| invalid("UI session poisoned"))?; .map_err(|_| invalid("UI session poisoned"))?;
// 关闭托管窗口后为 Presentation 排空 pending 并标 closing,后续刷新才安排 Mount 退役与空帧。
if let Some(view) = state.presentations.get_mut(&id) { if let Some(view) = state.presentations.get_mut(&id) {
cx.set_attr(view.desktop, "pending", Value::Nil)?; cx.set_attr(view.desktop, "pending", Value::Nil)?;
view.pending = Some(None); view.pending = Some(None);
@@ -217,6 +224,7 @@ impl NativeCallable for Function {
)?, )?,
))); )));
} }
// 诊断会读对象总数与宿主变化统计,禁止在只读渲染中观察并形成反馈依赖。
if matches!(self.operation, Stats) { if matches!(self.operation, Stats) {
if cx.store().is_read_only() { if cx.store().is_read_only() {
return Err(invalid( return Err(invalid(
@@ -287,6 +295,7 @@ impl NativeCallable for Function {
)?; )?;
return Ok(NativeResult::Return(Value::Object(stats))); return Ok(NativeResult::Return(Value::Object(stats)));
} }
// 状态是普通名义 Object,value 属性经 VM 追踪,set/increment 方法捕获同一对象,不额外建立原生状态副本。
if matches!(self.operation, State) { if matches!(self.operation, State) {
let state = cx.new_object()?; let state = cx.new_object()?;
attach_named_type(cx, state, "Object", &self.session)?; attach_named_type(cx, state, "Object", &self.session)?;
@@ -303,6 +312,7 @@ impl NativeCallable for Function {
let Value::Number(value) = cx.store().get_attr(owner, "value")? else { let Value::Number(value) = cx.store().get_attr(owner, "value")? else {
return Err(invalid("increment requires an integer state")); return Err(invalid("increment requires an integer state"));
}; };
// increment 要求可表示为 i64 的整数且加一不溢出,不能把任意浮点状态强转整数。
let next = value let next = value
.as_i64() .as_i64()
.and_then(|n| n.checked_add(1)) .and_then(|n| n.checked_add(1))
@@ -315,6 +325,7 @@ impl NativeCallable for Function {
if matches!(self.operation, TextOf) { if matches!(self.operation, TextOf) {
let source = object(args.first())?; let source = object(args.first())?;
let field = text(args.get(1))?; let field = text(args.get(1))?;
// textOf 建立前确认字段可读取,具体字符串化与依赖记录发生在快照提取时。
cx.store().get_attr(source, &field)?; cx.store().get_attr(source, &field)?;
let size = args let size = args
.get(2) .get(2)
@@ -334,6 +345,7 @@ impl NativeCallable for Function {
0.0, 0.0,
0.0, 0.0,
); );
// 保留源对象强边与字段名,不在创建时复制一个静态字符串;后续属性变化会让文字快照重算。
data.text_binding = Some((source, field)); data.text_binding = Some((source, field));
return Ok(NativeResult::Return(Value::Object(new_block( return Ok(NativeResult::Return(Value::Object(new_block(
cx, cx,
@@ -341,6 +353,7 @@ impl NativeCallable for Function {
&self.session, &self.session,
)?))); )?)));
} }
// 普通组件先组装 Data,再统一由 new_typed_block 安装 Cell、属性和名义类型;不直接写原生窗口。
let constructor = match self.operation { let constructor = match self.operation {
Scroll => Some(Data::new( Scroll => Some(Data::new(
Kind::Scroll { Kind::Scroll {
@@ -396,6 +409,7 @@ impl NativeCallable for Function {
} else { } else {
Kind::Column Kind::Column
}, },
// 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。
args[2..] args[2..]
.iter() .iter()
.map(|v| object(Some(v))) .map(|v| object(Some(v)))
@@ -422,6 +436,7 @@ impl NativeCallable for Function {
0.0, 0.0,
0.0, 0.0,
); );
// 每个新二维视口默认新建独立 Camera2D;显式属性共享相机才会联动其他视口。
data.camera = Some(new_camera2d(cx, &self.session, None, &[])?); data.camera = Some(new_camera2d(cx, &self.session, None, &[])?);
Some(data) Some(data)
} }
@@ -451,6 +466,7 @@ impl NativeCallable for Function {
data.camera = Some(camera); data.camera = Some(camera);
data.scene3d = Some(object(args.get(2))?); data.scene3d = Some(object(args.get(2))?);
let definition = desktop_type(cx.store(), "OrbitController", desktop)?; let definition = desktop_type(cx.store(), "OrbitController", desktop)?;
// 三维视口默认独立透视相机及 OrbitController,Scene3D 引用则来自调用者,可显式共享世界。
data.controller = Some(crate::scene3d_model::allocate( data.controller = Some(crate::scene3d_model::allocate(
cx, cx,
desktop, desktop,
@@ -460,6 +476,7 @@ impl NativeCallable for Function {
)?); )?);
Some(data) Some(data)
} }
// 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。
Button => { Button => {
let label = new_block( let label = new_block(
cx, cx,
@@ -479,6 +496,7 @@ impl NativeCallable for Function {
} }
_ => None, _ => None,
}; };
// 统一检查文本等级和 UTF-8 字节量,再按 requested_type 生成实际对象,保留子类工厂身份。
if let Some(data) = constructor { if let Some(data) = constructor {
if let Kind::Text { size, value, .. } = &data.kind if let Kind::Text { size, value, .. } = &data.kind
&& (!(1..=8).contains(size) || value.len() > 16_384) && (!(1..=8).contains(size) || value.len() > 16_384)
@@ -502,6 +520,7 @@ impl NativeCallable for Function {
.map_err(|_| invalid("UI session poisoned"))?; .map_err(|_| invalid("UI session poisoned"))?;
(state.master, state.desktop) (state.master, state.desktop)
}; };
// 非 Block 对象若显式支持 Presentable,调用其 present;已有 Block 直接进入下面的展示转换。
let capability = desktop_type(cx.store(), "Presentable", desktop)?; let capability = desktop_type(cx.store(), "Presentable", desktop)?;
if !cx if !cx
.store() .store()
@@ -528,6 +547,7 @@ impl NativeCallable for Function {
}, },
vec![desktop], vec![desktop],
))?; ))?;
// 小驱动先调用 present(value, window),再把结果交 PresentResult 验证/可选打开,所有委托共用当前 VM 预算。
let instructions = [ let instructions = [
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0), endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0),
endor_core::Instruction::simple(endor_core::Opcode::PushNull), endor_core::Instruction::simple(endor_core::Opcode::PushNull),
@@ -560,6 +580,7 @@ impl NativeCallable for Function {
}); });
} }
} }
// ui.open 把普通结果转换为根组件,再在必要时包一层 Page 并展示;ui.present 仅转换,不自动打开页面。
if matches!(self.operation, OpenValue) { if matches!(self.operation, OpenValue) {
let root = present_value(cx, &args[0], &self.session)?; let root = present_value(cx, &args[0], &self.session)?;
let page = if cx.store().payload::<block::Block>(root, "Block")?.page { let page = if cx.store().payload::<block::Block>(root, "Block")?.page {
@@ -581,6 +602,7 @@ impl NativeCallable for Function {
&self.session, &self.session,
)?))); )?)));
} }
// 从这里起都是需要接收者的 Block/Page 方法,未绑定 owner 时不能继续修改任意全局对象。
let owner = self.owner.ok_or(invalid("method has no owner"))?; let owner = self.owner.ok_or(invalid("method has no owner"))?;
if matches!(self.operation, Bind) { if matches!(self.operation, Bind) {
let field = text(args.first())?; let field = text(args.first())?;
@@ -598,6 +620,7 @@ impl NativeCallable for Function {
)?, )?,
))); )));
} }
// moveTab 重排同窗口页面索引并强制下一帧更新标签;移动的是原 Page,非新页面副本。
if matches!(self.operation, MoveTab) { if matches!(self.operation, MoveTab) {
let position = usize::try_from(integer(args.first())?) let position = usize::try_from(integer(args.first())?)
.map_err(|_| invalid("tab position out of range"))?; .map_err(|_| invalid("tab position out of range"))?;
@@ -615,6 +638,7 @@ impl NativeCallable for Function {
view.force_frame = true; view.force_frame = true;
return Ok(NativeResult::Return(Value::Object(owner))); return Ok(NativeResult::Return(Value::Object(owner)));
} }
// 接收者可能是共享转发别名,解析到最终 Data 后修改副本,最后用一次 replace_payload 写入结构边。
let (data_ref, data) = block::resolve(cx.store(), owner)?; let (data_ref, data) = block::resolve(cx.store(), owner)?;
let mut data = data.clone(); let mut data = data.clone();
let mut result = Value::Nil; let mut result = Value::Nil;
@@ -626,6 +650,7 @@ impl NativeCallable for Function {
return Err(invalid("text exceeds 16 KiB")); return Err(invalid("text exceeds 16 KiB"));
} }
*value = next; *value = next;
// 直接 setText 取代旧 textOf 推导;同时更新普通 text 属性,使快照属性读取与 payload 描述保持一致。
data.text_binding = None; data.text_binding = None;
cx.set_attr(owner, "text", args[0].clone())?; cx.set_attr(owner, "text", args[0].clone())?;
} }
@@ -654,8 +679,10 @@ impl NativeCallable for Function {
(data.children.len(), args.first()) (data.children.len(), args.first())
}; };
let child = object(argument)?; let child = object(argument)?;
// 插入前禁止孩子图回到当前 Data,避免循环组成;position 是插入位置,返回值却是稳定 slot。
block::validate(cx.store(), child, Some(data_ref))?; block::validate(cx.store(), child, Some(data_ref))?;
let slot = data.next_slot; let slot = data.next_slot;
// slot 单调增加且限制到 i64 正范围,不复用已删除身份;前后插入/重排不会把其他孩子 slot 改号。
data.next_slot = slot data.next_slot = slot
.checked_add(1) .checked_add(1)
.filter(|next| *next <= i64::MAX as u64) .filter(|next| *next <= i64::MAX as u64)
@@ -663,6 +690,7 @@ impl NativeCallable for Function {
data.children.insert(position, Child { slot, block: child }); data.children.insert(position, Child { slot, block: child });
result = Value::Number((slot as i64).into()); result = Value::Number((slot as i64).into());
} else { } else {
// remove/replace/move 首参为稳定 slot 而非当前数组下标;只有 move 的第二参才是目标位置。
let slot = integer(args.first())?; let slot = integer(args.first())?;
let index = data let index = data
.children .children
@@ -676,6 +704,7 @@ impl NativeCallable for Function {
Replace => { Replace => {
let child = object(args.get(1))?; let child = object(args.get(1))?;
block::validate(cx.store(), child, Some(data_ref))?; block::validate(cx.store(), child, Some(data_ref))?;
// replace 保留该组成边 slot,但换实际 Block;出现键还含 Block 身份,因此可能产生新的 Mount 上下文。
data.children[index].block = child; data.children[index].block = child;
} }
Move => { Move => {
@@ -691,6 +720,7 @@ impl NativeCallable for Function {
} }
} }
} }
// children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。
Children => { Children => {
let result = cx.new_dict()?; let result = cx.new_dict()?;
for child in data.children { for child in data.children {
@@ -709,6 +739,7 @@ impl NativeCallable for Function {
let root = object(args.first())?; let root = object(args.first())?;
block::validate(cx.store(), root, Some(data_ref))?; block::validate(cx.store(), root, Some(data_ref))?;
if matches!(self.operation, Navigate) { if matches!(self.operation, Navigate) {
// 首次 navigate 把当前根纳入历史,之后新导航先截断前进分支,再追加新根,保持浏览历史语义。
if data.history.is_empty() { if data.history.is_empty() {
if let Some(old) = data.children.first() { if let Some(old) = data.children.first() {
data.history.push(old.block); data.history.push(old.block);
@@ -719,6 +750,7 @@ impl NativeCallable for Function {
data.history.push(root); data.history.push(root);
data.history_index = data.history.len() - 1; data.history_index = data.history.len() - 1;
} }
// 页面内容统一保留根 slot0,render/navigate 改的是根对象;show 只记录待展示请求,后续刷新再布局。
data.children = vec![Child { data.children = vec![Child {
slot: 0, slot: 0,
block: root, block: root,
@@ -730,6 +762,7 @@ impl NativeCallable for Function {
if data.history.is_empty() { if data.history.is_empty() {
return Ok(NativeResult::Return(Value::Nil)); return Ok(NativeResult::Return(Value::Nil));
} }
// 后退/前进钳到历史首尾,复用已保留根对象而非重新执行旧源码。
let next = if matches!(self.operation, Back) { let next = if matches!(self.operation, Back) {
data.history_index.saturating_sub(1) data.history_index.saturating_sub(1)
} else { } else {
@@ -742,6 +775,7 @@ impl NativeCallable for Function {
}]; }];
show(cx, owner, &self.session)?; show(cx, owner, &self.session)?;
} }
// 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。
Detach => { Detach => {
let window = { let window = {
let mut state = self let mut state = self
@@ -780,10 +814,12 @@ impl NativeCallable for Function {
show_in(cx, owner, &self.session, target)?; show_in(cx, owner, &self.session, target)?;
return Ok(NativeResult::Return(Value::Object(owner))); return Ok(NativeResult::Return(Value::Object(owner)));
} }
// unmount 清内容但不关闭 Page 标签,也不直接等同于销毁整个业务对象。
Unmount => { Unmount => {
data.children.clear(); data.children.clear();
} }
Close => { Close => {
// 关闭 Page 当场取消页面和所属 Mount 任务,即使同一输入稍后又打开它,也不能复活旧异步工作。
crate::tasks::cancel_owner(cx, owner)?; crate::tasks::cancel_owner(cx, owner)?;
let mut state = self let mut state = self
.session .session
@@ -828,6 +864,7 @@ impl NativeCallable for Function {
} }
let handler = object(args.get(1))?; let handler = object(args.get(1))?;
crate::event_dispatch::validate_handler(cx.store(), handler)?; crate::event_dispatch::validate_handler(cx.store(), handler)?;
// 订阅默认非捕获;可用 Bool 或 capture/bubble 文本明确阶段,其他值拒绝而不做真值转换。
let capture = match args.get(2) { let capture = match args.get(2) {
None | Some(Value::Bool(false)) => false, None | Some(Value::Bool(false)) => false,
Some(Value::Bool(true)) => true, Some(Value::Bool(true)) => true,
@@ -835,6 +872,7 @@ impl NativeCallable for Function {
Some(Value::Text(phase)) if phase == "bubble" => false, Some(Value::Text(phase)) if phase == "bubble" => false,
_ => return Err(invalid("phase must be capture or bubble")), _ => return Err(invalid("phase must be capture or bubble")),
}; };
// 清掉已断开连接后再计算容量,保留仍有效订阅顺序;新 Connection 拥有 handler,返回 token 仅弱引用连接。
let mut active = Vec::new(); let mut active = Vec::new();
for token in &data.subscriptions { for token in &data.subscriptions {
if cx if cx
@@ -862,6 +900,7 @@ impl NativeCallable for Function {
data.subscriptions = active; data.subscriptions = active;
result = Value::Object(token); result = Value::Object(token);
} }
// 基类事件/挂载回调默认空实现,子类可覆盖;不在这条默认路径生成额外状态。
OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)), OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)),
Click | Dispatch => { Click | Dispatch => {
let desktop = self let desktop = self
@@ -869,6 +908,7 @@ impl NativeCallable for Function {
.lock() .lock()
.map_err(|_| invalid("UI session poisoned"))? .map_err(|_| invalid("UI session poisoned"))?
.desktop; .desktop;
// click 新建种类为 click 的 Event,dispatch 使用调用者提供事件;两者都将可选具体 Mount 交传播模块验证。
let (event, mount) = if matches!(self.operation, Click) { let (event, mount) = if matches!(self.operation, Click) {
let definition = desktop_type(cx.store(), "Event", desktop)?; let definition = desktop_type(cx.store(), "Event", desktop)?;
( (
@@ -883,6 +923,7 @@ impl NativeCallable for Function {
} }
_ => return Err(invalid("unsupported Block operation")), _ => return Err(invalid("unsupported Block operation")),
} }
// 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。
cx.replace_payload(data_ref, data)?; cx.replace_payload(data_ref, data)?;
Ok(NativeResult::Return(result)) Ok(NativeResult::Return(result))
} }
+13 -2
View File
@@ -10,18 +10,20 @@ struct OpenTool {
ui: ObjectRef, ui: ObjectRef,
} }
impl NativeCallable for OpenTool { impl NativeCallable for OpenTool {
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。 /// 仅接受一个工具名称字符串,工具类型与白名单在 invoke 检查。
fn arity(&self) -> Option<endor_core::CallArity> { fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1)) Some(endor_core::CallArity::exact(1))
} }
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。 /// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Text(name)] = args else { let [Value::Text(name)] = args else {
return Err(invalid("developerTools requires a tool name")); return Err(invalid("developerTools requires a tool name"));
}; };
// 工具名白名单包含四个管理页和特殊新标签动作,拒绝任意字符串进入工厂/工具标记路径。
if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) { if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) {
return Err(invalid("unknown developer tool")); return Err(invalid("unknown developer tool"));
} }
// 新标签交给预编译 ui.newTab 托管工厂,经 NativeResult::Invoke 回到同一 VM 执行。
if name == "New tab" { if name == "New tab" {
let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?; let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?;
return Ok(NativeResult::Invoke { return Ok(NativeResult::Invoke {
@@ -29,6 +31,7 @@ impl NativeCallable for OpenTool {
arguments: vec![], arguments: vec![],
}); });
} }
// 锁内只复制弱节点索引,之后逐个升级并查工具标记,不持 session 锁运行 show 的流程。
let nodes = self let nodes = self
.session .session
.lock() .lock()
@@ -39,6 +42,7 @@ impl NativeCallable for OpenTool {
if let Some(page) = cx.upgrade(weak)? if let Some(page) = cx.upgrade(weak)?
&& cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone())) && cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone()))
{ {
// 已有同类工具页则激活并聚焦其所属原生窗口,不每次打开都创建重复 Page。
show(cx, page, &self.session)?; show(cx, page, &self.session)?;
let window = { let window = {
let state = self let state = self
@@ -56,6 +60,7 @@ impl NativeCallable for OpenTool {
return Ok(NativeResult::Return(Value::Object(page))); return Ok(NativeResult::Return(Value::Object(page)));
} }
} }
// 没有现存工具页才创建空托管 Page,_tesseraTool 标记让宿主在该页面位置绘制对应管理工具。
let page = new_typed_block( let page = new_typed_block(
cx, cx,
Data::new(Kind::Page, vec![], 0.0, 0.0), Data::new(Kind::Page, vec![], 0.0, 0.0),
@@ -78,6 +83,7 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
}, },
vec![ui], vec![ui],
))?; ))?;
// 函数 captures 保活 ui;安装属性建立拥有边后释放局部分配根,避免额外常驻根。
vm.set_attr(ui, "developerTools", Value::Object(function))?; vm.set_attr(ui, "developerTools", Value::Object(function))?;
vm.release(function)?; vm.release(function)?;
Ok(()) Ok(())
@@ -87,6 +93,7 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
/// Its functions/classes and callbacks remain ordinary managed VM objects. /// Its functions/classes and callbacks remain ordinary managed VM objects.
/// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。 /// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。
pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> { pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
// 源码随 binary 嵌入,每个 VM 安装时解析/编译一次;函数工厂之后重复执行不再重复编译模块。
let source = frontend::EndorReader::from_text( let source = frontend::EndorReader::from_text(
"tessera:start-page.endor", "tessera:start-page.endor",
include_str!("start_page.endor"), include_str!("start_page.endor"),
@@ -96,6 +103,7 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
&ast, &ast,
&source, &source,
&compiler::CompileOptions { &compiler::CompileOptions {
// 显式声明模块可见的 ui 与宿主类型名;私有 globals 不污染用户主会话的命名空间。
globals: std::iter::once("ui") globals: std::iter::once("ui")
.chain(TYPE_NAMES.iter().copied()) .chain(TYPE_NAMES.iter().copied())
.map(str::to_owned) .map(str::to_owned)
@@ -107,6 +115,7 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
let result = (|| -> Result<()> { let result = (|| -> Result<()> {
bridge.install_globals(vm, globals)?; bridge.install_globals(vm, globals)?;
let code = compiled.load(vm)?; let code = compiled.load(vm)?;
// 执行模块定义类和工厂,使用单次预算;这里不主动调用 newTab,不在安装时打开页面。
let value = vm.execute_with_limits( let value = vm.execute_with_limits(
code, code,
globals, globals,
@@ -117,12 +126,14 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
); );
vm.release(code)?; vm.release(code)?;
vm.release_value(value?)?; vm.release_value(value?)?;
// 要求模块导出 newTab,将函数保存到 ui 上;该函数通过托管引用维持自己的私有全局环境。
let factory = vm let factory = vm
.dict_get(globals, &Value::Text("newTab".into()))? .dict_get(globals, &Value::Text("newTab".into()))?
.ok_or_else(|| anyhow::anyhow!("start page module requires newTab"))?; .ok_or_else(|| anyhow::anyhow!("start page module requires newTab"))?;
vm.set_attr(bridge.ui, "newTab", factory)?; vm.set_attr(bridge.ui, "newTab", factory)?;
Ok(()) Ok(())
})(); })();
// 无论编译代码执行或导出查找成功与否,释放私有字典的局部根,再返回保存的处理结果。
vm.release(globals)?; vm.release(globals)?;
result result
} }

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