Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d1ffdbcb4 | |||
| 9caa94b562 | |||
| ec05424e22 | |||
| 2e905df467 | |||
| 0c12190e2d | |||
| 8a6bdc3790 | |||
| 94e0f96e4a | |||
| 40b43ac3c3 | |||
| c3e7d01c09 | |||
| ffc98fb657 | |||
| 3bf5a5415a | |||
| 831d013f75 | |||
| 435ec3b8be | |||
| c15a5e6729 | |||
| 3f47f76a8d | |||
| 5e26994569 | |||
| fe8ffa1a78 | |||
| 91801dc417 | |||
| b15d0872d7 | |||
| 2d5138c7ad | |||
| f0bfbc9fc1 | |||
| 3db8b13768 | |||
| cd0f292b78 | |||
| afff46c0a8 | |||
| 45b0b53d4f | |||
| 0c4bd06231 | |||
| ab3cd0c7cf | |||
| 8042d2efd4 | |||
| 723e7f1fbd | |||
| 373a95996c | |||
| b44d7833ea | |||
| 1f26349436 | |||
| edf513bff7 | |||
| f743c427f7 | |||
| b10a5b43d2 | |||
| f1e413c8c5 | |||
| a1c1781b00 | |||
| 0cd708f8c7 | |||
| 7f7464ae2b | |||
| 99ce4e3fc6 | |||
| ce17cb6ed3 | |||
| 9cdbf00961 | |||
| 07812bc47a | |||
| a0545e7e40 | |||
| 8a61d27938 | |||
| ca5bc863c1 | |||
| 0971d08e1b | |||
| b95912d840 | |||
| d8dc24da26 | |||
| a0ff16e23f | |||
| bb6e78ad04 | |||
| 50571e156e | |||
| c28c08a29a | |||
| 9517053a75 | |||
| 03241d4ac1 | |||
| cd323de3ac | |||
| ac6cc82b0e | |||
| b18d56c439 | |||
| 0431510d0e | |||
| 3442e3b3c2 | |||
| df7e1846b2 | |||
| 13a8b1b4b5 | |||
| 7a3eea3958 | |||
| f4ab4db475 | |||
| c67a205867 | |||
| f0a55e0f7a | |||
| 38e8bbc1e6 | |||
| dbf0bb5800 | |||
| 742fee4295 | |||
| d9c191ec00 | |||
| 3ed148c880 | |||
| 00514825a0 | |||
| 6f84d6574a | |||
| 91bd0d4c5a | |||
| ed1a9d2956 | |||
| e7f387ed9f | |||
| 21aa7b837b | |||
| fbc5bed19e | |||
| f00fd05754 | |||
| b0ceb83320 | |||
| f2f19ef87b | |||
| 09c1090339 | |||
| 2715c1ce2c | |||
| 45ff965b01 | |||
| e315172ce1 | |||
| cd23e70db1 | |||
| d29a1be329 | |||
| 723e41706b | |||
| caea8c62a2 | |||
| 0b1394e407 | |||
| 534f6e03c1 |
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 中。
|
||||||
|
|||||||
@@ -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→软件渲染快照,原生交互验证来自用户操作。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 场景交互性能研究
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
性能问题有可验证的解决路径,但单改 Aster 字典不能让当前世界 UI 达到稳定 60 FPS。本 PR 交付可重复基准和研究记录;去除字符串键比较临时分配的改动仍在独立 Aster 分支,未随本 PR 集成;面板纹理缓存实验因当前示例收益不足而撤回。没有实现 GPU 后端或宣称完成性能目标。
|
||||||
|
|
||||||
|
## 方法
|
||||||
|
|
||||||
|
在 Apple M4 Pro、macOS 15.6 本机使用 release 构建,固定 1024×800 逻辑视口、1× / 2× 像素比例,分别加载 world-ui 和 scene3d 示例。每组执行 33 次真实 scene-orbit 运行时分发,前 3 次作为预热,统计后 30 次的均值、P50、P95。每次先测分发到文档更新,再测变化后的软件绘制,最后测相同显示列表的缓存绘制。没有运行 Console 编译来代替原生事件。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
|
||||||
|
```
|
||||||
|
|
||||||
|
这是无窗口的运行时与软件渲染基准,包含回调、快照、布局和运行时收尾,但不包含本地 HTTP、OS 事件排队、窗口呈现或输入到显示延迟。阶段耗时不能简单当作实测 FPS。时间是本机样本,不是跨设备保证;基准默认 ignored,普通回归不以绝对耗时断言通过。
|
||||||
|
|
||||||
|
## 对照结果
|
||||||
|
|
||||||
|
世界 UI、2× 像素比例,单位 ms:
|
||||||
|
|
||||||
|
| 条件 | 更新均值 | 更新 P95 | 变化绘制均值 | 变化绘制 P95 |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| 原始 Aster | 8.456 | 8.843 | 18.704 | 20.742 |
|
||||||
|
| 借用字符串键比较 | 6.842 | 7.226 | 18.598 | 20.665 |
|
||||||
|
| 再加面板纹理缓存实验 | 6.823 | 7.053 | 18.410 | 20.482 |
|
||||||
|
|
||||||
|
去掉字符串键比较的分配后,本组更新均值减少约 19%,绘制基本不变。面板缓存的绘制收益约 1%,不足以证明值得在本次增加缓存与失效复杂度,因此撤回。
|
||||||
|
|
||||||
|
作为对照,普通 scene3d 示例在 2× 下更新约 0.94 ms、变化绘制约 6.15 ms;它的视口尺寸、覆盖像素和 UI 复杂度不同,不能据此把两个示例的全部差值都归给某一层。
|
||||||
|
|
||||||
|
## Aster 发现与保留的修改
|
||||||
|
|
||||||
|
`EndorDictObject` 用插入有序的数组存储,`dict_position` 逐项调用 `key_equal`。原实现每次比较都会把内联文本或托管 String 转成新的 String;查找名称时会重复分配和复制候选键。
|
||||||
|
|
||||||
|
本次只改为借用字符串切片比较。保留线性查找、原 key_equal 分支、对象检查、只读依赖观察、键的插入身份与顺序、写入验证和引用计数流程。新增测试覆盖空文本、普通文本、Unicode、托管 / 内联字符串等价替换、删除和引用释放。
|
||||||
|
|
||||||
|
没有把通用字典直接改成 HashMap,也没有绕过 heap.replace_payload 的事务性边验证。前者还需处理托管键相等 / 哈希与顺序语义;后者涉及 GC、失败原子性和只读保护。它们不属于本次已经证明安全的改动。
|
||||||
|
|
||||||
|
## 尚未解决的主要成本
|
||||||
|
|
||||||
|
1. 世界 UI 的摄像机变化后,当前统计仍为 measured=8、arranged=88;普通 3D 为 measured=0、arranged=14。数据证明相机更新没有彻底绕开排版。
|
||||||
|
2. 3D 变化帧仍在窗口线程逐像素进行 CPU 绘制。当前世界 UI 的绘制本身已超过 60 Hz 的 16.7 ms 预算,其他 CPU 优化不能消除这个事实。
|
||||||
|
3. 原生连续输入仍逐项经本地 HTTP 发送,有界队列不等于按显示帧合并;真实排队和呈现延迟需要另外测量。
|
||||||
|
|
||||||
|
## 后续实施顺序与条件
|
||||||
|
|
||||||
|
1. **相机更新专用路径。** 复用已有 Mount 几何、UI 排版和绘制结果,仅更新视图 / 投影、可见性和命中映射。必须检查源 measure / arrange / paint 的实际读取依赖;源函数若读取相机,应继续正确失效。不能无条件跳过所有布局或绘制。验收:不依赖相机的 UI 在旋转时零测量、零重新排版,依赖相机的源协议回归仍通过。
|
||||||
|
2. **按帧收集连续输入。** 原生操作直接进入进程内通道,连续手势按捕获会话 / 目标与顺序合并;点击、页面切换和运行时替换保持边界。外部 HTTP API 保留严格版本校验。验收包括缩放锚点、非交换手势顺序、嵌套捕获与过期会话,不能只检查最终数值。
|
||||||
|
3. **GPU 合成与独立渲染。** 现有不可变网格、变换、相机、深度和 UV 数据可以成为渲染输入;VM 对象留在运行时线程。GPU 应直接向窗口表面呈现,同时合成 2D / 3D 内容,避免每帧 GPU→CPU 回读后再交给 softbuffer。跨线程队列只保留最新可呈现快照;纹理、缓冲与缓存均需资源预算和正确失效。
|
||||||
|
|
||||||
|
建议验收目标:固定示例 2× 下相机更新 P95 ≤2 ms、GPU 绘制 P95 ≤8 ms,另外记录真实窗口的帧间隔和输入到呈现延迟。目标尚未达到,也没有用无窗口基准宣称原生 60 FPS。
|
||||||
|
|
||||||
|
## 检查说明
|
||||||
|
|
||||||
|
Aster 全工作区测试发现 compiler 的 classes 测试中两项解析失败(第三行 expected EOF),以及 core 的 managed_coroutine_hard_budget_accumulates_and_error_releases_frames 指令预算失败。将对象实现恢复为未优化的基线后,这三项失败均能复现,说明不是本次借用字符串比较引入;不把全工作区标为全绿。对象层测试通过,Aster 全工作区 Clippy / 格式通过;GUI 为 186 项通过、1 项默认忽略的基准,GUI Clippy / 格式通过。该基准已经另行在 release 模式实际执行。
|
||||||
|
|
||||||
|
本次没有修改用户指向的 builtins.rs 第 176 行(该处是函数对象定义)。实际热路径修改位于 Aster 的 store_builtins.rs。
|
||||||
|
|
||||||
|
## PR #6 主线兼容修复
|
||||||
|
|
||||||
|
2026-11-12:合入最新 GUI main,保留 main 的 Aster f3e1670,而非旧性能分支 fd247ac。旧引用缺少 GUI 目前使用的精确读取依赖接口,不能为性能实验回退。上文数值是原有实验记录,不代表当前主线已经采用借用键优化。本 PR 的最终差异仅包括本研究记录和默认 ignored 的基准;Aster 优化需单独更新其分支并审阅后再集成。PR 标题中的 WIP 将移除,交付范围为可运行基准,不承诺上述后续性能目标。
|
||||||
|
|
||||||
|
本次兼容修复检查:GUI `cargo test --locked` 为 186 passed / 1 ignored;Clippy(全部 target,警告视为错误)、格式和差异空白检查通过。最初沙箱内 9 项 HTTP 测试因不能监听本地端口失败;允许回环网络后全套重跑通过。本次未重跑历史 release 性能采样,也未修改 Aster。
|
||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -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
|
||||||
@@ -1,22 +1,39 @@
|
|||||||
|
// 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。
|
||||||
|
// owner/key 相同的新读取替换旧任务;provider 读取失败记录 Task.error、不执行 apply;apply 自身失败则遵循共享运行时停域规则。
|
||||||
|
// 持有三个显示对象与当前任务;task 初值占位,第一次 read 后替换为真实 Task。
|
||||||
class FileReader {
|
class FileReader {
|
||||||
|
// 这三个字段保存已构造的文字对象引用,回调更新同一业务对象,不创建一份新的页面或复制 Mount。
|
||||||
output: TextBlock
|
output: TextBlock
|
||||||
problem: TextBlock
|
problem: TextBlock
|
||||||
status: TextBlock
|
status: TextBlock
|
||||||
|
// 初始用通用 Object 占位;read 安装真实 Task 后可查询 status/error,result 需等待成功交付,apply 不会在占位阶段执行。
|
||||||
task: Object
|
task: Object
|
||||||
|
// 仅成功时调用,result 在成功回调期间已可读;写原 TextBlock 触发显示刷新。
|
||||||
function apply(self: FileReader): Unit {
|
function apply(self: FileReader): Unit {
|
||||||
|
// 任务调度器在 I/O 成功且仍有交付资格时调用绑定的 apply;self 是原 reader,调用者无需另外传参。
|
||||||
|
// 本例适用于不超过 TextBlock 16 KiB UTF-8 字节预算的文件;readText 自身允许至多 1 MiB,读取成功不保证这次控件赋值合法。
|
||||||
self.output.text = self.task.result()
|
self.output.text = self.task.result()
|
||||||
}
|
}
|
||||||
|
// 以 self 为 owner、file 为 key 启动读取,清空旧输出,并把状态/错误文字绑定到当前任务。
|
||||||
function read(self: FileReader, path: String): Unit {
|
function read(self: FileReader, path: String): Unit {
|
||||||
|
// path 是 Console 本次传入的路径;任务创建同步返回句柄,文件读取在后台,方法这里不会等文件内容。
|
||||||
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("尚未读取文件")
|
||||||
|
// 单独显示 Task.error;失败不会触发 apply,所以读不到新内容时也能通过绑定看到错误。
|
||||||
problem = TextBlock("")
|
problem = TextBlock("")
|
||||||
|
// 独立进度控件绑定到这次任务的方法,普通重绘不会重新提交 readText。
|
||||||
status = TextBlock("")
|
status = TextBlock("")
|
||||||
|
// 全局 reader 保留 owner 和三个显示对象;ui.state(0) 仅满足 Object 字段初值,不是文件结果。
|
||||||
reader = FileReader(output, problem, status, ui.state(0))
|
reader = FileReader(output, problem, status, ui.state(0))
|
||||||
|
// 页面只是展示输出,任务 owner 是 reader;关闭这个页面不等于直接取消 reader 下的任务。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "异步文件"
|
page.title = "异步文件"
|
||||||
page.render(Column(24,16,TextBlock("在 REPL 调用 reader.read(绝对路径字符串)",2),status,output,problem))
|
page.render(Column(24,16,TextBlock("在 REPL 调用 reader.read(绝对路径字符串)",2),status,output,problem))
|
||||||
|
|||||||
@@ -1,25 +1,42 @@
|
|||||||
|
// 异步 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
|
||||||
|
// 保存最新请求句柄,owner/key 替换后 apply 只读取当前任务;初始 Object 值尚不是可查询的 Task。
|
||||||
task: Object
|
task: Object
|
||||||
|
// 完整响应留在 VM 业务字段,TextBlock 只显示提示;外部读取和 GUI 排版的内容量分开。
|
||||||
response: String
|
response: String
|
||||||
|
// 成功后取出完整 UTF-8 结果,写 response 供 Console 查询;provider 失败不会进入此方法。
|
||||||
function apply(self: TextFetcher): Unit {
|
function apply(self: TextFetcher): Unit {
|
||||||
|
// 成功交付前调度器已写入 Task 结果;绑定 self 的零参回调在所属 VM 执行,不在网络线程写业务字段。
|
||||||
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 {
|
||||||
|
// url 来自显式 fetch 调用,HTTP(S) GET 由后台 provider 完成;新句柄返回不代表网络已完成。
|
||||||
self.task = ui.fetchText(self, "request", url, self.apply)
|
self.task = ui.fetchText(self, "request", url, self.apply)
|
||||||
|
// 只有 fetchText 成功创建新句柄才清旧结果;若 URL 校验/任务额度拒绝,上面的调用报错,下面不会执行。
|
||||||
|
// 同步清旧结果,避免新请求等待期间让 Console 把旧响应误认成新请求结果。
|
||||||
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("尚未请求网络")
|
||||||
|
// 错误单独绑定 Task.error,即使失败没有调用 apply 也能展示原因。
|
||||||
problem = TextBlock("")
|
problem = TextBlock("")
|
||||||
|
// 进度显示绑定任务状态,响应内容不拼接进这个控件,避免大正文参与每次布局。
|
||||||
status = TextBlock("")
|
status = TextBlock("")
|
||||||
|
// 全局业务入口让后续 REPL 复用同一 owner,因此多次 fetch 才会按 request 键替换。
|
||||||
fetcher = TextFetcher(output, problem, status, ui.state(0), "")
|
fetcher = TextFetcher(output, problem, status, ui.state(0), "")
|
||||||
|
// Page 展示与任务身份分开,任务归属 fetcher;保持该业务对象可达时,页面隐藏不自动取消它。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "异步网络"
|
page.title = "异步网络"
|
||||||
page.render(Column(24,16,TextBlock("在 REPL 调用 fetcher.fetch(HTTP 或 HTTPS 地址字符串)",2),status,problem,output))
|
page.render(Column(24,16,TextBlock("在 REPL 调用 fetcher.fetch(HTTP 或 HTTPS 地址字符串)",2),status,problem,output))
|
||||||
|
|||||||
@@ -1,25 +1,43 @@
|
|||||||
|
// 把 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
|
||||||
|
// workflow 句柄由 ui.spawn 返回,用于观察协作函数状态和最终返回值。
|
||||||
task: Object
|
task: Object
|
||||||
|
// provider 句柄由 ui.readText 返回,apply 的 await 等待它;两个字段不是同一个 Task 的别名。
|
||||||
file: Object
|
file: Object
|
||||||
response: String
|
response: String
|
||||||
|
// await 在文件未完成时挂起并保留 self/操作数;恢复后只执行后半段一次,再把字符串作为 workflow.result。
|
||||||
function apply(self: AwaitReader): String {
|
function apply(self: AwaitReader): String {
|
||||||
|
// 调度器启动 apply 后遇到未完成 file 会保留续体,后续 provider 就绪再恢复;成功结果才赋给 response。
|
||||||
|
// 依赖取消会取消等待流程;依赖失败在 await 处变成源错误,本例没有捕获逻辑,也不会执行后面的完成提示。
|
||||||
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()"
|
||||||
|
// 正常返回值成为 workflow.result(),与 file 的读取结果来源相同但属于另一个任务结果。
|
||||||
return self.response
|
return self.response
|
||||||
}
|
}
|
||||||
|
// 先创建读任务,再调度普通函数 apply;spawn 而非新 async 语法提供可暂停执行上下文。
|
||||||
function read(self: AwaitReader, path: String): Unit {
|
function read(self: AwaitReader, path: String): Unit {
|
||||||
|
// 先安装本轮文件任务,省略成功回调;它以可 await 的结果状态通知 workflow。
|
||||||
self.file = ui.readText(self, "file", path)
|
self.file = ui.readText(self, "file", path)
|
||||||
|
// 再安装 workflow,捕获的是绑定 self 的源方法;它在后续调度量子内推进,不在此处同步跑完 apply。
|
||||||
|
// file/workflow 分属两个 key;连续启动不是一笔原子事务,若 spawn 创建失败,前面已创建的文件任务不会自动撤回。
|
||||||
self.task = ui.spawn(self, "workflow", self.apply)
|
self.task = ui.spawn(self, "workflow", self.apply)
|
||||||
|
// 启动后清上次业务结果及显示提示,新的 await 恢复后才写入本轮文本。
|
||||||
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(绝对路径字符串)")
|
||||||
|
// 只展示协作 workflow 的状态,provider 自身状态可从 reader.file 单独查询。
|
||||||
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),"")
|
||||||
|
// 页面只投影 status/output,两个任务 owner 都是 reader,页面可见与否不是该普通 owner 的交付资格条件。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "await 文件"
|
page.title = "await 文件"
|
||||||
page.render(Column(24,16,status,output))
|
page.render(Column(24,16,status,output))
|
||||||
|
|||||||
@@ -1,38 +1,57 @@
|
|||||||
// 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)
|
||||||
|
// fill 是共享对象的布局策略,每次出现仍从各自父约束得到实际宽度;不把首次测量宽度保存回 shared。
|
||||||
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"
|
||||||
|
// 给父 Row 的分配加上下限/上限,min/max 是 border-box 约束,不是原生窗口宽度。
|
||||||
left.minWidth = 180
|
left.minWidth = 180
|
||||||
left.maxWidth = 360
|
left.maxWidth = 360
|
||||||
|
// 四边分别设置厚度,测量/安排时边框与 padding 占内容外空间;颜色只影响绘制。
|
||||||
left.borderLeft = 2
|
left.borderLeft = 2
|
||||||
left.borderTop = 2
|
left.borderTop = 2
|
||||||
left.borderRight = 2
|
left.borderRight = 2
|
||||||
left.borderBottom = 2
|
left.borderBottom = 2
|
||||||
left.borderColor = "#CBD5E1"
|
left.borderColor = "#CBD5E1"
|
||||||
left.paddingLeft = 24
|
left.paddingLeft = 24
|
||||||
|
// Column 的交叉轴为横轴,未自行覆盖对齐的孩子按 center 排;共享文字还有 fill 自身宽度策略。
|
||||||
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
|
||||||
|
// 这里 end 仅选择右容器内的交叉轴位置,不改变 shared 业务对象在左容器的对齐。
|
||||||
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
|
||||||
|
// Row 的交叉轴为纵轴,start 让左右两列按可用上侧对齐,列高仍来自各自文字换行测量。
|
||||||
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
|
||||||
|
// 子项显式对齐覆盖父级默认交叉轴对齐,margin 保留在边框盒外,不作为文字内容绘制。
|
||||||
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
|
||||||
|
// Overlay 的主轴对齐参与各子项在同一区域的分配,不像 Column 那样把 content 和 badge 顺序堆叠。
|
||||||
surface.mainAlign = "end"
|
surface.mainAlign = "end"
|
||||||
|
// overflow 策略由宿主投影成子裁剪,超出有限表面的文字/按钮不能仅凭原布局盒穿透命中。
|
||||||
surface.overflow = "clip"
|
surface.overflow = "clip"
|
||||||
|
|
||||||
|
// Page 只提供展示入口,根容器自身的盒模型规则不依赖页面导航方式。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "嵌套盒模型"
|
page.title = "嵌套盒模型"
|
||||||
|
// 顶层脚本执行到这里请求展示完整对象组合;布局驱动随后按出现约束求解,再发布不可变绘制帧。
|
||||||
page.render(surface)
|
page.render(surface)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// 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 {
|
||||||
|
// source_paint 为每个卡片出现调用;fill 是本轮新结果,允许写字段,不能在这里修改课程或作业模型。
|
||||||
fill = PaintCommand()
|
fill = PaintCommand()
|
||||||
fill.kind = "roundedRect"
|
fill.kind = "roundedRect"
|
||||||
fill.x = geometry.x
|
fill.x = geometry.x
|
||||||
@@ -14,19 +17,28 @@ class CampusPanel: Column {
|
|||||||
return DisplayList(ObjectList(fill))
|
return DisplayList(ObjectList(fill))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 通过额外 width 字段固定表格列的期望宽,保持成绩表头和行对齐。
|
||||||
class CampusCell: Column {
|
class CampusCell: Column {
|
||||||
|
// 这是源子类的期望宽度策略,不是某个 Mount 的最终宽度;相同列宽仍要服从父 Constraints。
|
||||||
width: Number
|
width: Number
|
||||||
|
// 先算子内容自然高度,再改新测量结果宽度,保留真实字体引起的高度。
|
||||||
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
|
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
|
||||||
|
// source_layout 先调用覆盖,再显式委托 Column 得到新 MeasureResult;只改返回结果,不写 content 的业务状态。
|
||||||
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 {
|
||||||
|
// 每个侧栏出现的只读测量入口;固定返回值仍受父约束处理,不是强制原生窗口至少 620 高。
|
||||||
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"
|
||||||
fill.x = geometry.x
|
fill.x = geometry.x
|
||||||
@@ -38,25 +50,33 @@ 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 {
|
||||||
|
// source_layout 调用此固定尺寸策略,实际测量结果仍由宿主验证,再交给父导航容器安排。
|
||||||
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 {
|
||||||
|
// 原生按钮生成本轮临时命令,at(0) 只取背景;下面 campusNav 替换的白字孩子由统一遍历画出。
|
||||||
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
|
||||||
fill.radius = 7
|
fill.radius = 7
|
||||||
return DisplayList(ObjectList(fill))
|
return DisplayList(ObjectList(fill))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 课程业务记录,grade/progress 被多个页面绑定读取,其余静态字段构建时直接读取。
|
||||||
class CampusCourse {
|
class CampusCourse {
|
||||||
|
// grade/progress 用 ui.textOf 注册实时依赖;其余字段传给 TextBlock 时只读取构造当时的值。
|
||||||
title: String
|
title: String
|
||||||
code: String
|
code: String
|
||||||
teacher: String
|
teacher: String
|
||||||
grade: String
|
grade: String
|
||||||
progress: String
|
progress: String
|
||||||
}
|
}
|
||||||
|
// 实验状态集中在一个对象,course 指向同一课程模型,评分因此能影响全站课程摘要。
|
||||||
class CampusWork {
|
class CampusWork {
|
||||||
status: String
|
status: String
|
||||||
score: String
|
score: String
|
||||||
@@ -65,13 +85,18 @@ 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 {
|
||||||
|
// campusSubmitButton 的 click 连接调用;没有文件选择或成绩计算,重复点击会再次写相同预设状态。
|
||||||
self.status = "已提交 · 等待评分"
|
self.status = "已提交 · 等待评分"
|
||||||
self.submission = "lab01.endor · 已提交示例内容"
|
self.submission = "lab01.endor · 已提交示例内容"
|
||||||
self.todo = "已完成本次实验提交"
|
self.todo = "已完成本次实验提交"
|
||||||
self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
|
self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
|
||||||
}
|
}
|
||||||
|
// 应用预设92分和反馈,并更新所属课程成绩/进度;绑定让仪表盘、课程、成绩页同步观察。
|
||||||
|
// 不要求先 submit,也不校验现有 status;再次 submit 只覆盖其写到的字段,不会自动清空旧 score/feedback。
|
||||||
function assess(self: CampusWork, event: Event): Unit {
|
function assess(self: CampusWork, event: Event): Unit {
|
||||||
|
// campusAssessButton 派发到这里,course 引用 campusProgramming;只改该课程,数学与英语保持原值。
|
||||||
self.status = "已评分"
|
self.status = "已评分"
|
||||||
self.score = "92 / 100"
|
self.score = "92 / 100"
|
||||||
self.feedback = "结构清晰,边界情况处理完整。建议补充函数说明。"
|
self.feedback = "结构清晰,边界情况处理完整。建议补充函数说明。"
|
||||||
@@ -81,7 +106,9 @@ 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 {
|
||||||
|
// campusResetButton 的事件入口恢复共享模型字段,不重新创建七页,不需要逐页重新注册文字绑定。
|
||||||
self.status = "未提交"
|
self.status = "未提交"
|
||||||
self.score = "— / 100"
|
self.score = "— / 100"
|
||||||
self.feedback = "提交后可在此查看教师反馈。"
|
self.feedback = "提交后可在此查看教师反馈。"
|
||||||
@@ -92,28 +119,40 @@ class CampusWork {
|
|||||||
self.result = "已恢复实验、课程成绩和学习进度的示例状态。"
|
self.result = "已恢复实验、课程成绩和学习进度的示例状态。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 用统一包装构造表格单元格;传入的content是已有Block引用。
|
||||||
function campusCell(content: Block, width: Number): Block {
|
function campusCell(content: Block, width: Number): Block {
|
||||||
|
// campusGradeRow 和成绩表头调用;width 放在原生 Column 的单个孩子之后,属于源字段而非额外孩子。
|
||||||
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表示在合适窗口打开/激活目标页。
|
||||||
|
// campusLink/campusNav 保存此绑定方法;点击时以已保存的真实 Page 为目标,不按标题字符串重新找页。
|
||||||
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 {
|
||||||
|
// 由卡片、课程正文和作业详情等组合函数调用,每次生成独立按钮/适配器,但复用传入 target 页身份。
|
||||||
button = Button(label)
|
button = Button(label)
|
||||||
action = CampusTarget(target)
|
action = CampusTarget(target)
|
||||||
|
// 连接持有绑定接收者所需引用,action 局部变量离开函数不代表回调丢失目标;调用方不需额外保存适配器。
|
||||||
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 {
|
||||||
|
// campusNavigation 调用,先用源字段构造导航按钮,再替换稳定槽 0 的文字,不把背景字符串误传成孩子。
|
||||||
button = CampusNav(label, background)
|
button = CampusNav(label, background)
|
||||||
button.replace(0, TextBlock(label, 1, "#FFFFFF"))
|
button.replace(0, TextBlock(label, 1, "#FFFFFF"))
|
||||||
action = CampusTarget(target)
|
action = CampusTarget(target)
|
||||||
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 {
|
||||||
|
// 顶层调用四次建立不同高亮版本;四个 Page 参数只提供导航目标,此函数不直接 open 或 render 它们。
|
||||||
return CampusSidebar(12, 10,
|
return CampusSidebar(12, 10,
|
||||||
TextBlock("Endor", 2, "#FFFFFF"),
|
TextBlock("Endor", 2, "#FFFFFF"),
|
||||||
TextBlock("Campus", 1, "#E7BAC0"), Spacer(16),
|
TextBlock("Campus", 1, "#E7BAC0"), Spacer(16),
|
||||||
@@ -125,7 +164,9 @@ 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 {
|
||||||
|
// 七个 render 的参数求值调用本函数,各自新建外层滚动对象;传入 navigation 可共享,偏移则按出现分别保存。
|
||||||
scroll = ScrollBlock(1120, 680,
|
scroll = ScrollBlock(1120, 680,
|
||||||
Row(24, 28, navigation,
|
Row(24, 28, navigation,
|
||||||
Column(0, 16,
|
Column(0, 16,
|
||||||
@@ -135,7 +176,9 @@ 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 {
|
||||||
|
// 仪表盘为三门课各调用一次;color 同时用于静态色条与成绩文字,grade 的实时值仍来自传入 course。
|
||||||
return CampusPanel(0, 0,
|
return CampusPanel(0, 0,
|
||||||
RectBlock(248, 44, 8, color),
|
RectBlock(248, 44, 8, color),
|
||||||
Column(16, 8,
|
Column(16, 8,
|
||||||
@@ -145,7 +188,9 @@ 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 {
|
||||||
|
// 成绩总览逐门课程调用,创建新的表格视觉行,不复制 CampusCourse;详情按钮保留对应课程 Page。
|
||||||
return Column(0, 12,
|
return Column(0, 12,
|
||||||
Row(0, 0,
|
Row(0, 0,
|
||||||
campusCell(TextBlock(course.title), 240),
|
campusCell(TextBlock(course.title), 240),
|
||||||
@@ -154,7 +199,9 @@ 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 {
|
||||||
|
// 三个课程页调用同一构造函数,各自传不同课程模型/静态单元文案;只有明确 textOf 的字段会随模型改动重算。
|
||||||
return Column(0, 20,
|
return Column(0, 20,
|
||||||
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
|
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
|
||||||
CampusPanel(20, 12,
|
CampusPanel(20, 12,
|
||||||
@@ -171,11 +218,14 @@ 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)
|
||||||
|
// course 只关联程序设计模型;课程标题/成绩等对象引用与页面资源随后通过各组合函数串成同一托管对象图。
|
||||||
|
// 先创建全部七个Page,允许导航按钮互相引用;随后逐一render建立内容。
|
||||||
campusDashboard = Page()
|
campusDashboard = Page()
|
||||||
campusDashboard.title = "Campus · 仪表盘"
|
campusDashboard.title = "Campus · 仪表盘"
|
||||||
campusProgrammingPage = Page()
|
campusProgrammingPage = Page()
|
||||||
@@ -190,10 +240,13 @@ campusGrades = Page()
|
|||||||
campusGrades.title = "Campus · 成绩总览"
|
campusGrades.title = "Campus · 成绩总览"
|
||||||
campusWorkPage = Page()
|
campusWorkPage = Page()
|
||||||
campusWorkPage.title = "实验 01 · 提交详情"
|
campusWorkPage.title = "实验 01 · 提交详情"
|
||||||
|
// 预建四种高亮导航树,课程三页共享course导航,Page切换时各自拥有独立Mount。
|
||||||
|
// 所有“课程”导航按钮的目标均为 campusProgrammingPage;数学/英语详情由各自课程卡或成绩链接进入,不按当前页动态改目标。
|
||||||
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 +259,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 +268,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 +282,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 +298,15 @@ 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 +324,6 @@ 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()
|
||||||
|
// 激活只请求展示已有仪表盘,保留其余六页及共享课程/实验对象;所有页面都没有独立数据库或独立 VM。
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
|
// 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。
|
||||||
|
// 两个 textOf 共享同一模型字段,修改一次业务值会更新两个展示位置。
|
||||||
|
// 定义业务状态和公共事件入口,Number/String 字段在构造与赋值时按声明契约检查。
|
||||||
class Counter {
|
class Counter {
|
||||||
|
// value 是两处 textOf 的读取目标,lastEvent 是第三处绑定的读取目标;它们都属于同一模型。
|
||||||
value: Number
|
value: Number
|
||||||
lastEvent: String
|
lastEvent: String
|
||||||
|
|
||||||
|
// 保存实际事件种类,再通过 self 调用可覆盖的方法;接收者保持具体子类身份。
|
||||||
function activate(self: Counter, event: Event): Unit {
|
function activate(self: Counter, event: Event): Unit {
|
||||||
|
// Button.onClick 派发传入本次 Event;先记录种类,再调用下面的动态业务方法。
|
||||||
self.lastEvent = event.kind
|
self.lastEvent = event.kind
|
||||||
self.change()
|
self.change()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 基类默认行为把值置为 10,子类可复用入口并替换这部分策略。
|
||||||
function change(self: Counter): Unit {
|
function change(self: Counter): Unit {
|
||||||
|
// 仅直接使用 Counter 或未覆盖 change 的子类才采用此默认值;本例 DemoCounter 会替换它。
|
||||||
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 {
|
||||||
|
// 本例从 Counter.activate 的 self.change() 进入这里,self 仍是 model,两个数值绑定一起失效。
|
||||||
self.value = 12
|
self.value = 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建唯一模型,之后所有绑定和按钮回调都指向它,无需把 GUI 状态复制到另一份存储。
|
||||||
model = DemoCounter(9, "等待点击")
|
model = DemoCounter(9, "等待点击")
|
||||||
|
// 两次调用各创建一个展示对象,但都读取 model.value;不是把初值 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")
|
||||||
|
// 构造按钮只建立对象,下一行 onClick 注册后才拥有修改模型的点击行为。
|
||||||
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))
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
|
// 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。
|
||||||
|
// 省略 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 {
|
||||||
|
// 字段保存 TaggedRect 的对象引用,recolor 的写入会被当前页面中该对象的绘制读取观察到。
|
||||||
rect: TaggedRect
|
rect: TaggedRect
|
||||||
|
// 写原对象的 color,挂载显示由依赖跟踪刷新,不需要重新构造整个页面。
|
||||||
|
// 由下面 Button.onClick 注册为零参绑定方法;不接收 Event,也不修改 tag。
|
||||||
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 后的字段")
|
||||||
|
// kind 是原生 Event 参数,第二个字符串才是 TaggedEvent 的源字段 tag。
|
||||||
event = TaggedEvent("click", "事件字段")
|
event = TaggedEvent("click", "事件字段")
|
||||||
|
// 前两个托管对象归入原生 ObjectList 成员,末尾字符串留给子类字段,不作为第三个列表成员。
|
||||||
|
// TaggedList.tag 是该列表实例的业务字段;它既不改变 items.count,也不会参与 rect 的容器布局。
|
||||||
items = TaggedList(rect, event, "列表成员之后的字段")
|
items = TaggedList(rect, event, "列表成员之后的字段")
|
||||||
actions = Actions(rect)
|
actions = Actions(rect)
|
||||||
button = Button("调用原对象方法")
|
button = Button("调用原对象方法")
|
||||||
|
// 这里只注册回调,不立即执行 recolor,因此首次显示仍使用矩形原生构造的默认颜色。
|
||||||
button.onClick(actions.recolor)
|
button.onClick(actions.recolor)
|
||||||
|
// Column 原生参数接收 padding/gap 和视觉孩子;末尾 tag 字符串不参与布局,textOf 则读取各对象的 tag。
|
||||||
root = TaggedColumn(24, 16, TextBlock("原生选项与子类字段分别检查", 2), rect, ui.textOf(rect, "tag"), ui.textOf(event, "tag"), ui.textOf(items, "tag"), button, "可变子项之后的字段")
|
root = TaggedColumn(24, 16, TextBlock("原生选项与子类字段分别检查", 2), rect, ui.textOf(rect, "tag"), ui.textOf(event, "tag"), ui.textOf(items, "tag"), button, "可变子项之后的字段")
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "构造器契约"
|
page.title = "构造器契约"
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
// 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。
|
||||||
|
// 这里刻意重复增量语句,方便观察推进;量子按 VM 指令计数,不保证每个 increment 或每组 16 条之后恰好暂停。
|
||||||
|
// 取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
|
||||||
|
// 每个实例有独立 steps 状态,两个任务不会把局部帧或计数存储混在一起。
|
||||||
class CooperativeWork {
|
class CooperativeWork {
|
||||||
|
// 保存 ui.state 返回的原生可变计数,每个 CooperativeWork 构造传入不同对象。
|
||||||
steps: Object
|
steps: Object
|
||||||
|
// 普通零参数方法被 spawn 调度后可跨量子继续;每条 increment 更新同一实例的计数。
|
||||||
|
// 连续语句无需手工 yield,VM 按累计预算暂停,恢复时不会从第一条重放。
|
||||||
function run(self: CooperativeWork): Unit {
|
function run(self: CooperativeWork): Unit {
|
||||||
|
// 第 1 至 16 次增量:由 spawn 的调用续体依次执行,每次原生 increment 完成后才进入下一条。
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
@@ -17,6 +25,7 @@ class CooperativeWork {
|
|||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
|
// 第 17 至 32 次增量:若此前在 VM 指令量子处让出,恢复后从保存的位置继续,不重跑前面的增量。
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
@@ -33,6 +42,7 @@ class CooperativeWork {
|
|||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
|
// 第 33 至 48 次增量:依然修改当前实例的 steps,另一个 owner 的 run 有独立调用帧与计数。
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
@@ -49,6 +59,7 @@ class CooperativeWork {
|
|||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
|
// 第 49 至 64 次增量:正常走完才结束任务;中途取消保留已发生修改,不把计数回滚为零。
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
@@ -67,14 +78,21 @@ class CooperativeWork {
|
|||||||
self.steps.increment()
|
self.steps.increment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 创建两个独立 owner,虽然 key 都叫 work,也不会相互替换;owner/key 组合才是任务身份。
|
||||||
left = CooperativeWork(ui.state(0))
|
left = CooperativeWork(ui.state(0))
|
||||||
|
// 左右实例共享类和方法定义,但这里分别分配 state,不能仅凭相同 work key 判断会相互取消。
|
||||||
right = CooperativeWork(ui.state(0))
|
right = CooperativeWork(ui.state(0))
|
||||||
|
// owner 是 left,方法是绑定 left 的 run;Task 句柄只用于状态/取消/结果查询,不是第二个业务计数对象。
|
||||||
leftTask = ui.spawn(left,"work",left.run)
|
leftTask = ui.spawn(left,"work",left.run)
|
||||||
|
// 同 key 配不同 owner 构成不同任务身份,worker 可以在两个续体之间按量子交替推进。
|
||||||
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("")
|
||||||
|
// 两份文字控件分别绑定各自 Task.status,完成时间和状态不会通过同一字段覆盖彼此。
|
||||||
rightStatus = TextBlock("")
|
rightStatus = TextBlock("")
|
||||||
leftStatus.bind("text",leftTask.status)
|
leftStatus.bind("text",leftTask.status)
|
||||||
rightStatus.bind("text",rightTask.status)
|
rightStatus.bind("text",rightTask.status)
|
||||||
|
// 仅展示任务状态,任务 owner 不是 Page;关闭页面不会仅因内容消失就撤掉这两个普通业务 owner 的任务。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "独立暂停调用"
|
page.title = "独立暂停调用"
|
||||||
page.render(Column(24,16,TextBlock("两个任务分别保存执行帧;在 REPL 查看 left.steps.value / right.steps.value",2),leftStatus,rightStatus))
|
page.render(Column(24,16,TextBlock("两个任务分别保存执行帧;在 REPL 查看 left.steps.value / right.steps.value",2),leftStatus,rightStatus))
|
||||||
|
|||||||
@@ -1,39 +1,59 @@
|
|||||||
|
// 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。
|
||||||
|
// EventLog 用一个递增序列记录实际次序;改变监听器/传播标记可对比跳过与停止的语义。
|
||||||
|
// 首行仅列本例的非空阶段;额外的目标 capture、旧式 onClick 及其他祖先处理仍由通用分发器决定。
|
||||||
|
// 保存各阶段序号与最近 phase;count 是共享状态,其他字段直接写在本模型中。
|
||||||
class EventLog {
|
class EventLog {
|
||||||
|
// count 保存跨阶段递增的总序列;四个 Step 保存各阶段最近一次序号,不是各自独立递增的计数器。
|
||||||
count: Object
|
count: Object
|
||||||
captureStep: Number
|
captureStep: Number
|
||||||
methodStep: Number
|
methodStep: Number
|
||||||
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 {
|
||||||
|
// 由 root 的 capture 连接调用,当前 event.phase 来自本次派发路径,不由业务模型自行推断。
|
||||||
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 {
|
||||||
|
// button 的普通连接在目标 onEvent 后调用,读取已被前面阶段推进的同一个 count。
|
||||||
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 {
|
||||||
|
// 由 root 的非捕获连接调用;本例不设置停止标记,因此点击目标后可继续冒泡到这里。
|
||||||
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 {
|
||||||
|
// 原生 Button 参数之外追加模型引用,onEvent 和三个监听器共同写入该模型。
|
||||||
model: EventLog
|
model: EventLog
|
||||||
|
// 把模型序列递增后写 methodStep,供断言与 capture/target/bubble 比较。
|
||||||
function onEvent(self: EventButton, event: Event): Unit {
|
function onEvent(self: EventButton, event: Event): Unit {
|
||||||
|
// 分发器查找对象方法后进入这里,无需再给此方法单独注册 on 连接;方法返回后继续目标监听器。
|
||||||
|
// 本覆盖不检查 event.kind;显式 on 连接只监听 click,而该对象方法也可能处理源码派发的其他事件。
|
||||||
self.model.count.increment()
|
self.model.count.increment()
|
||||||
self.model.methodStep = self.model.count.value
|
self.model.methodStep = self.model.count.value
|
||||||
self.model.phase = event.phase
|
self.model.phase = event.phase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
|
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
|
||||||
|
// 只在顶层创建一次日志,重复点击累计序列,四个 Step 会被各自最近一次阶段调用覆盖。
|
||||||
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)
|
||||||
|
// root 是目标的祖先;若把监听器放在另一无关容器,它不会仅因共享 model 而加入这条传播路径。
|
||||||
bubble = root.on("click", model.bubble)
|
bubble = root.on("click", model.bubble)
|
||||||
page = Page()
|
page = Page()
|
||||||
page.render(root)
|
page.render(root)
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
|
// 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。
|
||||||
|
// 退出先取消任务、关闭窗口,再进行允许的卸载回调;已停止 VM 的清理不能重新进入源代码。
|
||||||
|
// 在原生文字类型上增加共享日志,用来观察每个 Mount 的卸载通知。
|
||||||
class ExitText: TextBlock {
|
class ExitText: TextBlock {
|
||||||
|
// 业务对象共享同一日志引用,Mount 参数区分每次出现;不为每个窗口复制一份 ExitText.log。
|
||||||
log: Object
|
log: Object
|
||||||
|
// 每次真实卸载递增同一 log;普通重绘不调用,重复 shutdown 也不应重复调用。
|
||||||
|
// 宿主挂载生命周期在某出现实际卸载时调用,不由普通 draw/repaint 调用;当前方法不再创建窗口或新任务。
|
||||||
function onUnmount(self: ExitText, mount: Mount): Unit {
|
function onUnmount(self: ExitText, mount: Mount): Unit {
|
||||||
|
// 每次出现通知增加一次共享值,可在保留的另一个视图观察;本例不把 Mount 当作另一份业务状态。
|
||||||
self.log.increment()
|
self.log.increment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 全局持有日志,之后 watched 及 textOf 也引用它,关闭一个窗口不意味着日志立即失去业务所有者。
|
||||||
log = ui.state(0)
|
log = ui.state(0)
|
||||||
|
// 文字业务对象只创建一次,Page 与另一窗都引用它,但两边有各自的 Mount 生命周期。
|
||||||
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
|
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
|
||||||
|
// 主窗口 Page 持有组合根,watched 在组合中形成一次出现;另一页稍后再形成独立出现。
|
||||||
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")
|
||||||
|
// 独立 Page 使用同一 watched 引用,挂到第二窗口时创建自己的 Mount 和卸载通知路径。
|
||||||
other = Page()
|
other = Page()
|
||||||
other.render(watched)
|
other.render(watched)
|
||||||
|
// 移动的是 Page 的窗口归属,watched 的共享对象身份保持;不创建第二个 VM 运行同一源码。
|
||||||
other.moveToWindow(second)
|
other.moveToWindow(second)
|
||||||
|
// 应用级长定时任务用于观察退出会取消尚未完成的工作,不让退出过程等满 30 秒。
|
||||||
|
// sleep 不带回调,返回可等待 Task;即使代码保存 pending,全局退出仍先取消任务而不等待计时结束。
|
||||||
pending = ui.sleep(ui,"pending",30000)
|
pending = ui.sleep(ui,"pending",30000)
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
|
// 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。
|
||||||
|
// 绑定显示读取该字段,GUI 回调与 Console 赋值受同一契约约束。
|
||||||
|
// 不匹配的字段写入保留该字段旧值;这不表示整次回调或同一输入中较早的其他写入都会回滚。
|
||||||
|
// 增加 value:Number;构造时先传原生 text,再传源字段值。
|
||||||
class CounterText: TextBlock {
|
class CounterText: TextBlock {
|
||||||
|
// value 是源字段,不是 TextBlock 的原生 text;页面把两者作为不同内容分别展示。
|
||||||
value: Number
|
value: Number
|
||||||
|
// 收到 click 后把已有数值改为 10,依赖文字随之更新,不修改原生 text 内容。
|
||||||
function change(self: CounterText, event: Event): Unit {
|
function change(self: CounterText, event: Event): Unit {
|
||||||
|
// button.on("click", ...) 派发时提供 event;本例不读取事件载荷,只向受 Number 契约保护的字段写值。
|
||||||
self.value = 10
|
self.value = 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 声明 target 必须是 Block,可接收 CounterText,因为它是 TextBlock 的子类。
|
||||||
class Model {
|
class Model {
|
||||||
|
// 这里用父类型约束对象身份;不复制 counter,也不将它退化为只有 Block 字段的新对象。
|
||||||
target: Block
|
target: Block
|
||||||
}
|
}
|
||||||
|
// 构造唯一受检对象,Model、事件回调和 textOf 均引用它。
|
||||||
counter = CounterText("共享的原生子类对象", 9)
|
counter = CounterText("共享的原生子类对象", 9)
|
||||||
model = Model(counter)
|
model = Model(counter)
|
||||||
|
// model 仅演示构造时接受子类型;下面的回调和绑定直接引用 counter,不经过 model.target。
|
||||||
button = Button("把 Number 字段改成 10")
|
button = Button("把 Number 字段改成 10")
|
||||||
connection = button.on("click", counter.change)
|
connection = button.on("click", counter.change)
|
||||||
|
// 保留 connection 供后续管理订阅;回调的接收者已经绑定为 counter,页面只负责把它与数值绑定挂载出来。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "字段类型契约"
|
page.title = "字段类型契约"
|
||||||
page.render(Column(24, 16, counter, ui.textOf(counter, "value"), button, TextBlock("GUI 和源码写入同一个受检查的字段")))
|
page.render(Column(24, 16, counter, ui.textOf(counter, "value"), button, TextBlock("GUI 和源码写入同一个受检查的字段")))
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
|
// flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。
|
||||||
|
// 增长达到上限或收缩达到下限后,剩余空间/缺额重新分给其他尚可调整的项,固定项保持 60。
|
||||||
|
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
|
||||||
class Strip: Row {
|
class Strip: Row {
|
||||||
|
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
|
||||||
|
// source_layout 在该 Strip 出现的测量阶段调用,返回 MeasureResult 仍会按父传入 Constraints 钳制。
|
||||||
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 触顶后,多余增长转给第二项。
|
||||||
|
// 增长按钮的绑定回调,先复位首项下界/上界,再改 basis 与权重,最终布局在本次输入后的刷新中求解。
|
||||||
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
|
||||||
|
// basis 提供参与分配的起始主轴尺寸,与 RectBlock 构造时自然宽度不同。
|
||||||
self.first.flexBasis = 100
|
self.first.flexBasis = 100
|
||||||
self.second.flexBasis = 100
|
self.second.flexBasis = 100
|
||||||
self.first.flex = 1
|
self.first.flex = 1
|
||||||
|
// grow 不清除 shrink 曾设的 flexShrink=1;本例 basis 总量小于可用量,走增长分支,故不影响这里的预期。
|
||||||
self.second.flex = 3
|
self.second.flex = 3
|
||||||
}
|
}
|
||||||
|
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
|
||||||
|
// 收缩按钮回调复位增长上界并设置下界,两个 flex=0 不参与增长,但 flexShrink=1 仍允许按 basis 收缩。
|
||||||
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
|
||||||
@@ -21,20 +33,28 @@ class Actions {
|
|||||||
self.second.flexBasis = 200
|
self.second.flexBasis = 200
|
||||||
self.first.flex = 0
|
self.first.flex = 0
|
||||||
self.second.flex = 0
|
self.second.flex = 0
|
||||||
|
// 权重是 flexShrink×basis,本例 basis 同为 200,首项触底后剩余缺额继续转到第二项。
|
||||||
self.first.flexShrink = 1
|
self.first.flexShrink = 1
|
||||||
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")
|
||||||
|
// 两个可调对象分别用不同颜色展示,当前自然宽并不决定设置 flexBasis 后的起始分配。
|
||||||
second = RectBlock(40, 20, 4, "#A6D8B2")
|
second = RectBlock(40, 20, 4, "#A6D8B2")
|
||||||
|
// 未改 flex/basis/shrink 的固定项不承担本例缺额,保留 60 宽参与总宽计算。
|
||||||
fixed = RectBlock(60, 20, 4, "#E8C990")
|
fixed = RectBlock(60, 20, 4, "#E8C990")
|
||||||
actions = Actions(first, second)
|
actions = Actions(first, second)
|
||||||
grow = Button("增长:上限后重新分配")
|
grow = Button("增长:上限后重新分配")
|
||||||
|
// 注册零参源方法,由点击派发执行;下面显式 actions.grow() 才是在初次 render 前立即初始化。
|
||||||
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()
|
||||||
|
// 340 总宽减左右 padding 共 20、两个 gap 共 20 和固定项 60,留给两项的主轴总量是 240。
|
||||||
strip = Strip(10, 10, first, second, fixed)
|
strip = Strip(10, 10, first, second, fixed)
|
||||||
|
// 输出提示中的 105/135 或 150/90 基于 Strip 获得足够父约束的尺寸;实际几何仍由宿主约束校验。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "基础尺寸与边界"
|
page.title = "基础尺寸与边界"
|
||||||
page.render(Column(20, 12, Row(0, 10, grow, shrink), strip, TextBlock("增长:105 / 135 / 60;收缩:150 / 90 / 60。")))
|
page.render(Column(20, 12, Row(0, 10, grow, shrink), strip, TextBlock("增长:105 / 135 / 60;收缩:150 / 90 / 60。")))
|
||||||
|
|||||||
@@ -1,19 +1,36 @@
|
|||||||
|
// 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。
|
||||||
|
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
|
||||||
|
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
|
||||||
class Actions {
|
class Actions {
|
||||||
|
// Actions 只持有首排填充块引用,不持有共享段落或第二排的固定矩形,按钮修改的对象范围因此明确。
|
||||||
filler: RectBlock
|
filler: RectBlock
|
||||||
|
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
|
||||||
|
// Button.onClick 接受绑定后的零参回调,事件系统负责调用;这里改权重,不直接给 Mount 写最终几何。
|
||||||
function balanced(self: Actions): Unit { self.filler.flex = 1 }
|
function balanced(self: Actions): Unit { self.filler.flex = 1 }
|
||||||
|
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
|
||||||
|
// 同一父 Row 内 paragraph=1、filler=3 按剩余空间分配,父容器与子上限仍可进一步限制实际宽度。
|
||||||
function narrow(self: Actions): Unit { self.filler.flex = 3 }
|
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 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
|
||||||
|
// 原生 measure 取得段落自然/换行尺寸,原生 arrange 分配主轴空间,源布局驱动可为分配宽度再次测高。
|
||||||
paragraph.flex = 1
|
paragraph.flex = 1
|
||||||
|
// 自然尺寸 20×20 不等于 flex 后最终宽度;flex=1 让这个出现参与首排剩余空间分配。
|
||||||
filler = RectBlock(20, 20)
|
filler = RectBlock(20, 20)
|
||||||
filler.flex = 1
|
filler.flex = 1
|
||||||
|
// 绑定下面两个按钮的方法时捕获同一 Actions 实例,后续点击仍更新已挂载的 filler。
|
||||||
actions = Actions(filler)
|
actions = Actions(filler)
|
||||||
balanced = Button("等比例分配")
|
balanced = Button("等比例分配")
|
||||||
|
// 注册监听器而不立即执行 balanced,所以第一次布局沿用上面已经设好的初始权重。
|
||||||
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)
|
||||||
|
// 第二排的段落仍是同一业务对象,兄弟是未设置 flex 的固定宽矩形,分配宽度属于第二个出现。
|
||||||
second = Row(10, 10, paragraph, RectBlock(300, 20))
|
second = Row(10, 10, paragraph, RectBlock(300, 20))
|
||||||
|
// 同一个 Page 的根中同时包含两个段落 Mount,修改文本会影响两处,修改首排 filler 权重只改变首排约束。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "按权重分配与文字重排"
|
page.title = "按权重分配与文字重排"
|
||||||
page.render(Column(10, 10, Row(0, 10, balanced, narrow), first, second))
|
page.render(Column(10, 10, Row(0, 10, balanced, narrow), first, second))
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
// 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。
|
||||||
|
// 布局使用实际字形宽度,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")
|
||||||
|
// title/detail 是两个独立 TextBlock,字体系统在各自测量和绘制阶段使用一致的整形规则,颜色不参与字宽估算。
|
||||||
|
// 两组相同尺寸色块跟在窄/宽字符后,12px gap 保持一致,差异来自真实文字测量。
|
||||||
let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF"))
|
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()
|
||||||
|
// 整个脚本顶层构造完成后请求展示,没有点击回调或定时刷新;宿主布局负责把 row 中的真实测量宽度累加到色块位置。
|
||||||
page.render(ui.column(24, 12, title, detail, row))
|
page.render(ui.column(24, 12, title, detail, row))
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
|
// Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。
|
||||||
|
// 参数和返回注解在真实调用边界校验,事件注册同样使用这些签名。
|
||||||
|
// Number 输入原样返回,用于说明参数类型在进入函数体前检查。
|
||||||
function identity(value: Number): Number { return value }
|
function identity(value: Number): Number { return value }
|
||||||
|
// 接受 Function 值并零参数调用,结果仍需符合本函数的 Number 返回契约。
|
||||||
|
// 顶层 initial 的求值会调用它,callback 是已绑定 actions 的 read,调用时无需再传 self。
|
||||||
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 {
|
||||||
|
// Object 允许保存原生状态对象;实际 read/click 仍要求该对象提供 value/increment 成员。
|
||||||
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 与 textOf 持有同一状态,点击 increment 后依赖显示读取新的 value,而非重新计算 initial。
|
||||||
actions = Actions(state)
|
actions = Actions(state)
|
||||||
|
// 保存绑定方法对象,之后无参调用仍使用 actions 作为 self;不是重新按字符串查找对象。
|
||||||
savedFunction = actions.read
|
savedFunction = actions.read
|
||||||
initial = identity(callNumber(savedFunction))
|
initial = identity(callNumber(savedFunction))
|
||||||
|
// initial 只在顶层求值一次,未注册为响应式绑定,也未加入页面,因此后续点击不更新这个变量。
|
||||||
button = Button("调用带类型检查的对象方法")
|
button = Button("调用带类型检查的对象方法")
|
||||||
connection = button.on("click",actions.click)
|
connection = button.on("click",actions.click)
|
||||||
|
// Column 具备 Visual 能力且满足返回 Block 契约,visual 只验证并返回原对象,不额外包一层容器。
|
||||||
root = visual(Column(24,16,TextBlock("源码、原生与绑定方法统一为 Function"),ui.textOf(state,"value"),button))
|
root = visual(Column(24,16,TextBlock("源码、原生与绑定方法统一为 Function"),ui.textOf(state,"value"),button))
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "函数类型检查"
|
page.title = "函数类型检查"
|
||||||
|
|||||||
@@ -1,42 +1,59 @@
|
|||||||
// 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.value 后续保存 Task;它和 holder.value 保存的 Page 是两条需要显式清除的外部可达路径。
|
||||||
pending = ui.state(0)
|
pending = ui.state(0)
|
||||||
phase = ui.state(0)
|
phase = ui.state(0)
|
||||||
|
// 通过 peer 指回包含自己的 Page,配合等待协程形成包含视觉缓存读依赖的引用环。
|
||||||
class CycleRect: RectBlock {
|
class CycleRect: RectBlock {
|
||||||
|
// 指回 transient 页面;页面又通过 render 的根保留 rect,形成真实托管对象环。
|
||||||
peer: Object
|
peer: Object
|
||||||
|
// sleep 归属该 rect,await 保留协程帧;先写 phase=1,只有实际恢复后才写 2 并改 Page 标题。
|
||||||
function wait(self: CycleRect): Unit {
|
function wait(self: CycleRect): Unit {
|
||||||
|
// ui.spawn 调用绑定 wait,sleep 的 owner 仍是同一 rect,键 sleep 与外层 workflow 任务键不同。
|
||||||
gate = ui.sleep(self,"sleep",300000)
|
gate = ui.sleep(self,"sleep",300000)
|
||||||
phase.set(1)
|
phase.set(1)
|
||||||
|
// 在 gate 完成前挂起此调用,下面两条写入尚未执行;done 只是保存恢复结果,本例不读取它。
|
||||||
done = await gate
|
done = await gate
|
||||||
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 {
|
||||||
|
// source_layout 的只读测量调用;observed 虽不参与数值计算,成员访问仍是需要跟踪的实际读取。
|
||||||
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 {
|
||||||
|
// source_paint 的只读计算也读取环中页面;委托基类生成绘制数据,不把 Page 放进输出命令。
|
||||||
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。
|
||||||
|
// 按 mountCycle → 观察 → releaseCycle 的顺序使用;未释放前重复 mountCycle 会另建页面并覆盖 holder,不是重置原环。
|
||||||
function mountCycle(): Unit {
|
function mountCycle(): Unit {
|
||||||
|
// 供 Console 手工调用的零参入口,加载此文件只定义函数,不立即创建临时环或开始五分钟等待。
|
||||||
transient = Page()
|
transient = Page()
|
||||||
transient.title = "临时引用环"
|
transient.title = "临时引用环"
|
||||||
rect = CycleRect(180,40,8,"#3868D9",transient)
|
rect = CycleRect(180,40,8,"#3868D9",transient)
|
||||||
transient.render(rect)
|
transient.render(rect)
|
||||||
holder.set(transient)
|
holder.set(transient)
|
||||||
phase.set(0)
|
phase.set(0)
|
||||||
|
// spawn 返回工作流 Task 立即存入 pending,等待期间可经 pending.value.status() 观察,不能在此同步等完成。
|
||||||
pending.set(ui.spawn(rect,"workflow",rect.wait))
|
pending.set(ui.spawn(rect,"workflow",rect.wait))
|
||||||
}
|
}
|
||||||
|
// 先关闭临时页,随后去掉两个外部引用;等待依赖无需等满五分钟,失去可达性后应整体回收。
|
||||||
function releaseCycle(): Unit {
|
function releaseCycle(): Unit {
|
||||||
|
// 应在 mountCycle 建立 holder 后由 Console 调用;close 先解除展示关系,再清除外部 Page 与 Task 引用。
|
||||||
holder.value.close()
|
holder.value.close()
|
||||||
holder.set(0)
|
holder.set(0)
|
||||||
pending.set(0)
|
pending.set(0)
|
||||||
}
|
}
|
||||||
|
// 基线页面始终存在,用它比较操作前后的托管对象数,不把临时页面当作基线。
|
||||||
base = Page()
|
base = Page()
|
||||||
|
// base/holder/pending/phase 这些顶层对象仍保留,比较 GC 数量时不能要求整个会话回到零对象。
|
||||||
base.title = "GC 基线"
|
base.title = "GC 基线"
|
||||||
base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
|
base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
|
// 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。
|
||||||
|
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使此处读取失效。
|
||||||
|
// 这不排除视口、原生几何或其他被读取的协议依赖触发刷新;本例对比的是 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 {
|
||||||
|
// source_paint 在当前出现绘制时调用;基类用本轮 geometry 构造新的 DisplayList 和矩形命令。
|
||||||
drawing = RectBlock.paint(self,mount,geometry)
|
drawing = RectBlock.paint(self,mount,geometry)
|
||||||
|
// 右侧按全局名称读取 color,左侧只修改本次新结果;不是写 self.color,也不修改已有业务对象。
|
||||||
drawing.commands.at(0).color = color
|
drawing.commands.at(0).color = color
|
||||||
|
// 宿主解码校验返回命令后缓存绘制结果;缓存失效观察上面真实发生的全局 color 读取。
|
||||||
return drawing
|
return drawing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 显示固定大小矩形和操作提示,后续通过 Console 改同一个会话的 color。
|
||||||
rect = GlobalPaint(480,120,12,"#DDEEFF")
|
rect = GlobalPaint(480,120,12,"#DDEEFF")
|
||||||
|
// 构造颜色是原生载荷,但每轮源 paint 会用全局 color 覆盖返回命令,所以初次可见颜色来自 #ED6A5A。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "全局名字依赖"
|
page.title = "全局名字依赖"
|
||||||
page.render(Column(24,16,TextBlock("在 REPL 求值 42 不重绘;修改 color 会更新矩形颜色",2),rect))
|
page.render(Column(24,16,TextBlock("在 REPL 求值 42 不重绘;修改 color 会更新矩形颜色",2),rect))
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
function heading(value: Text): View {
|
function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。调用方为下面的 main,参数 value 是该次调用的 Text,不是可写全局状态。
|
||||||
return ui.text(value, 2, "#242422")
|
return ui.text(value, 2, "#242422") // 这里只创建文字对象,具体宽高由实际字体和出现位置约束测量。
|
||||||
}
|
}
|
||||||
|
|
||||||
function main(): View {
|
function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。endor_host 的模块执行路径在定义加载完成后调用这个零参数入口。
|
||||||
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 间隔决定最终命令位置;返回后宿主提取原生发布快照;Column 在 VM 中继续按托管引用拥有这些子对象。
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// All application state, motion, fire, hit reactions and scheduling live here.
|
||||||
|
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
|
||||||
|
// 整个文件作为一次源码输入加载:应用状态、跟随运动、火焰与定时重绘均由源方法实现,宿主提供通用图形与任务能力。
|
||||||
|
// graphics 是通用图形入口,math 提供数学/时钟函数;assets 为相对于运行工作目录的素材路径,不是网络地址。
|
||||||
|
g = ui.graphics
|
||||||
|
math = g.math
|
||||||
|
assets = "examples/assets/manuscript/"
|
||||||
|
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
|
||||||
|
// 同步 graphics.readText 先按 16 KiB UTF-8 资源预算读故事,再按 Unicode 标量索引截取;1000000 不会扩大读取预算。
|
||||||
|
// 两套字号/行距在顶层预整形,paint 按宽度选择;字体注册属于进程级文字引擎,不因这个 VM 关闭自动撤销。
|
||||||
|
story = g.textSlice(g.readText(assets + "story.txt"), 1, 1000000)
|
||||||
|
bodyText = g.prepareText(story, font, 21, 34, "#2A1A0A", 2)
|
||||||
|
smallText = g.prepareText(story, font, 16, 26, "#2A1A0A", 2)
|
||||||
|
hint = g.prepareText("Move the pointer. Hold to breathe fire.", font, 14, 22, "#705B44", 2)
|
||||||
|
|
||||||
|
// artwork 调用的精灵包装:image 是共享图片资源,返回新的绘制命令;位置/锚点/旋转不写回图片对象。
|
||||||
|
function sprite(image: Object, x: Number, y: Number, scale: Number, angle: Number, ax: Number, ay: Number): Object {
|
||||||
|
return g.sprite(image, x, y, image.width * scale, image.height * scale, angle, ax, ay, 1)
|
||||||
|
}
|
||||||
|
function wrappedAngle(angle: Number): Number {
|
||||||
|
// step 的姿态插值调用,把差值归一化到 [-π,π],跨越一圈边界时仍选择短方向;循环不修改传入业务姿态。
|
||||||
|
result = angle
|
||||||
|
// 大于正半圈就逐圈减去 2π,允许输入差值超过一圈,不只处理一次越界。
|
||||||
|
while result > math.pi { result = result - math.pi * 2 }
|
||||||
|
// 小于负半圈则反向逐圈加回,与上面共同覆盖任意有限角度差。
|
||||||
|
while result < -math.pi { result = result + math.pi * 2 }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
class Segment {
|
||||||
|
// 龙身体的单节状态,位置为手稿设计坐标,angle 为弧度;step 修改姿态,artwork 只读它生成本轮命令。
|
||||||
|
x: Number
|
||||||
|
y: Number
|
||||||
|
angle: Number
|
||||||
|
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 为火焰图片资源,axis 为该图片长轴方向校准,artwork 用速度方向减它以正确旋转素材。
|
||||||
|
image: Object
|
||||||
|
axis: Number
|
||||||
|
}
|
||||||
|
// 顶层 fireDirections 的轻量记录,loadFire 逐一取 angle,与 Layer 2..11 的图片顺序对应。
|
||||||
|
class Direction { angle: Number }
|
||||||
|
class Flame {
|
||||||
|
// x/y、vx/vy 为设计坐标中的粒子位置与每步速度;age 按 step 次数递增,size/art 控制绘制,不使用独立后台线程。
|
||||||
|
x: Number
|
||||||
|
y: Number
|
||||||
|
vx: Number
|
||||||
|
vy: Number
|
||||||
|
size: Number
|
||||||
|
age: Number
|
||||||
|
art: FireArt
|
||||||
|
}
|
||||||
|
class Dragon {
|
||||||
|
// 三个列表分别保存龙节、活跃粒子与校准素材;图片字段共享资源,目标/按压/时间/seed 则为可变应用状态。
|
||||||
|
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 {
|
||||||
|
// step 产生火焰时调用的确定性伪随机序列;先推进 seed,再取正弦混合值的小数部分,返回 [0,1)。
|
||||||
|
self.seed = self.seed + 1
|
||||||
|
value = math.sin(self.seed * 12.9898) * 43758.5453
|
||||||
|
return value - math.floor(value)
|
||||||
|
}
|
||||||
|
function pointer(self: Dragon, event: Event): Unit {
|
||||||
|
// pointermove 和 down 调用;event 为当前出现局部指针坐标,bounds 给出可用宽度,反变换与 paint 的缩放/留白保持一致。
|
||||||
|
bounds = event.mount.bounds()
|
||||||
|
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||||||
|
self.targetX = (event.x - (bounds.width - 700 * scale) / 2) / scale
|
||||||
|
self.targetY = (event.y - 30) / scale
|
||||||
|
self.lastMove = math.now()
|
||||||
|
}
|
||||||
|
function down(self: Dragon, event: Event): Unit {
|
||||||
|
// pointerdown 先更新目标和最近移动时刻,再开启喷火;这里只改状态,粒子生成留到定时 step。
|
||||||
|
self.pointer(event)
|
||||||
|
self.pressed = 1
|
||||||
|
}
|
||||||
|
// pointerup 关闭新粒子生成,已有火焰仍按后续 step 继续移动并到龄移除。
|
||||||
|
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
|
||||||
|
function leave(self: Dragon, event: Event): Unit {
|
||||||
|
// pointerleave/pointercancel 共用入口,清按压并把最近移动时间置为过期值,使龙回到待机姿态。
|
||||||
|
self.pressed = 0
|
||||||
|
self.lastMove = -10
|
||||||
|
}
|
||||||
|
function step(self: Dragon): Unit {
|
||||||
|
// Manuscript.tick 每次定时恢复调用一次;这是可写业务阶段,更新姿态/粒子供下一轮只读 paint 使用。
|
||||||
|
self.time = math.now()
|
||||||
|
// 超过两秒没有指针移动时,用平滑插值回到预设弧形,不继续追踪旧目标。
|
||||||
|
if self.time - self.lastMove > 2 {
|
||||||
|
// x/y 为本轮待机目标游标,i 从头节开始;float 明确提供可进行浮点运算的起始值。
|
||||||
|
x = math.float(150)
|
||||||
|
y = math.float(110)
|
||||||
|
i = 0
|
||||||
|
while i < self.segments.count {
|
||||||
|
// 顺序访问每一节,以索引定义弧度;每轮只修改这一节的姿态,再推进游标。
|
||||||
|
s = self.segments.at(i)
|
||||||
|
angle = -i / 19 * math.pi * 0.7
|
||||||
|
if i > 0 {
|
||||||
|
// 头部使用固定起点,后续节沿目标角度后退 30 设计单位,形成待机弧线。
|
||||||
|
x = x - math.cos(angle) * 30
|
||||||
|
y = y - math.sin(angle) * 30
|
||||||
|
}
|
||||||
|
s.x = s.x + (x - s.x) * 0.12
|
||||||
|
// 位置与角度都只靠近目标的 12%,wrappedAngle 防止跨 ±π 时绕远路。
|
||||||
|
s.y = s.y + (y - s.y) * 0.12
|
||||||
|
s.angle = s.angle + wrappedAngle(angle - s.angle) * 0.12
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 最近有输入时,头部追逐指针目标,其余节依次根据已经更新的前一节重建位置。
|
||||||
|
head = self.segments.at(0)
|
||||||
|
dx = self.targetX - head.x
|
||||||
|
dy = self.targetY - head.y
|
||||||
|
distance = math.sqrt(dx * dx + dy * dy)
|
||||||
|
if distance > 4 {
|
||||||
|
// 距离足够才归一化方向,避免近零除法和微小抖动;速度下限 12,但不会超过剩余距离。
|
||||||
|
speed = math.min(distance, math.max(12, distance * 0.15))
|
||||||
|
head.x = head.x + dx / distance * speed
|
||||||
|
head.y = head.y + dy / distance * speed
|
||||||
|
head.angle = math.atan2(dy, dx)
|
||||||
|
}
|
||||||
|
i = 1
|
||||||
|
while i < self.segments.count {
|
||||||
|
// 身体从第二节向尾部遍历,限制相对前节弯曲到 ±0.25 弧度,再保持 30 单位节距。
|
||||||
|
previous = self.segments.at(i - 1)
|
||||||
|
s = self.segments.at(i)
|
||||||
|
angle = math.atan2(previous.y - s.y, previous.x - s.x)
|
||||||
|
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
|
||||||
|
// 速度衰减模拟阻力,vy 再减 0.6 令屏幕坐标中的火焰向上飘;年龄是步数,不是秒。
|
||||||
|
p.vy = p.vy * 0.95 - 0.6
|
||||||
|
// 存活 12 步后删除,artwork 的透明度使用相同寿命常量,避免留下不可见粒子。
|
||||||
|
if p.age >= 12 { self.flames.remove(i) }
|
||||||
|
}
|
||||||
|
// 按压状态才发射;先更新/清理旧粒子,再检查总数上限。
|
||||||
|
if self.pressed > 0 {
|
||||||
|
head = self.segments.at(0)
|
||||||
|
i = 0
|
||||||
|
while i < 1 {
|
||||||
|
// 当前循环上界为 1,即每个 step 至多尝试产生一个粒子;并非一次生成十二个。
|
||||||
|
if self.flames.count < 12 {
|
||||||
|
// 仍有容量时随机扰动喷射方向/速度/大小,按素材列表长度选一个合法图片索引。
|
||||||
|
angle = head.angle + (self.random() - 0.5) * 0.25
|
||||||
|
speed = 20 + self.random() * 12
|
||||||
|
size = 12 + self.random() * 8
|
||||||
|
art = self.fireImages.at(math.floor(self.random() * self.fireImages.count))
|
||||||
|
// 从头部前方 30 单位发射,位置和速度都在设计坐标里;缩放只在 artwork 转绘制命令时应用。
|
||||||
|
self.flames.append(Flame(head.x + math.cos(head.angle) * 30, head.y + math.sin(head.angle) * 30, math.cos(angle) * speed, math.sin(angle) * speed, size, 0, art))
|
||||||
|
}
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
|
||||||
|
// Manuscript.paint 的只读调用,commands 是本次新列表;允许填写新命令,不能在这里推进 seed 或粒子年龄。
|
||||||
|
commands = ObjectList()
|
||||||
|
wing = self.segments.at(5)
|
||||||
|
commands.append(sprite(self.backWing, ox + wing.x * scale, oy + wing.y * scale, scale * 0.24, wing.angle + math.sin(self.time * 3) * 0.4, 1, 1))
|
||||||
|
// 后翼先画,再从尾到头画龙节,让头部覆盖后面的身体;索引 5 对应的前翼在该节之后追加形成前景层。
|
||||||
|
i = self.segments.count
|
||||||
|
while i > 0 {
|
||||||
|
// 与 step 的头到尾更新顺序不同,这里反向遍历仅为控制绘制遮盖,不改 segments 顺序。
|
||||||
|
i = i - 1
|
||||||
|
s = self.segments.at(i)
|
||||||
|
ax = math.float(0.5)
|
||||||
|
if i == 0 {
|
||||||
|
// 头节使用校准锚点,先追加舌头再追加头图,舌根被头部覆盖。
|
||||||
|
ax = 0.45
|
||||||
|
commands.append(sprite(self.tongue, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, -s.image.width * 0.3 / self.tongue.width, 0.5))
|
||||||
|
}
|
||||||
|
commands.append(sprite(s.image, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, ax, 0.5))
|
||||||
|
// 前翼只在固定身体节追加,时间正弦控制摆动;图像资源自身仍不发生旋转字段写入。
|
||||||
|
if i == 5 { commands.append(sprite(self.frontWing, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle - math.sin(self.time * 3 + 0.5) * 0.4, 1, 1)) }
|
||||||
|
}
|
||||||
|
i = 0
|
||||||
|
while i < self.flames.count {
|
||||||
|
// 每个活跃粒子产生一条新精灵命令;按图片最大边等比缩放,以粒子速度角减素材轴角校准朝向。
|
||||||
|
p = self.flames.at(i)
|
||||||
|
image = p.art.image
|
||||||
|
imageScale = p.size * scale / math.max(image.width, image.height)
|
||||||
|
angle = math.atan2(p.vy, p.vx) - p.art.axis
|
||||||
|
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
|
||||||
|
drawing.opacity = 1 - p.age / 12
|
||||||
|
// 随年龄分别跨过两个色调阈值,第二个 if 可覆盖第一个 tint;二者不是互斥分支。
|
||||||
|
if p.age > 4 { drawing.tint = "#E08A30" }
|
||||||
|
if p.age > 8 { drawing.tint = "#F0C030" }
|
||||||
|
commands.append(drawing)
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
|
||||||
|
// paint 只把本轮 flowText 新命令传入;允许改这些临时字形命令,不改 prepared 资源或 Dragon 原状态。
|
||||||
|
i = 0
|
||||||
|
while i < commands.count {
|
||||||
|
// 外循环逐字形命令处理,内循环逐火焰累加热扰动,多个粒子可共同影响同一字形。
|
||||||
|
c = commands.at(i)
|
||||||
|
j = 0
|
||||||
|
while j < self.flames.count {
|
||||||
|
p = self.flames.at(j)
|
||||||
|
dx = c.x - ox - p.x * scale
|
||||||
|
if math.abs(dx) < 60 * scale {
|
||||||
|
// 先做横轴范围排除,命中后才计算纵轴差,减少远处粒子的距离计算。
|
||||||
|
dy = c.y - oy - p.y * scale
|
||||||
|
if math.abs(dy) < 60 * scale {
|
||||||
|
// 通过轴向粗筛后求欧氏距离,以下两个条件分别排除近零除法和圆形作用范围外区域。
|
||||||
|
distance = math.sqrt(dx * dx + dy * dy)
|
||||||
|
if distance > 0.1 {
|
||||||
|
if distance < 60 * scale {
|
||||||
|
// 距离越近、粒子越年轻权重越大;位置向外推开,轻微转动并降低透明度但至少保留 0.3。
|
||||||
|
weight = (1 - distance / (60 * scale)) * (1 - p.age / 12)
|
||||||
|
c.x = c.x + dx / distance * weight * 8 * scale
|
||||||
|
c.y = c.y + dy / distance * weight * 8 * scale
|
||||||
|
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 保存动态业务状态;page 是动画任务 owner;prepared/small/initial 是共享资源,timer 保存最近一次 after 返回的 Task。
|
||||||
|
dragon: Dragon
|
||||||
|
page: Object
|
||||||
|
prepared: Object
|
||||||
|
small: Object
|
||||||
|
initial: Object
|
||||||
|
timer: Object
|
||||||
|
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
|
||||||
|
// source_layout 调用的只读测量,给出不超过 1000×900 的期望大小,宿主仍按完整 Constraints 校验/钳制。
|
||||||
|
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
|
||||||
|
}
|
||||||
|
function onMount(self: Manuscript, mount: Mount): Unit {
|
||||||
|
// 宿主挂载通知启动首个 80ms 单次任务,任务归 page、键为 animation;不会在 paint 中调度副作用。
|
||||||
|
// 本例 view 只有一个有效出现;若复用它产生多个 Mount,仍共用同一 page/key 和 timer 字段,不会各自拥有独立动画时钟。
|
||||||
|
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||||||
|
}
|
||||||
|
// 宿主卸载通知取消最近安排的任务,防止隐藏/退役视图继续无意义地推进动画。
|
||||||
|
function onUnmount(self: Manuscript, mount: Mount): Unit { self.timer.cancel() }
|
||||||
|
function tick(self: Manuscript): Unit {
|
||||||
|
// after 到期且获得交付后调用 tick,先改龙状态,再安排下一次 80ms 单次任务;不是固定帧率或补齐错过帧。
|
||||||
|
// 位置插值与粒子年龄按实际 step 次数更新;worker 忙时回调延后,不按经过秒数补做相同数量的步。
|
||||||
|
self.dragon.step()
|
||||||
|
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||||||
|
}
|
||||||
|
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
|
||||||
|
// source_paint 用本轮出现 Bounds 调用,设计画布宽 700,缩放限制为 0.2..1,ox/oy 把设计坐标映射到布局坐标。
|
||||||
|
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||||||
|
ox = bounds.x + (bounds.width - 700 * scale) / 2
|
||||||
|
oy = bounds.y + 30
|
||||||
|
margin = 45 * scale
|
||||||
|
text = self.prepared
|
||||||
|
line = 34
|
||||||
|
// 窄于 540 时切到预整形的小字和 26 行距,否则用正文字体与 34 行距,不改任何资源对象。
|
||||||
|
if bounds.width < 540 {
|
||||||
|
text = self.small
|
||||||
|
line = 26
|
||||||
|
}
|
||||||
|
initialHeight = line * 7
|
||||||
|
// 首字母图高占七行,宽按素材比例计算;单独矩形排除区给正文留下首字母和右侧间距。
|
||||||
|
initialWidth = self.initial.width / self.initial.height * initialHeight
|
||||||
|
art = self.dragon.artwork(ox, oy, scale)
|
||||||
|
exclusions = ObjectList(g.rectExclusion(ox + margin, oy + margin, initialWidth + 12, initialHeight))
|
||||||
|
i = 0
|
||||||
|
while i < art.count {
|
||||||
|
// 每条龙/火焰精灵按图像形状生成绕排排除区,额外留白随画布缩放,不只用整体外接矩形挡文字。
|
||||||
|
exclusions.append(g.imageExclusion(art.at(i), 6 * scale))
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
letters = 0
|
||||||
|
// 有火焰时请求可逐字母扰动的输出;无火焰时使用普通输出,避免总是拆成大量命令。
|
||||||
|
if self.dragon.flames.count > 0 { letters = 1 }
|
||||||
|
// 正文在给定矩形内绕开首字母与所有精灵;可用高度至少为零,避免小窗口产生负尺寸。
|
||||||
|
drawing = g.flowText(text, ox + margin, oy + margin, 700 * scale - margin * 2, math.max(0, bounds.height - 90 - margin), exclusions, letters)
|
||||||
|
// 只对新生成的正文命令应用热扰动,原文字符串和预整形资源保持原值。
|
||||||
|
if self.dragon.flames.count > 0 { self.dragon.heat(drawing.commands, ox, oy, scale) }
|
||||||
|
// 命令层次为矩形底色 → 首字母 → 正文 → 龙/火焰 → 页脚,列表追加顺序就是绘制顺序。
|
||||||
|
commands = RectBlock.paint(self, mount, bounds).commands
|
||||||
|
commands.append(g.sprite(self.initial, ox + margin, oy + margin, initialWidth, initialHeight, 0, 0, 0, 1))
|
||||||
|
commands.extend(drawing.commands)
|
||||||
|
commands.extend(art)
|
||||||
|
footer = g.flowText(hint, bounds.x + 20, bounds.y + bounds.height - 28, math.max(0, bounds.width - 40), 24, ObjectList(), 0)
|
||||||
|
// 页脚不使用正文排除区,也不应用 heat;把这批命令接到末尾再交宿主统一验证和绘制。
|
||||||
|
commands.extend(footer.commands)
|
||||||
|
return DisplayList(commands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶层先创建头节,再调用 makeBody 添加十九个身体节;图片只在加载示例时读取,不在动画步中加载。
|
||||||
|
segments = ObjectList()
|
||||||
|
headImage = g.loadImage(assets + "dragon-sprites/head.png")
|
||||||
|
segments.append(Segment(math.float(150), math.float(110), math.float(0), headImage))
|
||||||
|
function makeBody(): Unit {
|
||||||
|
// 顶层唯一一次调用,游标沿与待机姿态相同的弧线前进;i=1..19 对应 body-1.png 到 body-19.png。
|
||||||
|
i = 1
|
||||||
|
x = math.float(150)
|
||||||
|
y = math.float(110)
|
||||||
|
while i < 20 {
|
||||||
|
// 计算当前节相对于前节的后退位置,加载对应素材并保留在 Segment,随后递增索引。
|
||||||
|
angle = -i / 19 * math.pi * 0.7
|
||||||
|
x = x - math.cos(angle) * 30
|
||||||
|
y = y - math.sin(angle) * 30
|
||||||
|
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 {
|
||||||
|
// 顶层调用一次,按图片编号 2..11 加载十张火焰;校准表索引用 i-2 转成从零开始。
|
||||||
|
i = 2
|
||||||
|
while i <= 11 {
|
||||||
|
// FireArt 同时保留图片和其自身轴角,之后随机选择整个记录,避免图片和校准值错位。
|
||||||
|
image = g.loadImage(assets + "fire-sprites/Layer " + g.string(i) + ".png")
|
||||||
|
fireImages.append(FireArt(image, fireDirections.at(i - 2).angle))
|
||||||
|
i = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadFire()
|
||||||
|
// 构造唯一 Dragon,空 flames 列表稍后由 step 填充;初始 lastMove=-10 进入待机路径,seed=42 固定伪随机起点。
|
||||||
|
dragon = Dragon(segments, ObjectList(), fireImages, g.loadImage(assets + "dragon-sprites/tongue.png"), g.loadImage(assets + "dragon-sprites/wing-front.png"), g.loadImage(assets + "dragon-sprites/wing-back.png"), math.float(150), math.float(110), 0, math.now(), -10, math.float(42))
|
||||||
|
page = Page()
|
||||||
|
page.title = "Illustrated Manuscript · Endor"
|
||||||
|
view = Manuscript(700, 800, 0, "#F4EEE0", dragon, page, bodyText, smallText, g.loadImage(assets + "dropcap.png"), ObjectList())
|
||||||
|
// 原生 RectBlock 构造参数之后追加六个源字段;timer 的临时初值在挂载通知中换成真正 Task,随后才能 cancel。
|
||||||
|
view.flex = 1
|
||||||
|
view.boxWidth = "fill"
|
||||||
|
page.crossAlign = "stretch"
|
||||||
|
// 五个连接共用 dragon 的绑定方法,取消与离开走同一清理入口;只注册输入,不在顶层启动 step 循环。
|
||||||
|
moveConnection = view.on("pointermove", dragon.pointer)
|
||||||
|
downConnection = view.on("pointerdown", dragon.down)
|
||||||
|
upConnection = view.on("pointerup", dragon.up)
|
||||||
|
leaveConnection = view.on("pointerleave", dragon.leave)
|
||||||
|
cancelConnection = view.on("pointercancel", dragon.leave)
|
||||||
|
// render 后建立出现,onMount 才启动动画任务;paint 由宿主刷新调用,tick 的状态变化使后续帧重新计算。
|
||||||
|
page.render(view)
|
||||||
@@ -1,40 +1,62 @@
|
|||||||
|
// 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。
|
||||||
|
// ViewportBand 还显式读取实际页面视口,使原生 resize 可通过依赖触发重测。
|
||||||
|
// 只负责测量需要的宽度,和绘制颜色分成两个业务对象以便观察独立失效。
|
||||||
class WidthModel {
|
class WidthModel {
|
||||||
width: Number
|
width: Number
|
||||||
|
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
|
||||||
|
// 按钮 click 的 Event 回调,只写 WidthModel.width;后续是否重测由已记录的真实读取失效决定。
|
||||||
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
|
||||||
|
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
|
||||||
|
// 另一按钮的 Event 回调只写 Tint.color,不改 dimensions,避免用无关模型写入混淆测量依赖。
|
||||||
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 {
|
||||||
|
// CachedRect 引用两个不同策略对象;measure 读尺寸对象,paint 读颜色对象,各阶段依赖分开记录。
|
||||||
dimensions: WidthModel
|
dimensions: WidthModel
|
||||||
tint: Tint
|
tint: Tint
|
||||||
|
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
|
||||||
|
// 只读测量由 source_layout 调用,self 是共享矩形,mount/constraints 是当前出现输入,不写 geometry。
|
||||||
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 {
|
||||||
|
// 源 paint 由 source_paint 调用,显式委托基类得到本轮新 DisplayList,不在这里发输入事件或改变 Page。
|
||||||
drawing = RectBlock.paint(self, mount, geometry)
|
drawing = RectBlock.paint(self, mount, geometry)
|
||||||
|
// 修改的是新分配命令的颜色字段,再由宿主统一解码校验;self.tint 仍只读。
|
||||||
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 会减少分配宽度。
|
||||||
|
// ui.viewport 取当前窗口逻辑页面范围,实际返回尺寸还会受父级更窄的 Constraints 约束。
|
||||||
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")
|
||||||
|
// 源字段 dimensions/tint 放在原生构造参数后,RectBlock 的默认尺寸不是这个覆盖 measure 的最终输出。
|
||||||
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
|
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
|
||||||
band = ViewportBand(80, 24, 0, "#237A45")
|
band = ViewportBand(80, 24, 0, "#237A45")
|
||||||
blue = Button("只改绘制颜色")
|
blue = Button("只改绘制颜色")
|
||||||
expand = Button("修改测量依赖")
|
expand = Button("修改测量依赖")
|
||||||
|
// 保存两个监听连接,颜色按钮绑定 tint,宽度按钮绑定 dimensions,不把 UI 事件对象用作缓存 owner。
|
||||||
blueConnection = blue.on("click", tint.blue)
|
blueConnection = blue.on("click", tint.blue)
|
||||||
expandConnection = expand.on("click", dimensions.expand)
|
expandConnection = expand.on("click", dimensions.expand)
|
||||||
|
// 根组合同时展示 viewport 依赖和业务尺寸依赖;按钮回调完成后宿主统一刷新,不在点击方法里手工重排。
|
||||||
root = Column(24, 16, TextBlock("改变颜色保留测量缓存;改变宽度重新测量"), band, rect, blue, expand)
|
root = Column(24, 16, TextBlock("改变颜色保留测量缓存;改变宽度重新测量"), band, rect, blue, expand)
|
||||||
page = Page()
|
page = Page()
|
||||||
page.render(root)
|
page.render(root)
|
||||||
|
|||||||
@@ -1,35 +1,58 @@
|
|||||||
|
// 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。
|
||||||
|
// paragraph 被两个容器共享;每个 Mount 使用独立约束,不能把新宽度写回共享文字状态。
|
||||||
|
// 保存第一个段落出现位置的分配宽度,按钮只改策略值。
|
||||||
class Policy {
|
class Policy {
|
||||||
width: Number
|
width: Number
|
||||||
|
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
|
||||||
|
// narrow/wide 按钮事件回调更新同一 policy.width,只有持有该策略的 ArrangedColumn 读取它。
|
||||||
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 {
|
||||||
|
// 布局共享策略模型,不保存段落的全局最终宽度;某个出现的约束/分配由 Plan 独立记录。
|
||||||
policy: Policy
|
policy: Policy
|
||||||
|
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
|
||||||
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
|
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
|
||||||
|
// source_layout 的安排阶段调用覆盖方法,基类利用本轮孩子测量值生成包含全部稳定 slot 的临时结果。
|
||||||
result = Column.arrange(self, mount, geometry)
|
result = Column.arrange(self, mount, geometry)
|
||||||
|
// 只改返回的第一子槽 Bounds.width,宿主会验证槽完整性并据显式分配反推下一轮精确测量约束。
|
||||||
|
// at(0) 只用于挑选返回列表中的首项;该 Bounds 原有的稳定 slot 保持不变,宿主按 slot 而非列表下标关联孩子。
|
||||||
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 {
|
||||||
|
// MeasuredColumn 自有策略字段,不是共享 paragraph 的 boxWidth,也不复制段落内容。
|
||||||
width: Number
|
width: Number
|
||||||
|
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
|
||||||
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
|
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
|
||||||
|
// 测量阶段先委托基类求自然尺寸,再修改临时 MeasureResult;已有业务对象仍受只读限制。
|
||||||
result = Column.measure(self, mount, constraints)
|
result = Column.measure(self, mount, constraints)
|
||||||
|
// 宿主读取这个新结果字段后还会按 Constraints 钳制;源返回 width 不等于绕过上层几何预算。
|
||||||
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("这一行跟随上方段落。")
|
||||||
|
// 首个孩子是 paragraph,故 at(0) 修改的是它的分配;tail 的位置在重新测高与收敛后随段落高度更新。
|
||||||
arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
|
arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
|
||||||
|
// 另一出现仍用同一 paragraph,但父测量策略为 360,与上面的 policy.width 独立。
|
||||||
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("测量与排列直到稳定")
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
// 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。
|
||||||
|
// 返回 Column 视觉对象,宿主选择/挂载页面;这里的 title 和 rectangle 只在 VM 内建立业务引用。
|
||||||
|
// endor_script 将本文件作为完整模块解析,endor_host 调用零参数 main 后把返回的视觉根交给兼容展示路径。
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
// 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。
|
||||||
|
// 两个文字绑定读同一 counter;原生点击和 Console 都能操作该对象并传播显示变化。
|
||||||
|
// state 提供可写 value 和 increment 方法,textOf 建立显式字段到文本的依赖。
|
||||||
counter = ui.state(9)
|
counter = ui.state(9)
|
||||||
|
// 两次 textOf 分别创建文字展示对象,但读取同一 counter.value;一次 increment 会更新两处绑定。
|
||||||
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 参数。
|
||||||
|
// 由 Button.onClick 在点击时调用,函数从当前会话全局取得 counter,不维护另一份局部数值。
|
||||||
function increment(): Unit { counter.increment() }
|
function increment(): Unit { counter.increment() }
|
||||||
|
// 把实际函数作为回调而非函数名文本;render 之前只创建业务对象,render 后才有出现位置。
|
||||||
button.onClick(increment)
|
button.onClick(increment)
|
||||||
|
// Page 和 Column 将已有对象组成展示树;此时不调用 increment,首次绑定读到的值仍为 9。
|
||||||
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)))
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
|
// 方法覆盖遵守参数可更宽、返回可更具体的规则。
|
||||||
|
// 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 {
|
||||||
|
// 由 WideRect.measure 显式调用此实现,再委托 RectBlock 原生测量;传递的仍是当前出现与当前约束。
|
||||||
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 {
|
||||||
|
// 宿主布局驱动仍传入 Mount/Constraints;Object 注解只放宽本覆盖的参数检查,不改变实际输入对象。
|
||||||
|
// 若手工传入其他 Object,下一行委托 BaseRect.measure 仍检查其较窄的 Mount/Constraints 契约,不保证调用成功。
|
||||||
measured = BaseRect.measure(self, mount, constraints)
|
measured = BaseRect.measure(self, mount, constraints)
|
||||||
|
// 拷贝的是本轮测量结果的两个数值,不复制业务矩形或 Mount;返回新结果供宿主校验。
|
||||||
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 {
|
||||||
|
// Button.onClick 调用零参绑定方法;width 写入令依赖该原生尺寸的测量失效,count 驱动另一文字绑定。
|
||||||
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)
|
||||||
|
// 所有点击都操作同一个 rect/count;重复设 width=260 不继续增加宽度,但 increment 每次都会增加计数。
|
||||||
button = Button("扩大矩形")
|
button = Button("扩大矩形")
|
||||||
button.onClick(actions.widen)
|
button.onClick(actions.widen)
|
||||||
root = Column(24, 16, TextBlock("参数可以更宽,返回类型可以更具体", 2), rect, button, ui.textOf(count, "value"))
|
root = Column(24, 16, TextBlock("参数可以更宽,返回类型可以更具体", 2), rect, button, ui.textOf(count, "value"))
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
// 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。
|
||||||
|
// 普通数据变化不会重新挂载;页面关闭时子项先卸载,父项随后卸载,旧 Mount 不因重开而复活。
|
||||||
|
// order 是统一时序,mounted/unmounted 是子项次数;latest 保存最近通知的实际 Mount。
|
||||||
class LifecycleLog {
|
class LifecycleLog {
|
||||||
|
// order/mounted/unmounted/latest 为可写 state,四个顺序字段为 Number;构造参数按这里的字段次序传入。
|
||||||
order: Object
|
order: Object
|
||||||
mounted: Object
|
mounted: Object
|
||||||
unmounted: Object
|
unmounted: Object
|
||||||
@@ -8,35 +12,49 @@ 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 {
|
||||||
|
// 宿主为 child 的每个新出现分别调用;同一 self 会收到两次通知,但传入的是两个不同 Mount。
|
||||||
self.log.order.increment()
|
self.log.order.increment()
|
||||||
self.log.mounted.increment()
|
self.log.mounted.increment()
|
||||||
self.log.childMounted = self.log.order.value
|
self.log.childMounted = self.log.order.value
|
||||||
|
// 保存最近一次参数供 Console 查询,后一次通知会覆盖前一次;mounted 累计值则保留两次调用。
|
||||||
|
// latest.value 强持有这个 Mount 供观察,不使已卸载 Mount 复活;Mount 到 Page/desktop 的反向关系仍为弱引用。
|
||||||
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 {
|
||||||
|
// 展示树移除此出现时由宿主调用,不能通过再次 setText 或保存 mount 让已经卸载的出现重新生效。
|
||||||
self.log.order.increment()
|
self.log.order.increment()
|
||||||
self.log.unmounted.increment()
|
self.log.unmounted.increment()
|
||||||
self.log.childUnmounted = self.log.order.value
|
self.log.childUnmounted = self.log.order.value
|
||||||
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 {
|
||||||
|
// 父 Mount 建立后的业务通知,只改日志;初始父通知先于下方两个 child 出现的通知。
|
||||||
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 {
|
||||||
|
// 对整页的卸载,两个子出现先各计一次,父回调最后把当前总序号写入 parentUnmounted。
|
||||||
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)
|
||||||
|
// 两个 child 参数是同一对象引用;新增 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)
|
||||||
page = Page()
|
page = Page()
|
||||||
page.render(root)
|
page.render(root)
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
|
// 原生回调也发布 Function 签名:state.set 接收一个任意值,increment 接收零参数;此处事件分发将 Event 作为 set 的那个值。
|
||||||
|
// 同一事件注册机制按实际签名调用;label 绑定再把事件 kind 转成可显示的字符串。
|
||||||
|
// 保留最后一次收到的 Event 对象,初值 kind=waiting;不是只存一份格式化事件文本。
|
||||||
received = ui.state(Event("waiting"))
|
received = ui.state(Event("waiting"))
|
||||||
|
// 单独计数的状态与 received 无关,第二个按钮不会改变 eventKind 读取的事件对象。
|
||||||
count = ui.state(0)
|
count = ui.state(0)
|
||||||
|
// 只读计算提取当前事件种类,作为 label.text 绑定的依赖。
|
||||||
|
// 由 label.bind 的绑定求值调用,无参数;读取链经过状态 value 再到 Event.kind,返回可显示字符串。
|
||||||
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)
|
||||||
|
// subscription 管理字段绑定,下面两个 Connection 管理输入监听,两种注册的用途不同。
|
||||||
withEvent = Button("传递 Event 给原生 set")
|
withEvent = Button("传递 Event 给原生 set")
|
||||||
|
// received.set 的参数并非专门限定为 Event;本次订阅按一参数签名调用,传入实际 click Event 并覆盖 received.value。
|
||||||
firstConnection = withEvent.on("click", received.set)
|
firstConnection = withEvent.on("click", received.set)
|
||||||
|
// 保存的是绑定 count 的原生方法,事件分发按它的零参签名调用,无需人为包装一个丢弃 Event 的函数。
|
||||||
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))
|
||||||
|
|||||||
@@ -1,35 +1,51 @@
|
|||||||
|
// 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。
|
||||||
|
// label 在树中出现多次但仍是同一对象,原生方法和源字段写入共同更新各出现位置。
|
||||||
|
// 扩展 TextBlock 的业务字段;其文本/颜色仍由原生父类存储与绘制。
|
||||||
class StatusText: TextBlock {
|
class StatusText: TextBlock {
|
||||||
|
// 构造后保存颜色策略;CustomStatus 继续继承此字段,源覆盖中通过同一 self 读取。
|
||||||
activeColor: String
|
activeColor: String
|
||||||
|
|
||||||
|
// 用继承的 setText/setColor 改原对象;activeColor 是构造时追加的源字段。
|
||||||
function activate(self: StatusText, event: Event): Unit {
|
function activate(self: StatusText, event: Event): Unit {
|
||||||
|
// 这是基类事件策略,本例实际 label 是 CustomStatus,因此 ActionButton 转发会选中下方覆盖。
|
||||||
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 {
|
||||||
|
// 接收的是按钮转发的同一 Event;显式基类方法调用只传目标 self 和新文本,不把 Event 当作文本参数。
|
||||||
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 {
|
||||||
|
// button.onClick(button.activate) 派发进入这里,target 实际为 CustomStatus,动态查找其 activate 覆盖。
|
||||||
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)
|
||||||
|
// caption 是 CustomPage 的源构造字段;下面 render 只把它的当前字符串用于标题 TextBlock,没有注册 caption 绑定。
|
||||||
page = CustomPage("Endor 原生类型的源码继承")
|
page = CustomPage("Endor 原生类型的源码继承")
|
||||||
|
// label 在根孩子中引用两次而不复制,setText/setColor 一次写入会刷新两处出现;末尾 textOf 单独展示 message 字段。
|
||||||
page.render(Column(24, 16, TextBlock(page.caption, 2), label, label, button, ui.textOf(label, "message")))
|
page.render(Column(24, 16, TextBlock(page.caption, 2), label, label, button, ui.textOf(label, "message")))
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
|
// owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。
|
||||||
|
// 每个 owner 下同 key 的重复点击替换旧定时器;任务生命周期不由画面是否可见推断。
|
||||||
|
// 把 owner 与要修改的 state 分开持有,使同一个调度方法可用于三个作用域。
|
||||||
class Actions {
|
class Actions {
|
||||||
|
// 保存任务的生命周期归属,不一定等于回调接收者 self;schedule 把它作为 ui.after 第一参。
|
||||||
owner: Object
|
owner: Object
|
||||||
|
// 回调实际修改的共享计数,与 owner 分开,三个 Actions 可以修改同一状态但有不同取消边界。
|
||||||
state: Object
|
state: Object
|
||||||
|
// 定时任务成功交付后修改共享计数,零参数签名符合 timer 回调要求。
|
||||||
|
// 任务调度器用已绑定 self 的 complete 零参调用,timer 不额外传 Event;自有 state 的增量是交付后的业务修改。
|
||||||
function complete(self: Actions): Unit { self.state.increment() }
|
function complete(self: Actions): Unit { self.state.increment() }
|
||||||
|
// 每次启动 1500ms 的 load 定时器;相同 owner/key 只有最新任务继续等待。
|
||||||
|
// 三个按钮的 click 事件路由调用 schedule,event 为事件参数;方法内部不读取它,只创建延时任务。
|
||||||
function schedule(self: Actions, event: Event): Unit {
|
function schedule(self: Actions, event: Event): Unit {
|
||||||
|
// 返回句柄未存入字段也不立即取消:owner 的任务字典强持有 Task,调度器本身仅保存弱索引。
|
||||||
|
// 此处 owner 仍由全局/业务字段保留;同一 owner/load 新任务替换旧等待,不靠局部 Task 变量维持资格。
|
||||||
ui.after(self.owner, "load", 1500, self.complete)
|
ui.after(self.owner, "load", 1500, self.complete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 唯一业务计数,三种 owner 的成功回调都递增此对象,文字绑定显示同一个 value。
|
||||||
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、应用,便于比较关闭页面的取消边界。
|
||||||
|
// Page 仍在窗口注册表中才具备此 owner 的交付资格,单纯切换标签不等于关闭 Page。
|
||||||
pageActions = Actions(page, state)
|
pageActions = Actions(page, state)
|
||||||
|
// hidden 是普通 Block,未挂载不阻止其拥有任务;全局引用和 Actions 字段保留业务对象。
|
||||||
blockActions = Actions(hidden, state)
|
blockActions = Actions(hidden, state)
|
||||||
|
// 应用 owner 使用 ui,页面关闭后仍可交付,但整个运行时退出时会统一取消未完成任务。
|
||||||
appActions = Actions(ui, state)
|
appActions = Actions(ui, state)
|
||||||
pageButton = Button("Page 任务:关闭 Tab 会取消")
|
pageButton = Button("Page 任务:关闭 Tab 会取消")
|
||||||
blockButton = Button("隐藏 Block 任务:关闭 Tab 仍继续")
|
blockButton = Button("隐藏 Block 任务:关闭 Tab 仍继续")
|
||||||
appButton = Button("应用任务:关闭 Tab 仍继续")
|
appButton = Button("应用任务:关闭 Tab 仍继续")
|
||||||
|
// 保存连接对象供显式监听器生命周期管理,按钮的事件路由先调用对应 Actions.schedule 再安装定时器。
|
||||||
pageConnection = pageButton.on("click", pageActions.schedule)
|
pageConnection = pageButton.on("click", pageActions.schedule)
|
||||||
|
// 监听器绑定 blockActions,实际任务 owner 来自该实例字段 hidden,不误用触发点击的按钮 Mount。
|
||||||
blockConnection = blockButton.on("click", blockActions.schedule)
|
blockConnection = blockButton.on("click", blockActions.schedule)
|
||||||
|
// 监听器绑定 appActions,所以同一个按钮回调实现可复用而不改变三个不同 owner 身份。
|
||||||
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 空闲轮询交付。
|
||||||
|
// key=initial 与应用按钮的 key=load 不同,不会因启动演示定时器而取消后续 load 任务。
|
||||||
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")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
let heading = ui.text("Hello Endor", 2, "#242422")
|
let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。endor_host 的脚本执行路径按顶层语句顺序运行本文件。
|
||||||
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 请求展示此页,宿主刷新时再完成出现位置、测量、安排与绘制;源方法调用结束不等于 OS 已呈现像素。
|
||||||
|
|||||||
@@ -1,27 +1,44 @@
|
|||||||
|
// 普通业务对象显式实现 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 {
|
||||||
|
// 由 present 中 button.on 注册的绑定方法调用,self 仍是顶层 product,不是 result 容器或 title。
|
||||||
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 {
|
||||||
|
// ui.present 验证显式能力后调用本方法,window 是本次展示上下文;每次调用都会新建下面这些视觉对象。
|
||||||
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)
|
||||||
|
// title/swatch 的初值只是构造输入,后续 getter 绑定读取原 Product,使事件写入能继续传播。
|
||||||
button = Button("调用业务对象方法")
|
button = Button("调用业务对象方法")
|
||||||
connection = button.on("click",self.update)
|
connection = button.on("click",self.update)
|
||||||
|
// result 保留三个视觉孩子,返回该根给调用方;这里尚未通过 Page.render 建立出现树。
|
||||||
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 类型,实际业务字段在这个唯一实例上初始化;label/shade 将读取此实例。
|
||||||
product = Product("普通对象的显式展示能力", "#526AC7")
|
product = Product("普通对象的显式展示能力", "#526AC7")
|
||||||
|
// 先获得视觉根,再由 ui.open 把它挂载到页面;最后设置 Page 标题。
|
||||||
view = ui.present(product)
|
view = ui.present(product)
|
||||||
|
// ui.open 接收上面已经生成的 Block,不会再次把 product 交给 present 重新生成另一棵视觉树。
|
||||||
page = ui.open(view)
|
page = ui.open(view)
|
||||||
page.title = "Presentable"
|
page.title = "Presentable"
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
|
// 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。
|
||||||
|
// 绑定在目标隐藏时也会更新;两个页面共享同一组 Block,不需要两份模型。
|
||||||
|
// width/color/label 是唯一业务来源,绑定计算只读,expand 是显式事件写入入口。
|
||||||
class Model {
|
class Model {
|
||||||
|
// 三个业务值由下面三个 getter 分别读取;绑定的目标属性保存在视觉对象上,业务修改入口在 expand。
|
||||||
width: Number
|
width: Number
|
||||||
color: String
|
color: String
|
||||||
label: String
|
label: String
|
||||||
|
// 绑定求值调用此零参 getter 并取得 Number;结果写入 rect.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 {
|
||||||
|
// 由 clickConnection 派发调用,可写业务状态;三个 getter 则由绑定求值调用,不能反向修改模型。
|
||||||
self.width = 260
|
self.width = 260
|
||||||
self.color = "#237A45"
|
self.color = "#237A45"
|
||||||
self.label = "绑定已更新"
|
self.label = "绑定已更新"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model = Model(120, "#526AC7", "点击更新共享对象")
|
model = Model(120, "#526AC7", "点击更新共享对象")
|
||||||
|
// 原生构造给初始载荷,后面的 bind 再把这些属性接到模型;label 的初始化字符串也会被绑定结果替换。
|
||||||
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)
|
||||||
|
// 每次传入的是已绑定 model 的零参函数,注册不会调用 expand,也不把方法名当作待执行源码字符串。
|
||||||
colorBinding = rect.bind("color", model.getColor)
|
colorBinding = rect.bind("color", model.getColor)
|
||||||
labelBinding = label.bind("text", model.getLabel)
|
labelBinding = label.bind("text", model.getLabel)
|
||||||
button = Button("更新宽度、颜色和文字")
|
button = Button("更新宽度、颜色和文字")
|
||||||
clickConnection = button.on("click", model.expand)
|
clickConnection = button.on("click", model.expand)
|
||||||
root = Column(24, 16, label, rect, button)
|
root = Column(24, 16, label, rect, button)
|
||||||
|
// 两个页面共享 root 及其子对象,因此只有一组属性绑定,不需要为每个 Mount 再注册一组。
|
||||||
|
// 两页同属一个窗口,本例最终仅激活第一页;同时跨窗口显示的传播由宿主测试另外覆盖。
|
||||||
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)
|
||||||
|
// 切回第一页不销毁第二页或重建模型;隐藏页仍引用同一组更新后的业务/视觉对象。
|
||||||
page.activate()
|
page.activate()
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
// The scene has world positions; each finite viewport has its own camera.
|
// 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。
|
||||||
|
// 内层视口也可作为世界子项;HUD 在外层 Overlay 中,所以不会随世界相机变换。
|
||||||
|
// 场景保存世界对象,视口负责有限窗口与相机变换。
|
||||||
count = ui.state(0)
|
count = ui.state(0)
|
||||||
|
// 同一按钮随 card 在两个视口各出现一次,但只注册这一份回调,点击任意出现都修改同一个 count。
|
||||||
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
|
||||||
|
// 这些 x/y 是 Scene2D 世界坐标,不是页面 Row 中的像素位置;每个外层相机分别投影它们。
|
||||||
card.y = 40
|
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 自身占外层世界里的有限盒,盒内的小场景另走 inner 的相机和裁剪,输入也需逐层换算。
|
||||||
inner.x = 260
|
inner.x = 260
|
||||||
inner.y = 40
|
inner.y = 40
|
||||||
|
// 两个外层视口引用同一 world,卡片/内层视口因此各产生两个出现位置。
|
||||||
|
// inner 自身仍是同一个 CanvasViewport 对象,其 camera 属性也只有一份;两个 inner Mount 的导航会联动,约束/裁剪仍各自独立。
|
||||||
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)
|
||||||
|
// 两次构造视口默认产生不同 Camera2D,改 right.camera 的字段不会修改 left.camera。
|
||||||
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"
|
||||||
|
// HUD 是 views 的 Overlay 兄弟,不是 world 的孩子;其位置受普通布局控制,不参与 world 相机平移缩放。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "场景与视口"
|
page.title = "场景与视口"
|
||||||
page.render(Overlay(12, 0, views, hud))
|
page.render(Overlay(12, 0, views, hud))
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
|
// 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。
|
||||||
|
// 三维内容与普通 Endor 二维控件一起排版;viewport 的像素宽高不是场景世界坐标范围,世界数值与几何规模仍受宿主预算检查。
|
||||||
|
// 保存左侧视口和共享立方体;动作可改相机位置或真实网格变换。
|
||||||
class SceneActions {
|
class SceneActions {
|
||||||
|
// view 只保存左透视视口,cube 保存两个视口共同引用的网格,两个字段的修改传播范围不同。
|
||||||
view: Viewport3D
|
view: Viewport3D
|
||||||
cube: Mesh3D
|
cube: Mesh3D
|
||||||
|
// 把左相机放到 z=6 正面,保持相机既有 target。
|
||||||
function front(self: SceneActions): Unit {
|
function front(self: SceneActions): Unit {
|
||||||
|
// front.onClick 以零参绑定方法调用,按绝对坐标设置左相机,不重新构造 Scene3D 或清除网格旋转。
|
||||||
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 {
|
||||||
|
// side.onClick 调用;只设置位置三个分量,保留既有 target 和投影参数。
|
||||||
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 的欧拉旋转,因此两个视口都看到物体变化。
|
||||||
|
// rotate.onClick 调用,15 是本次绕 Y 轴增加的角度,重复点击累加,不把旋转每次复位到 15。
|
||||||
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 注册到视口。
|
||||||
|
// 这是供后续命中事件注册的入口,须由提供 hit.mesh 的网格命中事件调用;本文件的三个按钮均不调用它。
|
||||||
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,19 +34,24 @@ 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)
|
||||||
|
// group 保留两个网格的变换层级,两视口复用下面同一个 scene,不分别创建立方体副本。
|
||||||
|
// 薄盒作地面,方向光与 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)
|
||||||
orthographic.camera = OrthographicCamera()
|
orthographic.camera = OrthographicCamera()
|
||||||
|
// height 表示正交相机可见世界高度,不是右视口 360 的逻辑像素高度。
|
||||||
orthographic.camera.height = 5
|
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)
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
|
// 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。
|
||||||
|
// 同一 paragraph 出现两次,文字内容共享,换行/偏移/范围在各 Mount 中独立。
|
||||||
|
// 通过具体出现位置的 Mount 操作偏移;ScrollBlock.axis 则是对象级策略,可影响其所有出现位置。
|
||||||
class Actions {
|
class Actions {
|
||||||
|
// 两个不同 ScrollBlock 对象引用同一 paragraph;对象级 axis 策略各自保存,Mount 偏移也各自保存。
|
||||||
vertical: ScrollBlock
|
vertical: ScrollBlock
|
||||||
horizontal: ScrollBlock
|
horizontal: ScrollBlock
|
||||||
|
// 只对 vertical 第一个 Mount 累加纵偏移,运行时按当前范围夹取。
|
||||||
|
// down.onClick 调用零参方法;点击发生在挂载之后,本例 vertical 仅有一个有效 Mount,故选择 at(0)。
|
||||||
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 累加横偏移,禁止轴仍保持零。
|
||||||
|
// right.onClick 选择另一个滚动对象,60 是相对增量,不是强制把偏移设为 60。
|
||||||
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 {
|
||||||
|
// reset.onClick 调用;这里用 scrollTo 指定绝对原点,保留容器尺寸和当前 axis 策略。
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
// 把左对象策略改为双轴,下一次求解重新计算约束和范围。
|
||||||
|
// both.onClick 改左侧对象策略而不是某次出现的偏移,也不把右侧 horizontal 改成双轴。
|
||||||
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,两个视图可以拥有不同的换行结果。")
|
||||||
|
// 构造视口宽 260,左右 padding 各 8 时内容宽为 244;若父分配另行缩窄,则应按该 Mount 实际视口重算。
|
||||||
vertical = ScrollBlock(260, 140, paragraph)
|
vertical = ScrollBlock(260, 140, paragraph)
|
||||||
vertical.padding = 8
|
vertical.padding = 8
|
||||||
|
// 先给左对象设置 vertical,右对象随后独立设 horizontal;同一 paragraph 不保存任何一个父容器的轴策略。
|
||||||
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)
|
||||||
@@ -25,6 +41,7 @@ reset = Button("回到起点")
|
|||||||
reset.onClick(actions.reset)
|
reset.onClick(actions.reset)
|
||||||
both = Button("左侧恢复双轴")
|
both = Button("左侧恢复双轴")
|
||||||
both.onClick(actions.both)
|
both.onClick(actions.both)
|
||||||
|
// 注册只保存函数,不提前查询 mounts;render 建立两个滚动区域的出现后,点击才会执行上述偏移操作。
|
||||||
page = Page()
|
page = Page()
|
||||||
page.title = "滚动轴"
|
page.title = "滚动轴"
|
||||||
page.render(Column(24, 16, TextBlock("同一个对象,不同的滚动轴", 2), Row(0, 24, Column(0, 8, TextBlock("vertical"), vertical), Column(0, 8, TextBlock("horizontal"), horizontal)), Row(0, 12, down, right, reset, both)))
|
page.render(Column(24, 16, TextBlock("同一个对象,不同的滚动轴", 2), Row(0, 24, Column(0, 8, TextBlock("vertical"), vertical), Column(0, 8, TextBlock("horizontal"), horizontal)), Row(0, 12, down, right, reset, both)))
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
// 同一 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 {
|
||||||
|
// source_paint 为当前出现调用此覆盖;只返回基类新命令,不把滚动偏移写回共享矩形坐标。
|
||||||
return RectBlock.paint(self,mount,geometry)
|
return RectBlock.paint(self,mount,geometry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 三个按钮复用同一个 Action,任意可见按钮点击都递增同一 count。
|
||||||
class Actions {
|
class Actions {
|
||||||
|
// 三个按钮连接同一 Actions,count 是共享业务状态;每个 ScrollBlock 出现的偏移不保存在这里。
|
||||||
count: Object
|
count: Object
|
||||||
|
// 签名接受 Event,业务逻辑只需修改计数,不直接处理滚动位置。
|
||||||
|
// 原生指针点击先经过滚动裁剪命中,再由事件分发调用此方法;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)
|
||||||
@@ -13,18 +22,23 @@ first = Button("顶部按钮")
|
|||||||
middle = Button("中间按钮")
|
middle = Button("中间按钮")
|
||||||
last = Button("底部按钮")
|
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)
|
||||||
|
// 高内容夹有三个按钮与矩形,在获得构造所需的 320×180 分配时产生横纵滚动范围;最终仍受各窗父约束限制。
|
||||||
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)
|
||||||
|
// body 自然内容大于固定视口,宿主为各 Mount 计算范围;同一 scroller 不需要克隆 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()
|
||||||
second.title = "独立滚动位置"
|
second.title = "独立滚动位置"
|
||||||
second.open(other)
|
second.open(other)
|
||||||
|
// 先指定副窗口归属再 render,使这份 root 的新出现进入 other,而不是新建另一份业务根。
|
||||||
second.render(root)
|
second.render(root)
|
||||||
ui.window().setTitle("Scroll Primary")
|
ui.window().setTitle("Scroll Primary")
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
|
// 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。
|
||||||
|
// 圆外部分可落到父容器监听器;按下与释放须经相同合法目标复核。
|
||||||
|
// 扩展矩形的绘制、命中和事件方法,用同一个 hits 状态观察交互结果。
|
||||||
class CircleTarget: RectBlock {
|
class CircleTarget: RectBlock {
|
||||||
|
// hits 由下面顶层 state 提供,源命中只读它所需的几何,计数写入留给 onEvent。
|
||||||
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 {
|
||||||
|
// source_hit 在候选出现的命中阶段调用,point 已转换为局部坐标,不应再减 geometry.x/y。
|
||||||
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 {
|
||||||
|
// source_paint 调用此覆盖;修改新生成的第一条矩形命令,不把共享对象的原生 width 从 160 改成 80。
|
||||||
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 {
|
||||||
|
// 此示例的点击分发选中圆后调用对象方法;stopPropagation 只阻止后续祖先组,不撤销已经执行的捕获处理。
|
||||||
|
// 此覆盖没有按 event.kind 过滤;若从源码派发其他事件,同样会递增 hits 并停止其后续祖先传播。
|
||||||
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)
|
||||||
|
// 布局仍给 circle 原生的 160×80 盒,只有绘制和形状命中缩成左侧圆;透明右半部并非不存在的布局区域。
|
||||||
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)
|
||||||
|
|||||||
@@ -1,29 +1,50 @@
|
|||||||
|
// 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。
|
||||||
|
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
|
||||||
|
// 保存宽度与左偏移;两字段只在事件回调中修改。
|
||||||
class Metrics {
|
class Metrics {
|
||||||
|
// width 提供 measure 输出,left 提供 arrange 的首子槽 x;模型状态不是 Mount 的派生几何副本。
|
||||||
width: Number
|
width: Number
|
||||||
left: Number
|
left: Number
|
||||||
|
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
|
||||||
|
// button.on 的绑定事件回调写入两个字段,方法返回后的刷新把受影响布局合并到同一帧。
|
||||||
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 {
|
||||||
|
// 从当前出现而非共享 rect 查父 Mount,同一 MeasuredRect 换父容器时可读取不同的 metrics。
|
||||||
|
// 读取真实 Mount 上下文时,当前测量缓存会保守判定易变;不能仅因最终宽度只取模型字段就认为结果可跨帧复用。
|
||||||
parent = mount.parent()
|
parent = mount.parent()
|
||||||
|
// 父 Block 需要符合示例的 metrics 约定;MeasureResult 宽高随后仍由宿主按当前 Constraints 检查和钳制。
|
||||||
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 {
|
||||||
|
// 保留原生 Column 对全部孩子的槽分配,临时结果可修改,不在 arrange 中写 self.metrics。
|
||||||
positions = Column.arrange(self, mount, geometry)
|
positions = Column.arrange(self, mount, geometry)
|
||||||
|
// 这里直接覆盖排列结果坐标为 metrics.left,不是给 geometry.x 再加相对位移;其他槽保持原生结果。
|
||||||
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)
|
||||||
|
// 原生矩形构造参数仍提供绘制载荷,实际测量被源覆盖为模型宽度和固定高 60。
|
||||||
rect = MeasuredRect(80, 20, 0, "#526AC7")
|
rect = MeasuredRect(80, 20, 0, "#526AC7")
|
||||||
button = Button("更新布局")
|
button = Button("更新布局")
|
||||||
|
// 首个孩子是 rect,metrics 是 Column 子类新增字段,不是额外的视觉子项。
|
||||||
root = OffsetColumn(24, 16, rect, TextBlock("尺寸来自源码 measure,位置来自源码 arrange"), button, metrics)
|
root = OffsetColumn(24, 16, rect, TextBlock("尺寸来自源码 measure,位置来自源码 arrange"), button, metrics)
|
||||||
|
// 保存监听连接使按钮操作同一个模型;布局方法由宿主协议调用,不由事件回调直接手工调用。
|
||||||
connection = button.on("click", metrics.expand)
|
connection = button.on("click", metrics.expand)
|
||||||
page = Page()
|
page = Page()
|
||||||
|
// 先建立出现树和临时 Mount 上下文,再运行 measure/arrange,源方法才能在求解阶段查看父关系。
|
||||||
page.render(root)
|
page.render(root)
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
|
// 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。
|
||||||
|
// 同一 rect 出现两次,两份命令随同一 tint 更新;业务身份共享,几何按 Mount 分开。
|
||||||
|
// 原生矩形测量不变,仅将绘制颜色切换为源字段 tint。
|
||||||
class TintRect: RectBlock {
|
class TintRect: RectBlock {
|
||||||
|
// 源颜色策略与原生构造颜色分别保存;两个出现都读取同一 tint,但用各自 geometry 生成命令。
|
||||||
tint: String
|
tint: String
|
||||||
|
// 从原生基类取得当前出现位置的矩形命令,再修改本次返回列表中的颜色并交回。
|
||||||
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
|
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
|
||||||
|
// source_paint 在只读计算阶段调用,基类返回本轮新列表,修改其中命令不等于修改已有业务状态。
|
||||||
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 {
|
||||||
|
// Badge 的源覆盖构造一条新背景命令,再组合原生文字命令;并未创建额外的背景 Block 或 Mount。
|
||||||
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,18 +27,26 @@ 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"
|
||||||
|
// ObjectList 的先后决定遮盖次序,返回时宿主统一验证命令字段和范围。
|
||||||
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 {
|
||||||
|
// 分发器调用 Button 子类的对象方法,无需另写 button.on;这里可写 target,paint 中只能读它。
|
||||||
|
// onEvent 本身不限定 click:本覆盖没有检查 event.kind,其他经对象方法递送的事件也会执行改色。
|
||||||
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 的末尾字符串是新增 background 字段;TextBlock 的原生可选颜色省略,随后 paint 将文字改为白色。
|
||||||
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
|
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
|
||||||
button = RecolorButton("通过对象方法改变绘制颜色", rect)
|
button = RecolorButton("通过对象方法改变绘制颜色", rect)
|
||||||
root = Column(24, 16, TextBlock("源码 paint 进入真实窗口", 2), rect, rect, badge, button)
|
root = Column(24, 16, TextBlock("源码 paint 进入真实窗口", 2), rect, rect, badge, button)
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
// 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 {
|
||||||
|
// source_layout 调用覆盖,Column.measure 返回新临时结果;改 size.width 不修改 student 或其绑定文字。
|
||||||
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 {
|
||||||
|
// source_paint 的只读入口创建新命令,不在绘制阶段修改成绩/表格;绘制顺序由父背景后接孩子命令保证。
|
||||||
fill = PaintCommand()
|
fill = PaintCommand()
|
||||||
fill.kind = "roundedRect"
|
fill.kind = "roundedRect"
|
||||||
fill.x = geometry.x
|
fill.x = geometry.x
|
||||||
@@ -21,7 +29,9 @@ class GradePanel: Column {
|
|||||||
return DisplayList(ObjectList(fill))
|
return DisplayList(ObjectList(fill))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 姓名/学号和各科成绩/状态是业务字段;数值修改受 Number 声明检查。
|
||||||
class Student {
|
class Student {
|
||||||
|
// 同一 Student 可由多个 gradeRow 引用,响应式成绩/状态绑定始终读取原对象,没有每行一份成绩副本。
|
||||||
name: String
|
name: String
|
||||||
number: String
|
number: String
|
||||||
chinese: Number
|
chinese: Number
|
||||||
@@ -29,10 +39,15 @@ 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 {
|
||||||
|
// gradeRow 和 gradeHeader 调用,传入孩子之后的 width 是 GradeCell 源字段,不能混进 Column 视觉子项列表。
|
||||||
return GradeCell(0, 0, content, width)
|
return GradeCell(0, 0, content, width)
|
||||||
}
|
}
|
||||||
|
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与期望列宽组织表格。
|
||||||
|
// 修改 student.name/number 不会自动更新已构造的 TextBlock;需要像成绩一样显式绑定或重新构造行。
|
||||||
function gradeRow(student: Student): Block {
|
function gradeRow(student: Student): Block {
|
||||||
|
// 顶层 allGradeRows 调用六次,attentionGradeRows 另为 studentLin 调用一次;视觉树不同,学生业务身份可相同。
|
||||||
return Column(0, 12,
|
return Column(0, 12,
|
||||||
Row(0, 0,
|
Row(0, 0,
|
||||||
gradeCell(TextBlock(student.name), 150),
|
gradeCell(TextBlock(student.name), 150),
|
||||||
@@ -43,28 +58,39 @@ 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 {
|
||||||
|
// all/attention 两棵子树都由模型强引用保留,切掉某个展示根不会丢失它;student 是要复核的 studentLin。
|
||||||
table: Column
|
table: Column
|
||||||
all: Column
|
all: Column
|
||||||
attention: Column
|
attention: Column
|
||||||
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 {
|
||||||
|
// showAllGrades 的绑定事件入口,替换容器当前槽 0 内容,不把 all 的孩子逐个拷贝到 table。
|
||||||
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 {
|
||||||
|
// showAttentionGrades 调用,选中既有 attention 子树;pending 变成零也不会自动删除这棵预建视图。
|
||||||
self.table.replace(0, self.attention)
|
self.table.replace(0, self.attention)
|
||||||
self.message = "林沐的数学成绩有一条复核记录"
|
self.message = "林沐的数学成绩有一条复核记录"
|
||||||
}
|
}
|
||||||
|
// 写预设数学成绩68及状态,清待复核数;相关绑定在两个视图都读同一 studentLin。
|
||||||
|
// 不检查 pending 或当前表格模式,重复 review 仍写相同预设值;这不是逐条审核队列。
|
||||||
function review(self: GradeCenter, event: Event): Unit {
|
function review(self: GradeCenter, event: Event): Unit {
|
||||||
|
// reviewGrade 事件入口写入同一学生,隐藏的另一行也通过绑定观察新值;没有遍历所有行手工改文字。
|
||||||
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 {
|
||||||
|
// resetGrades 派发时恢复字段并切回 all;保留 Student、GradeCenter 和按钮身份,便于继续点击演示。
|
||||||
self.student.math = 58
|
self.student.math = 58
|
||||||
self.student.status = "待复核"
|
self.student.status = "待复核"
|
||||||
self.pending = 1
|
self.pending = 1
|
||||||
@@ -72,6 +98,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, "已确认")
|
||||||
@@ -80,16 +107,21 @@ studentSu = Student("苏映雪", "2026005", 88, 92, 90, "已确认")
|
|||||||
studentLu = Student("陆景行", "2026006", 79, 84, 87, "已确认")
|
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 始终只包含一个内容槽,后续 replace 在这个边界换整棵视图;表头独立放在外层 GradePanel。
|
||||||
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)
|
||||||
|
// 四次注册不立即调用方法,所以初始化仍显示全部学生、待复核数 1 和林沐数学 58。
|
||||||
|
// 表头复用与数据行完全相同的列宽,摘要仅 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 +133,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,
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
|
// 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。
|
||||||
|
// 当前侧栏提供选择/关闭,文案中的拖放场景也可经版本化事件 API 测试;不要把它理解成已实现的侧栏拖动手势。
|
||||||
|
// 继承 Page 后观察 moveToWindow/moveTab 的真实方法调用,保留原生迁移/重排行为。
|
||||||
class TrackedPage: Page {
|
class TrackedPage: Page {
|
||||||
|
// 每个 TrackedPage 引用同一 calls 状态,统计的是这两个覆盖方法的调用,不是渲染或所有页面操作次数。
|
||||||
calls: Object
|
calls: Object
|
||||||
|
// 先递增 calls,再显式调用基类执行跨窗迁移;Page 身份保留,旧 Mount 上下文会替换。
|
||||||
function moveToWindow(self: TrackedPage, window: Window): Page {
|
function moveToWindow(self: TrackedPage, window: Window): Page {
|
||||||
|
// 外部代码/API 选择此源覆盖时进入;显式 Page.moveToWindow 绕过再次动态分派,避免递归调用自身。
|
||||||
|
// 计数先于原生迁移执行;如果随后基类报错,已发生的 increment 不自动回滚,不能把它当作成功迁移次数。
|
||||||
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 {
|
||||||
|
// 显式调用原生基类并返回它的 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 {
|
||||||
|
// 由 button 的 click 连接派发;索引 1 是本次调用时的位置,每次点击新建文字并插入到当前第二项之前。
|
||||||
self.list.insert(1, TextBlock("插入的新对象"))
|
self.list.insert(1, TextBlock("插入的新对象"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
calls = ui.state(0)
|
calls = ui.state(0)
|
||||||
list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"))
|
list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"))
|
||||||
|
// list 的可变孩子槽由同一 Column 持有,所有共享页面观察插入后的同一序列,不为各页单独执行回调。
|
||||||
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 +44,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")
|
||||||
@@ -37,7 +52,9 @@ other.moveTo(1700,80)
|
|||||||
target = TrackedPage(calls)
|
target = TrackedPage(calls)
|
||||||
target.title = "目标窗口"
|
target.title = "目标窗口"
|
||||||
target.render(shared)
|
target.render(shared)
|
||||||
|
// 这里调用继承的 open,不直接调用上面的 moveToWindow 覆盖;不能把初始建页过程当作这两个方法的演示调用。
|
||||||
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")
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
// 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)
|
||||||
|
// connection 保存输入订阅,textOf 另外建立值到文字的绑定;二者共用 count,但承担不同调用职责。
|
||||||
increment = Button("加一")
|
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()
|
||||||
|
// 第二页有自己的内容根,没有引用 count;切页不等于把第一页面里的对象移进第二页面。
|
||||||
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()
|
||||||
|
// 三个全局 Page 引用在 close 后仍可保留页面业务身份,重新 open 会建立新的有效展示上下文。
|
||||||
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()
|
||||||
|
// 当前活动页切回 first 后请求该窗口标题,命令由成功输入后的 GUI 处理,不在这里同步操作原生窗口。
|
||||||
ui.window().setTitle("Endor Pages")
|
ui.window().setTitle("Endor Pages")
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
|
// 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。
|
||||||
|
// onMount 作为业务阶段收集结果;实际布局/绘制计算仍受只读规则约束。
|
||||||
|
// 把协议探测结果保存到普通业务字段,textOf 将其中一部分显示出来。
|
||||||
class VisualResults {
|
class VisualResults {
|
||||||
|
// width/height 来自测量返回值,childX/childY 来自安排返回值,color 来自新绘制命令;都不是最终 Mount 的几何快照。
|
||||||
|
// 只在 onMount 探测时覆盖这些值,未建立对所有后续协议调用的自动镜像或响应式绑定。
|
||||||
width: Number
|
width: Number
|
||||||
height: Number
|
height: Number
|
||||||
childX: Number
|
childX: Number
|
||||||
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 {
|
||||||
|
// 宿主挂载通知进入此业务回调,允许写 results;这里主动调用继承的原生 measure 来查询指定约束下的结果。
|
||||||
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
|
||||||
self.results.height = measured.height
|
self.results.height = measured.height
|
||||||
|
// 自定义 Bounds 仅作为这次 paint 的输入,不会把当前页面里 rect 的实际布局强制设为该位置和大小。
|
||||||
drawing = self.paint(mount, Bounds(12, 18, 200, 60))
|
drawing = self.paint(mount, Bounds(12, 18, 200, 60))
|
||||||
|
// 第一个命令来自 RectBlock 的原生矩形绘制,本例读取颜色后保存到业务模型供下方 textOf 展示。
|
||||||
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 {
|
||||||
|
// 当前父出现的子槽已存在,at(0) 对应下面 root 的标题 TextBlock;查询结果不替换宿主随后发布的最终安排。
|
||||||
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, "等待挂载")
|
||||||
|
// 父 ProbeColumn 与子 ProbeRect 共享同一结果容器,分别填写自己的探测字段,避免把结果误写到原生宽高载荷。
|
||||||
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)
|
||||||
page = Page()
|
page = Page()
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
|
// 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。
|
||||||
|
// 实际可用视口再返回布局约束,原生窗口尺寸不是业务对象上凭空写入的一份几何副本。
|
||||||
|
// 保存目标 Window,两个按钮对同一资源请求不同尺寸。
|
||||||
class WindowActions {
|
class WindowActions {
|
||||||
|
// 保存一个 Window 资源引用;事件发生在哪个按钮并不重新选择目标窗口,两个方法始终使用这个字段。
|
||||||
window: Window
|
window: Window
|
||||||
|
// 请求 900x600 逻辑尺寸,最终大小仍由窗口管理器决定。
|
||||||
function compact(self: WindowActions, event: Event): Unit {
|
function compact(self: WindowActions, event: Event): Unit {
|
||||||
|
// compactConnection 派发此绑定方法,只发 resize 请求,不给 band 或 Mount 直接写宽高。
|
||||||
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 {
|
||||||
|
// wideConnection 调用此事件入口;实际窗口变化回报后,新父约束才进入下面 WindowBand.measure。
|
||||||
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 {
|
||||||
|
// source_layout 在每个出现的测量阶段调用,maxWidth 是上层已扣除布局预算的约束,不是整窗原始宽度。
|
||||||
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")
|
||||||
compact = Button("紧凑窗口 900 × 600")
|
compact = Button("紧凑窗口 900 × 600")
|
||||||
wide = Button("宽窗口 1120 × 720")
|
wide = Button("宽窗口 1120 × 720")
|
||||||
compactConnection = compact.on("click", actions.compact)
|
compactConnection = compact.on("click", actions.compact)
|
||||||
|
// 两个回调均接收 Event,保存连接不等于立即请求尺寸;初次尺寸由文件最后的 resize 设置。
|
||||||
wideConnection = wide.on("click", actions.wide)
|
wideConnection = wide.on("click", actions.wide)
|
||||||
page = Page()
|
page = Page()
|
||||||
page.render(Column(24, 16, TextBlock("代码与 GUI 操作同一个受管窗口对象"), band, compact, wide))
|
page.render(Column(24, 16, TextBlock("代码与 GUI 操作同一个受管窗口对象"), band, compact, wide))
|
||||||
page.open(window)
|
page.open(window)
|
||||||
|
// 明确归属后向同一 window 排队标题和尺寸命令;Page 的业务内容与原生窗口资源分别保留身份。
|
||||||
window.setTitle("Endor Window")
|
window.setTitle("Endor Window")
|
||||||
window.resize(1120, 720)
|
window.resize(1120, 720)
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
// 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)
|
||||||
|
// 左右按钮是两个视觉对象,连接的却是绑定同一 count 的原生零参 increment 方法。
|
||||||
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 = "主窗口页面"
|
||||||
|
// 此次 textOf 建立左侧文字绑定,右页还会建立另一个;计数业务状态仍只有上面一份。
|
||||||
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 回调的业务接收者;从哪个窗口点击都只增加同一个 count.value。
|
||||||
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)
|
||||||
|
// open 把 right 归入副窗口;它不把后续顶层执行上下文永久改为该窗口,下面 ui.window() 仍取默认主窗口。
|
||||||
|
// 若在副窗口事件回调里调用 ui.window(),则取该事件窗口;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)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// 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 {
|
||||||
|
// 由顶层 statistics 连续调用三次,每次返回新的容器和文字;value 是展示字符串,不在这里解析或累计任务数。
|
||||||
return ui.column(0, 6,
|
return ui.column(0, 6,
|
||||||
ui.text(label, 1, "#637082"),
|
ui.text(label, 1, "#637082"),
|
||||||
ui.text(value, 2, "#243047"),
|
ui.text(value, 2, "#243047"),
|
||||||
@@ -10,15 +13,20 @@ 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 {
|
||||||
|
// 顶层 projects 为三组演示数据调用,state 只是状态文案,不是 ui.state 托管状态对象。
|
||||||
|
// 项目名和状态横排,状态颜色与进度前景相同。
|
||||||
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")
|
||||||
)
|
)
|
||||||
|
// caption/progress 是本次函数调用的局部视觉对象,返回根通过子项关系保留它们,不需要放到全局逐个挂载。
|
||||||
return ui.column(0, 6,
|
return ui.column(0, 6,
|
||||||
caption,
|
caption,
|
||||||
ui.text(detail, 1, "#637082"),
|
ui.text(detail, 1, "#637082"),
|
||||||
@@ -26,8 +34,11 @@ 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 {
|
||||||
|
// 由 timeline 的三次普通调用构造记录,time 是固定字符串;没有时钟读取、排序或后续自动推进。
|
||||||
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))
|
||||||
|
// stamp 把小圆点列与时间文字横排;上方 spacer 给圆点增加局部纵向留白,不是一个全局坐标。
|
||||||
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
|
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
|
||||||
return ui.column(0, 4,
|
return ui.column(0, 4,
|
||||||
stamp,
|
stamp,
|
||||||
@@ -36,6 +47,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 +60,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 +68,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"),
|
||||||
@@ -63,7 +77,9 @@ let timeline = ui.column(0, 14,
|
|||||||
)
|
)
|
||||||
|
|
||||||
let body = ui.row(0, 20, projects, timeline)
|
let body = ui.row(0, 20, projects, timeline)
|
||||||
|
// body 只建立两个已构造子树的组合关系,页面尚未 render,不会在构造这一行单独发布一帧。
|
||||||
|
|
||||||
|
// 底部提示以细色块和文字组合,明确说明页面还未接业务交互。
|
||||||
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 +88,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,
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
// 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
|
||||||
|
// 重置三维相机位置与观察目标,不改变面板或内层画布业务状态。
|
||||||
|
// 不重建 Camera,也不复位其 fov/near/far/up 或 controller;reset 仅写下面显式列出的六个属性。
|
||||||
function reset(self: WorldActions): Unit {
|
function reset(self: WorldActions): Unit {
|
||||||
|
// reset.onClick 派发零参绑定方法,按绝对值恢复外层相机位置/target;不会调用 canvas.fitView。
|
||||||
self.view.camera.x = 0
|
self.view.camera.x = 0
|
||||||
self.view.camera.y = 0.5
|
self.view.camera.y = 0.5
|
||||||
self.view.camera.z = 7
|
self.view.camera.z = 7
|
||||||
@@ -11,22 +18,31 @@ class WorldActions {
|
|||||||
self.view.camera.targetY = 0
|
self.view.camera.targetY = 0
|
||||||
self.view.camera.targetZ = 0
|
self.view.camera.targetZ = 0
|
||||||
}
|
}
|
||||||
|
// 只旋转 Panel3D 平面,面板内 UI 树仍用自己的逻辑宽高。
|
||||||
|
// turn.onClick 调用,每次给面板世界变换增加 15 度 Y 旋转,不修改 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,而非用外层三维视口大小。
|
||||||
|
// fit.onClick 在挂载后调用;本例 canvas 只在一个面板出现,at(0) 选择其有效 Mount,再按该出现计算相机适配。
|
||||||
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)
|
||||||
|
// 这个按钮在 Panel3D 内,后面的 reset/turn/fit 在外层页面;它们共享 VM,但输入需要不同坐标转换路径。
|
||||||
|
// 二维场景卡片使用带符号世界坐标;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
|
||||||
|
// panel 的 x/rotationY 改三维平面变换;UI 仍在 320×240 的逻辑区域里布局,不把世界单位当作字形像素。
|
||||||
panel.x = -1.2
|
panel.x = -1.2
|
||||||
panel.rotationY = 15
|
panel.rotationY = 15
|
||||||
cube = Box3D(1.4, 1.4, 1.4, "#7B9CB7")
|
cube = Box3D(1.4, 1.4, 1.4, "#7B9CB7")
|
||||||
@@ -34,12 +50,15 @@ 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
|
||||||
view.camera.z = 7
|
view.camera.z = 7
|
||||||
|
// 控制器保存已创建的三层对象,注册回调不会提前执行重置、转动或 Mount 查询。
|
||||||
actions = WorldActions(view, panel, canvas)
|
actions = WorldActions(view, panel, canvas)
|
||||||
reset = Button("重置摄像机")
|
reset = Button("重置摄像机")
|
||||||
reset.onClick(actions.reset)
|
reset.onClick(actions.reset)
|
||||||
@@ -47,6 +66,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"
|
||||||
|
|||||||
@@ -1,43 +1,66 @@
|
|||||||
|
// 多窗口共享长段落:同一文字对象在每个 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 画文字,和原生测量采用相同换行区域。
|
||||||
|
// 源绘制驱动在布局完成后传本轮 Bounds,原生文字命令携带对应换行边界,不重新猜另一套宽度。
|
||||||
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 {
|
||||||
|
// 只保存主窗口资源;narrow/wide 不改下面 other 的尺寸,两个窗口共享的是 paragraph 而非 viewport。
|
||||||
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) }
|
||||||
|
// 用短文本替换同一对象内容,两个窗口的文字都重新测量。
|
||||||
|
// 短文本提示只是示例字符串;真实 paragraph 仍同时出现在两个窗口,修改内容不会把两处 Mount 合成一个。
|
||||||
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 👩💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
|
||||||
|
// 只创建一次共享业务对象,full 字符串另由 Actions 保存供 restore 恢复,不重新 new 一个段落实例。
|
||||||
paragraph = Paragraph(full)
|
paragraph = Paragraph(full)
|
||||||
|
// 后继文字没有硬编码 y,由 Column 安排读取前一个段落本轮实际高度来定位。
|
||||||
tail = TextBlock("这一行的位置由前面段落的实际高度决定。")
|
tail = TextBlock("这一行的位置由前面段落的实际高度决定。")
|
||||||
actions = Actions(ui.window(), paragraph, full)
|
actions = Actions(ui.window(), paragraph, full)
|
||||||
narrow = Button("变窄")
|
narrow = Button("变窄")
|
||||||
wide = Button("变宽")
|
wide = Button("变宽")
|
||||||
short = Button("短文本")
|
short = Button("短文本")
|
||||||
restore = Button("恢复段落")
|
restore = Button("恢复段落")
|
||||||
|
// 四个连接各绑定同一 Actions 实例的不同事件方法;方法声明的 event 由点击派发提供。
|
||||||
narrowConnection = narrow.on("click", actions.narrow)
|
narrowConnection = narrow.on("click", actions.narrow)
|
||||||
wideConnection = wide.on("click", actions.wide)
|
wideConnection = wide.on("click", actions.wide)
|
||||||
shortConnection = short.on("click", actions.short)
|
shortConnection = short.on("click", actions.short)
|
||||||
restoreConnection = restore.on("click", actions.restore)
|
restoreConnection = restore.on("click", actions.restore)
|
||||||
|
// 主页面先展示四按钮、共享段落和 tail,页面与窗口身份和 paragraph 业务身份分别保存。
|
||||||
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 可有不同高度。
|
||||||
|
// 创建同 VM 的另一个窗口资源,后面的 resize/title/move 是提交后由 GUI 执行的窗口命令。
|
||||||
other = Window()
|
other = Window()
|
||||||
other.resize(720, 500)
|
other.resize(720, 500)
|
||||||
other.setTitle("同一个段落 · 另一组宽度")
|
other.setTitle("同一个段落 · 另一组宽度")
|
||||||
other.moveTo(1700, 80)
|
other.moveTo(1700, 80)
|
||||||
|
// 第二页面使用不同的 Column 宽度上下文,再引用同一个 paragraph,产生独立换行测量和 Mount。
|
||||||
second = Page()
|
second = Page()
|
||||||
second.title = "共享 Paragraph"
|
second.title = "共享 Paragraph"
|
||||||
second.render(Column(24, 16, paragraph))
|
second.render(Column(24, 16, paragraph))
|
||||||
|
// 将此页归到副窗口,不把主窗口 Page 或它的 Mount 直接复制过去。
|
||||||
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("文字换行与相邻布局")
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Native macOS build and app bundle. No Docker, mirror configuration, or tests.
|
# macOS 应用打包入口:构建 release 可执行文件并组装最小 Tessera.app;脚本本身不运行测试或部署。
|
||||||
|
# 执行会产生构建产物,应在已确认的 macOS 构建环境中调用。
|
||||||
|
# 任一命令失败或使用未定义变量即退出,避免在失败编译后继续打包旧文件。
|
||||||
|
# 构建调用方显式执行此脚本才产生文件;阅读或 sh -n 仅检查语法,不会运行下方 Cargo。
|
||||||
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。
|
||||||
|
# 后续复制路径固定为 target/release/tessera,不会随 --target、--target-dir 或 CARGO_TARGET_DIR 自动调整。
|
||||||
|
# 这里确实触发编译,不是只验证清单;依赖锁不匹配时 --locked 要求失败而非自动更新 Cargo.lock。
|
||||||
cargo build --release --locked "$@"
|
cargo build --release --locked "$@"
|
||||||
|
# 从 Cargo.toml 文本读取首个行首 version 值,空值由 test 拒绝;这不是 cargo metadata 的结构化查询。
|
||||||
|
# version 从清单文本读取,后面用于两个 plist 版本键;脚本不调用构建后的 Tessera 来查询版本。
|
||||||
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"
|
||||||
|
# 创建/复用固定 release 产物目录,不清空旧 bundle;后面逐文件替换主二进制和元数据。
|
||||||
mkdir -p "$bundle/Contents/MacOS"
|
mkdir -p "$bundle/Contents/MacOS"
|
||||||
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
|
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
|
||||||
cp target/release/tessera "$bundle/Contents/MacOS/tessera.new"
|
cp target/release/tessera "$bundle/Contents/MacOS/tessera.new"
|
||||||
mv -f "$bundle/Contents/MacOS/tessera.new" "$bundle/Contents/MacOS/tessera"
|
mv -f "$bundle/Contents/MacOS/tessera.new" "$bundle/Contents/MacOS/tessera"
|
||||||
# 生成最小 macOS bundle 元数据;应用名、入口与高分辨率标志供系统启动/显示使用。
|
# 生成最小 macOS bundle 元数据;应用名、入口与高分辨率标志供系统启动/显示使用。
|
||||||
|
# heredoc 展开本次 version,XML 文本与入口文件名相互对应;这里没有签名、公证或安装动作。
|
||||||
cat > "$bundle/Contents/Info.plist.new" <<PLIST
|
cat > "$bundle/Contents/Info.plist.new" <<PLIST
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -31,5 +42,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"
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
#!/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 指向已安装依赖的工具目录;README 给出固定版本安装示例,脚本自身不安装或校验版本。
|
||||||
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';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
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 进程可执行原本面向浏览器的布局过程。
|
||||||
|
// 这些赋值修改当前 Node 进程 globalThis,脚本结束前不恢复;按独立命令行工具使用,不是无全局副作用的库。
|
||||||
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 +26,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 +46,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'}`;
|
||||||
@@ -62,29 +69,38 @@ document.createElementNS = (namespace, tag) => {
|
|||||||
document.body = element('body');
|
document.body = element('body');
|
||||||
document.documentElement.appendChild(document.body);
|
document.documentElement.appendChild(document.body);
|
||||||
globalThis.document = document;
|
globalThis.document = document;
|
||||||
|
// 全局 DOM/Canvas 适配已设置完毕才加载引擎,避免模块初始化读取到缺失的浏览器接口。
|
||||||
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');
|
||||||
|
// 引擎有时以正常 SVG 输出错误页,所以不能只判断 Promise resolve;还要检查文字层的错误标签。
|
||||||
|
// 检查根标签和几类错误文本,不是完整 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);
|
||||||
|
// 序列化修改后的 DOM,而不是直接写引擎原字符串,确保白底矩形进入两个导出格式。
|
||||||
const svg = new XMLSerializer().serializeToString(xml);
|
const svg = new XMLSerializer().serializeToString(xml);
|
||||||
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
|
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
|
||||||
const stem = filename.slice(0, -5);
|
const stem = filename.slice(0, -5);
|
||||||
|
// 两种产物顺序写入且不组成事务;SVG 成功后若 PNG 失败,前面已写出的文件不会自动回滚。
|
||||||
|
// 同名文件直接覆盖;本轮没有对应 .puml 的旧产物不会被自动删除,较早已完成的图也不会因后图失败撤回。
|
||||||
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();
|
||||||
|
|||||||
@@ -0,0 +1,861 @@
|
|||||||
|
//! 通用保留式图形资源桥接:图片、字体、预排文字、绕排避让形状和数值工具。
|
||||||
|
//! ui_bridge::bootstrap 调用 install,把原生 callable 挂到 ui.graphics 与 ui.graphics.math。
|
||||||
|
//! Endor 源程序调用这些函数创建模型资源/绘图命令;source_paint 解码图片命令时调用 decode_image。
|
||||||
|
//! PointerDispatch 由 UiBridge::dispatch 包装成临时 callable,用同一 VM 分发原始指针事件。
|
||||||
|
//! 这里不定义应用剧情或按钮业务。像素资源经 Arc 共享,托管资源身份与引用边仍由 Aster 管理。
|
||||||
|
//! graphics.readText 是初始化用的同步 UTF-8 小文件读取(16 KiB);ui.readText 的后台任务接口另由 io_tasks 实现。
|
||||||
|
//! 图片命令经托管 image 属性拥有资源;source_paint 解码后只把 Arc 像素带入渲染命令,绘制线程无需访问 VM。
|
||||||
|
use crate::{
|
||||||
|
document::{RenderCommand, parse_color},
|
||||||
|
geometry::{Point, Rect},
|
||||||
|
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};
|
||||||
|
/// 本文件的解码和分派分支统一构造 VM 参数错误,让调用栈继续提供源代码诊断位置。
|
||||||
|
fn invalid(message: &'static str) -> VmError {
|
||||||
|
VmError::InvalidArgument(message)
|
||||||
|
}
|
||||||
|
/// 图片/字体/避让对象参数的解码入口,只复制对象句柄;payload 种类由各调用点进一步检查。
|
||||||
|
fn object(v: &Value) -> Result<ObjectRef, VmError> {
|
||||||
|
// 不从 nil、字符串或数字猜测资源 ID,避免把源值误指到 VM 堆中任意对象。
|
||||||
|
if let Value::Object(o) = v {
|
||||||
|
Ok(*o)
|
||||||
|
} else {
|
||||||
|
Err(invalid("expected Object"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// 图形几何参数调用,先用 Endor 数字转换再限制绝对值,最后降为渲染器使用的 f32。
|
||||||
|
fn number(v: &Value) -> Result<f32, VmError> {
|
||||||
|
// 原生签名没有逐参数类型声明,因此 invoke 内仍必须检查值的实际表示。
|
||||||
|
let Value::Number(v) = v else {
|
||||||
|
return Err(invalid("expected Number"));
|
||||||
|
};
|
||||||
|
// to_f64 传播无法有限转换等错误;通用坐标允许负数,宽高/半径的非负约束由具体操作检查。
|
||||||
|
let n = v.to_f64()?;
|
||||||
|
if n.abs() > 1_000_000. {
|
||||||
|
// 在转换 f32 和进入排版/光栅化前限制坐标预算,拒绝极端图形数值。
|
||||||
|
return Err(invalid("graphics number exceeds bounds"));
|
||||||
|
}
|
||||||
|
Ok(n as f32)
|
||||||
|
}
|
||||||
|
/// 文件路径、字体名和源文本参数调用,借用真实字符串而不对任意对象隐式执行 repr。
|
||||||
|
fn text(v: &Value) -> Result<&str, VmError> {
|
||||||
|
if let Value::Text(s) = v {
|
||||||
|
Ok(s)
|
||||||
|
} else {
|
||||||
|
Err(invalid("expected String"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// command/flow/指针事件把原生几何返回语言时调用,统一把 f32 提升为 Endor Number。
|
||||||
|
fn n(v: f32) -> Value {
|
||||||
|
Value::Number((v as f64).into())
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
/// LoadImage/flow 产生的托管资源 payload;Arc 像素可供发布命令持有,不要求 GUI 线程读取 VM。
|
||||||
|
pub(crate) struct ImageResource(pub Arc<Image>);
|
||||||
|
#[derive(Debug)]
|
||||||
|
/// LoadFont 创建、Prepare 读取的字体族句柄,字体注册/塑形细节由 render::text 负责。
|
||||||
|
struct FontResource(TextFamily);
|
||||||
|
#[derive(Debug)]
|
||||||
|
/// Prepare 生成、Flow 消费的字词/字母预排结果,昂贵的塑形可在多次绕排中复用。
|
||||||
|
struct TextResource {
|
||||||
|
// 图形流转中仅复制 Arc,不为每一行重复塑形和生成字形图片。
|
||||||
|
prepared: Arc<Prepared>,
|
||||||
|
// 每次 Flow 的行带高度,ranges 用它判断避让形状与这一行的相交范围。
|
||||||
|
line: f32,
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
/// Circle/Rectangle/ImageExclusion 产生,Flow/ranges 读取的纯原生避让形状,不保存 VM 子对象引用。
|
||||||
|
struct Exclusion {
|
||||||
|
// 圆用圆心/半径描述;多边形资源保持 None,避免两种形状同时隐式叠加。
|
||||||
|
circle: Option<(Point, f32)>,
|
||||||
|
// 图片轮廓或矩形顶点,已经变换到绕排使用的世界/页面逻辑坐标。
|
||||||
|
polygon: Vec<Point>,
|
||||||
|
// 在多边形行带与横向阻塞范围外再扩展的避让距离。
|
||||||
|
padding: f32,
|
||||||
|
}
|
||||||
|
// 四类资源复用 ObjectPayload 适配;没有托管引用字段,因此不需要额外 trace 子对象。
|
||||||
|
macro_rules! resource {
|
||||||
|
($t:ty,$name:literal) => {
|
||||||
|
impl ObjectPayload for $t {
|
||||||
|
// VM 的 payload::<T> 通过 Any 向下转型,防止把普通 Object 当图形资源读取。
|
||||||
|
fn as_any(&self) -> &dyn Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn kind_name(&self) -> &'static str {
|
||||||
|
// 稳定种类名供类型错误/诊断显示,不代替 Endor 名义 Type。
|
||||||
|
$name
|
||||||
|
}
|
||||||
|
fn allows_attributes(&self) -> bool {
|
||||||
|
// 资源宽高等元信息作为普通托管属性暴露,仍经 store 的属性访问规则管理。
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn reference_assignment_only(&self) -> bool {
|
||||||
|
// 赋值共享同一资源对象,不能按值复制资源 payload 后失去既有身份。
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
resource!(ImageResource, "image-resource");
|
||||||
|
resource!(FontResource, "font-resource");
|
||||||
|
resource!(TextResource, "prepared-text");
|
||||||
|
resource!(Exclusion, "layout-exclusion");
|
||||||
|
/// Function/flow 分配原生资源后调用,附当前宿主域的 Object 名义类型,不创建新的资源专属类。
|
||||||
|
fn attach(cx: &mut NativeContext<'_>, o: ObjectRef, desktop: ObjectRef) -> Result<(), VmError> {
|
||||||
|
crate::ui_bridge::attach_desktop_type(cx, o, "Object", desktop)
|
||||||
|
}
|
||||||
|
/// LoadImage/LoadFont/ReadText 的同步文件读取助手;调用者先拒绝布局/绘制只读阶段的 I/O。
|
||||||
|
fn file(path: &str, limit: u64) -> Result<Vec<u8>, VmError> {
|
||||||
|
use std::io::Read;
|
||||||
|
if path.len() > 8192 {
|
||||||
|
// 路径本身也有限长,不能只限制最终文件大小而接受无限字符串操作。
|
||||||
|
return Err(invalid("resource path too long"));
|
||||||
|
}
|
||||||
|
// 文件句柄限定在本调用作用域,任何 ? 返回都会由 Rust 关闭,不放进托管图形资源。
|
||||||
|
let f = std::fs::File::open(path).map_err(|_| invalid("resource file cannot be opened"))?;
|
||||||
|
// 只接收有界普通文件,拒绝目录/特殊设备等无法靠 metadata 给出可靠预算的输入。
|
||||||
|
let meta = f
|
||||||
|
.metadata()
|
||||||
|
.map_err(|_| invalid("resource metadata unavailable"))?;
|
||||||
|
if !meta.is_file() || meta.len() > limit {
|
||||||
|
return Err(invalid("resource must be a bounded regular file"));
|
||||||
|
}
|
||||||
|
// metadata 检查后文件仍可能增长,所以实际读取最多 limit+1 字节并再次检查。
|
||||||
|
let mut data = Vec::new();
|
||||||
|
f.take(limit + 1)
|
||||||
|
.read_to_end(&mut data)
|
||||||
|
.map_err(|_| invalid("resource read failed"))?;
|
||||||
|
if data.len() as u64 > limit {
|
||||||
|
return Err(invalid("resource exceeds byte limit"));
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
/// decode_image 读取 PaintCommand 的数值属性时调用,缺字段/错误类型直接传播,不自行补缺省。
|
||||||
|
fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result<f32, VmError> {
|
||||||
|
number(&s.get_attr(o, key)?)
|
||||||
|
}
|
||||||
|
/// source_paint 的 image 命令分支及 Sprite/ImageExclusion 调用,验证托管命令并提取纯数据图片绘制命令。
|
||||||
|
pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result<RenderCommand, VmError> {
|
||||||
|
// 命令引用真实 ImageResource,不能只靠同名 width/height 属性冒充有效图片。
|
||||||
|
let resource = object(&s.get_attr(c, "image")?)?;
|
||||||
|
// 克隆像素 Arc,使发布结果拥有资源,不再依赖命令/资源 ObjectRef 的 VM 生命周期。
|
||||||
|
let image = s
|
||||||
|
.payload::<ImageResource>(resource, "ImageResource")?
|
||||||
|
.0
|
||||||
|
.clone();
|
||||||
|
// tint 可缺失/nil;显式错误颜色和其他属性读取失败仍报错,不静默退回无着色。
|
||||||
|
let tint = match s.get_attr(c, "tint") {
|
||||||
|
Ok(Value::Text(v)) => Some(parse_color(&v).map_err(|_| invalid("invalid image tint"))?),
|
||||||
|
Ok(Value::Nil) | Err(VmError::AttributeNotFound(_)) => None,
|
||||||
|
_ => return Err(invalid("invalid image tint")),
|
||||||
|
};
|
||||||
|
// 同名局部 c 从对象身份变为原生命令;所有几何字段均经受控 number 解码。
|
||||||
|
let c = RenderCommand::Image {
|
||||||
|
x: field(s, c, "x")?,
|
||||||
|
y: field(s, c, "y")?,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
/// Sprite 和 Flow 生成 image 类 PaintCommand 的共同助手,保留图片托管强边及几何字段。
|
||||||
|
fn command(
|
||||||
|
cx: &mut NativeContext<'_>,
|
||||||
|
desktop: ObjectRef,
|
||||||
|
image: ObjectRef,
|
||||||
|
origin: Point,
|
||||||
|
rect: Rect,
|
||||||
|
angle: f32,
|
||||||
|
opacity: f32,
|
||||||
|
) -> Result<ObjectRef, VmError> {
|
||||||
|
// origin 是旋转定位点,rect.x/y 是相对该原点的局部图像偏移,不与绝对 x/y 混用。
|
||||||
|
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)]
|
||||||
|
/// install 的名称表选择实际操作;arity/invoke 同用此枚举,不依赖函数显示名称猜业务分支。
|
||||||
|
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)]
|
||||||
|
/// install 分配到 VM 的 callable payload,供源代码经 ui.graphics/math 调用。
|
||||||
|
struct Function {
|
||||||
|
// 具体操作决定数量与实现,图形和数值名字可以挂在不同命名空间中共享分派器。
|
||||||
|
op: Op,
|
||||||
|
// 资源/命令的名义类型来自该注册域;captures 同时持有对应托管强边。
|
||||||
|
desktop: ObjectRef,
|
||||||
|
}
|
||||||
|
impl NativeCallable for Function {
|
||||||
|
/// VM 在进入 invoke 前查询精确参数数目,下面按位置索引 args 依赖此调用边界检查。
|
||||||
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||||
|
use Op::*;
|
||||||
|
Some(endor_core::CallArity::exact(match self.op {
|
||||||
|
// 时钟无参数;资源路径和一元数学操作各一参,类型/范围在具体分支校验。
|
||||||
|
Now => 0,
|
||||||
|
LoadImage | ReadText | Float | Sin | Cos | Sqrt | Abs | Stringify | Floor => 1,
|
||||||
|
// 二参分别是路径+字体族、两个操作数,或图片命令+避让边距。
|
||||||
|
LoadFont | Atan2 | Min | Max | ImageExclusion => 2,
|
||||||
|
// 圆/矩形几何、数值上下界和文本切片都有固定数量,不接任意变参。
|
||||||
|
Circle | Clamp | Slice => 3,
|
||||||
|
Rectangle => 4,
|
||||||
|
// Prepare 接文本/字体/字号/行高/颜色/比例;Flow/Sprite 再按各自几何接口解释位置参数。
|
||||||
|
Prepare => 6,
|
||||||
|
Flow => 7,
|
||||||
|
Sprite => 9,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/// VM 执行注册 callable 时调用;同步资源加载拒绝只读阶段,纯资源派生/数学可供源 paint 使用。
|
||||||
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||||
|
use Op::*;
|
||||||
|
// 复制所属类型域身份,后面分配资源和协议值都复用这一个注册表。
|
||||||
|
let desktop = self.desktop;
|
||||||
|
// 各分支产出语言值,除提前返回数学分支外统一包成 NativeResult::Return。
|
||||||
|
let result = match self.op {
|
||||||
|
Now => {
|
||||||
|
if cx.store().is_read_only() {
|
||||||
|
// 时钟变化不能成为只读绘制中的隐式输入,源程序应在事件/任务里采样并存进模型。
|
||||||
|
return Err(invalid(
|
||||||
|
"sample the clock in an event/task and store it in the model",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// 进程级首次调用确定单调起点,返回的是相对秒数,不是 UTC 时间戳或每 VM 独立时钟。
|
||||||
|
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||||
|
Value::Number(
|
||||||
|
START
|
||||||
|
.get_or_init(std::time::Instant::now)
|
||||||
|
.elapsed()
|
||||||
|
.as_secs_f64()
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LoadImage => {
|
||||||
|
if cx.store().is_read_only() {
|
||||||
|
// 源布局/绘制可复用已加载图片,但不允许在只读阶段重新执行文件 I/O。
|
||||||
|
return Err(invalid("load resources outside paint/layout"));
|
||||||
|
}
|
||||||
|
// 先限制输入文件 4 MiB,再由 PNG 解码器校验格式和解码图像预算。
|
||||||
|
let image = Image::png(&file(text(&args[0])?, 4_194_304)?)
|
||||||
|
.map_err(|_| invalid("invalid or oversized PNG"))?;
|
||||||
|
// 宽高取自解码结果,而非源程序可任意伪造的资源属性。
|
||||||
|
let (w, h) = (image.width, image.height);
|
||||||
|
// 托管外壳提供稳定身份,像素仍由 Arc 共享给后续绘图命令。
|
||||||
|
let resource = cx.allocate(ImageResource(image))?;
|
||||||
|
attach(cx, resource, desktop)?;
|
||||||
|
cx.set_attr(resource, "width", n(w as f32))?;
|
||||||
|
cx.set_attr(resource, "height", n(h as f32))?;
|
||||||
|
Value::Object(resource)
|
||||||
|
}
|
||||||
|
LoadFont => {
|
||||||
|
// 不仅文件读取有副作用,register_font 还会修改字体注册表,因此布局/绘制只读回调也不能加载字体。
|
||||||
|
if cx.store().is_read_only() {
|
||||||
|
return Err(invalid("load resources outside paint/layout"));
|
||||||
|
}
|
||||||
|
// 显式字体族名用于注册与后续塑形查找,不把文件名直接当字体族名。
|
||||||
|
let family = text(&args[1])?;
|
||||||
|
if family.len() > 128 {
|
||||||
|
return Err(invalid("font family name too long"));
|
||||||
|
}
|
||||||
|
// 文件读取预算与字体注册表预算分别检查;错误不返回一个看似有效但会回退的字体资源。
|
||||||
|
let font = crate::text::register_font(file(text(&args[0])?, 4_194_304)?, family)
|
||||||
|
.map_err(|_| invalid("invalid font family or registry budget"))?;
|
||||||
|
// FontResource 只保存验证后的族句柄,源代码不能直接改写全局字体数据库。
|
||||||
|
let resource = cx.allocate(FontResource(font))?;
|
||||||
|
attach(cx, resource, desktop)?;
|
||||||
|
Value::Object(resource)
|
||||||
|
}
|
||||||
|
ReadText => {
|
||||||
|
// 同步读取小型资源文本供模型初始化使用;后台异步读取仍走 runtime/io_tasks 的接口。
|
||||||
|
if cx.store().is_read_only() {
|
||||||
|
return Err(invalid("load resources outside paint/layout"));
|
||||||
|
}
|
||||||
|
Value::Text(
|
||||||
|
String::from_utf8(file(text(&args[0])?, 16_384)?)
|
||||||
|
.map_err(|_| invalid("resource is not UTF-8"))?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Slice => {
|
||||||
|
// 源文本切片按 Unicode 标量索引而非 UTF-8 字节下标;先保留原文借用再转换边界。
|
||||||
|
let value = text(&args[0])?;
|
||||||
|
// number 的通用预算之外,这两个索引还必须为非负、递增的整数值。
|
||||||
|
let start = number(&args[1])?;
|
||||||
|
// 结束位置为半开区间上界,不包含该索引上的字符。
|
||||||
|
let end = number(&args[2])?;
|
||||||
|
if start < 0. || end < start || start.fract() != 0. || end.fract() != 0. {
|
||||||
|
return Err(invalid("invalid text slice"));
|
||||||
|
}
|
||||||
|
// char_indices 把字符序号换回合法字节边界;超出文本时夹到末尾,不切开多字节字符。
|
||||||
|
let lo = value
|
||||||
|
.char_indices()
|
||||||
|
.nth(start as usize)
|
||||||
|
.map_or(value.len(), |(i, _)| i);
|
||||||
|
// 与 lo 同一转换规则保证切片边界总在原字符串范围内。
|
||||||
|
let hi = value
|
||||||
|
.char_indices()
|
||||||
|
.nth(end as usize)
|
||||||
|
.map_or(value.len(), |(i, _)| i);
|
||||||
|
Value::Text(value[lo..hi].into())
|
||||||
|
}
|
||||||
|
// 仅格式化标量,不对任意对象调用 repr,避免资源/图形计算触发未知对象逻辑。
|
||||||
|
Stringify => Value::Text(match &args[0] {
|
||||||
|
Value::Text(s) => s.clone(),
|
||||||
|
Value::Number(v) => v.to_string(),
|
||||||
|
Value::Bool(v) => v.to_string(),
|
||||||
|
Value::Nil => "nil".into(),
|
||||||
|
_ => return Err(invalid("string expects scalar value")),
|
||||||
|
}),
|
||||||
|
Prepare => {
|
||||||
|
// 读取源文本并按 UTF-8 字节预算限制一次塑形的内容量。
|
||||||
|
let value = text(&args[0])?;
|
||||||
|
if value.len() > 16_384 {
|
||||||
|
return Err(invalid("prepared text exceeds 16 KiB"));
|
||||||
|
}
|
||||||
|
// 必须是 loadFont 返回的原生 FontResource,普通对象的同名字体属性不能冒充资源。
|
||||||
|
let family = cx
|
||||||
|
.store()
|
||||||
|
.payload::<FontResource>(object(&args[1])?, "FontResource")?
|
||||||
|
.0;
|
||||||
|
// size 是逻辑字体尺寸,line 是逻辑行带高度,scale 只控制字形预绘制清晰度/资源量。
|
||||||
|
let size = number(&args[2])?;
|
||||||
|
// 行高不得小于字号,避免绕排占位与字形高度约定完全失配。
|
||||||
|
let line = number(&args[3])?;
|
||||||
|
// 设备比例有独立预算,不能通过极大 scale 把短文本变成巨量像素资源。
|
||||||
|
let scale = number(&args[5])?;
|
||||||
|
if !(8. ..=96.).contains(&size)
|
||||||
|
|| !(size..=192.).contains(&line)
|
||||||
|
|| !(0.5..=4.).contains(&scale)
|
||||||
|
{
|
||||||
|
return Err(invalid("invalid text metrics"));
|
||||||
|
}
|
||||||
|
// 颜色预先烘入字形图片,后续 flow 不为每个词重复解析/绘制文字。
|
||||||
|
let color =
|
||||||
|
parse_color(text(&args[4])?).map_err(|_| invalid("invalid text color"))?;
|
||||||
|
// 交给 text_flow 完成词/字母拆分、塑形及像素墨迹提取,这里只描述样式与预算。
|
||||||
|
let prepared = Prepared::new(
|
||||||
|
value,
|
||||||
|
TextStyle {
|
||||||
|
font_size: size,
|
||||||
|
line_height: line,
|
||||||
|
family,
|
||||||
|
},
|
||||||
|
scale,
|
||||||
|
color,
|
||||||
|
);
|
||||||
|
// text_flow 的静态错误文本转成 VM 参数错误,源调用得到当前调用栈上下文。
|
||||||
|
let prepared = prepared.map_err(invalid)?;
|
||||||
|
// Arc 让多次 flow 复用同一预排结果,line 单独保存供每行障碍相交测试使用。
|
||||||
|
let resource = cx.allocate(TextResource {
|
||||||
|
prepared: Arc::new(prepared),
|
||||||
|
line,
|
||||||
|
})?;
|
||||||
|
attach(cx, resource, desktop)?;
|
||||||
|
Value::Object(resource)
|
||||||
|
}
|
||||||
|
Sprite => {
|
||||||
|
// Sprite 返回一条变换图片命令,不立即画到屏幕;原图对象必须带真实 ImageResource payload。
|
||||||
|
let image = object(&args[0])?;
|
||||||
|
cx.store()
|
||||||
|
.payload::<ImageResource>(image, "ImageResource")?;
|
||||||
|
// x/y 是旋转定位原点,图像左上角还受 anchor 比例和尺寸影响。
|
||||||
|
let x = number(&args[1])?;
|
||||||
|
// 垂直定位与水平定位使用相同逻辑坐标空间。
|
||||||
|
let y = number(&args[2])?;
|
||||||
|
// 目标宽高允许缩放原图,但命令 validate 会拒绝非法几何范围。
|
||||||
|
let w = number(&args[3])?;
|
||||||
|
// 图片采样继续使用原像素尺寸,不把 h 当作纹理行数。
|
||||||
|
let h = number(&args[4])?;
|
||||||
|
// a 按渲染器约定为弧度,绕 origin 旋转局部矩形。
|
||||||
|
let a = number(&args[5])?;
|
||||||
|
// ax/ay 把图像内部相对锚点换成局部偏移,0 是边缘,0.5 是中心。
|
||||||
|
let ax = number(&args[6])?;
|
||||||
|
// 不在这里默认 clamp 锚点,源代码可以选择矩形之外的旋转中心。
|
||||||
|
let ay = number(&args[7])?;
|
||||||
|
// 透明度除通用有限值外还受 RenderCommand::validate 的 0..1 语义限制。
|
||||||
|
let opacity = number(&args[8])?;
|
||||||
|
// 本地矩形以负锚点偏移定位,确保绘制与 imageExclusion 共用同一几何变换。
|
||||||
|
let c = command(
|
||||||
|
cx,
|
||||||
|
desktop,
|
||||||
|
image,
|
||||||
|
Point::new(x, y),
|
||||||
|
Rect::new(-w * ax, -h * ay, w, h),
|
||||||
|
a,
|
||||||
|
opacity,
|
||||||
|
)?;
|
||||||
|
// 立即复用实际 paint 解码器验证命令,不等到下一帧才暴露非法 sprite 几何。
|
||||||
|
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 {
|
||||||
|
// decode_image 的唯一成功变体是 Image,其他变体到此说明内部接口约定被破坏。
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
// 避让边距只扩展文字禁用区域,不改图片自身的绘制尺寸。
|
||||||
|
let padding = number(&args[1])?;
|
||||||
|
if !(0. ..=1000.).contains(&padding) {
|
||||||
|
return Err(invalid("invalid exclusion padding"));
|
||||||
|
}
|
||||||
|
// 对全部轮廓顶点共用同一旋转三角值,保持与 sprite 绘制的角度一致。
|
||||||
|
let (sin, cos) = angle.sin_cos();
|
||||||
|
// 原图像素轮廓按目标宽高缩放,再加 anchor 偏移、旋转、平移到绕排坐标。
|
||||||
|
let polygon = image
|
||||||
|
.outline
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
// 归一化原图 x 到目标图像宽度,并还原相对旋转中心的局部偏移。
|
||||||
|
let px = offset_x + p.x / image.width as f32 * width;
|
||||||
|
// y 方向使用原图高度归一化,非正方形缩放不能复用宽度比例。
|
||||||
|
let py = offset_y + p.y / image.height as f32 * height;
|
||||||
|
Point::new(x + cos * px - sin * py, y + sin * px + cos * py)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// 轮廓已经是普通 Point 数组,避让资源不再需要引用原图片对象。
|
||||||
|
let e = cx.allocate(Exclusion {
|
||||||
|
circle: None,
|
||||||
|
polygon,
|
||||||
|
padding,
|
||||||
|
})?;
|
||||||
|
attach(cx, e, desktop)?;
|
||||||
|
Value::Object(e)
|
||||||
|
}
|
||||||
|
Circle | Rectangle => {
|
||||||
|
// 圆的前两参是圆心,矩形的前两参是左上角;第三参分别为半径或宽度。
|
||||||
|
let x = number(&args[0])?;
|
||||||
|
// 坐标可负,几何 extent 的非负约束单独检查。
|
||||||
|
let y = number(&args[1])?;
|
||||||
|
// 共用 extent 检查,但不把圆半径误当直径或矩形面积。
|
||||||
|
let size = number(&args[2])?;
|
||||||
|
if size < 0. {
|
||||||
|
return Err(invalid("negative exclusion extent"));
|
||||||
|
}
|
||||||
|
// 圆保留解析几何,矩形用闭合四点多边形,ranges 分别采用相应相交算法。
|
||||||
|
let e = if matches!(self.op, Circle) {
|
||||||
|
Exclusion {
|
||||||
|
circle: Some((Point::new(x, y), size)),
|
||||||
|
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.,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 把纯几何避让值包装成有稳定身份的 VM 对象,供 ObjectList 在 flow 时引用。
|
||||||
|
let e = cx.allocate(e)?;
|
||||||
|
attach(cx, e, desktop)?;
|
||||||
|
Value::Object(e)
|
||||||
|
}
|
||||||
|
// 绕排工作集中在助手中,复用已预排字形与已验证避让资源,返回 DisplayList。
|
||||||
|
Flow => flow(cx, desktop, args)?,
|
||||||
|
_ => {
|
||||||
|
// 数学分支用 f64,和受 1e6 坐标预算的 number 解码区分,允许数学中间量范围更大。
|
||||||
|
let real = |v: &Value| -> Result<f64, VmError> {
|
||||||
|
if let Value::Number(n) = v {
|
||||||
|
// Endor 的整数/十进制/二进制数统一按显式浮点近似参与这些数学函数。
|
||||||
|
n.to_f64()
|
||||||
|
} else {
|
||||||
|
// 不进行文本到数字或布尔到整数的隐式强制转换。
|
||||||
|
Err(invalid("math expects Number"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 所有剩余操作至少有一个数字参数,数量已经由 arity 在 VM 调用边界验证。
|
||||||
|
let x = real(&args[0])?;
|
||||||
|
if matches!(self.op, Floor) {
|
||||||
|
if x.abs() > 1_000_000_000. {
|
||||||
|
// floor 返回整数,先限定转换预算,不能依靠 Rust 饱和转换掩盖超大数值。
|
||||||
|
return Err(invalid("floor result exceeds integer conversion budget"));
|
||||||
|
}
|
||||||
|
return Ok(NativeResult::Return(Value::Number(
|
||||||
|
(x.floor() as i64).into(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// 按操作计算一个结果,二/三元分支继续解析额外数字,不预读不需要的参数。
|
||||||
|
let v = match self.op {
|
||||||
|
Float => x,
|
||||||
|
Sin => x.sin(),
|
||||||
|
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 => {
|
||||||
|
// 下界/上界顺序必须合法,否则 f64::clamp 会 panic,不能让源参数引发宿主崩溃。
|
||||||
|
let lo = real(&args[1])?;
|
||||||
|
// 上界是第三参数,不能交换两端后悄悄修正调用者的错误范围。
|
||||||
|
let hi = real(&args[2])?;
|
||||||
|
if lo > hi {
|
||||||
|
return Err(invalid("clamp minimum exceeds maximum"));
|
||||||
|
}
|
||||||
|
x.clamp(lo, hi)
|
||||||
|
}
|
||||||
|
// 非数学操作已经在外层 match 命中,Floor 也已提前返回,因此不存在其他合法分支。
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
if !v.is_finite() {
|
||||||
|
// sqrt 负数等情况会生成 NaN,必须转成语言错误而非让非有限数流入下一轮图形几何。
|
||||||
|
return Err(invalid("non-finite math result"));
|
||||||
|
}
|
||||||
|
Value::Number(v.into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(NativeResult::Return(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// flow 为每个行带调用:合并避让形状的水平阻塞范围,返回按 x 递增且至少 8 单位宽的可排文字区间。
|
||||||
|
/// 这是按整行带求保守横向范围,不是每个字形轮廓与多边形的精确碰撞检测。
|
||||||
|
fn ranges(left: f32, right: f32, y: f32, line: f32, exclusions: &[&Exclusion]) -> Vec<(f32, f32)> {
|
||||||
|
// 每个形状先贡献一个阻塞区间;最后统一排序/扫线,支持多个重叠障碍。
|
||||||
|
let mut blocked = Vec::new();
|
||||||
|
// 按资源分别求本行投影;不修改 Exclusion,使同一障碍可被多个 Flow 调用和不同文本列共享。
|
||||||
|
for e in exclusions {
|
||||||
|
// 圆直接求解析弦宽;矩形/图片轮廓的 circle 为 None,继续交给下面的多边形边遍历。
|
||||||
|
if let Some((p, r)) = e.circle {
|
||||||
|
// 行带包含圆心时 gap=0,否则为圆心到最近水平边的垂直距离。
|
||||||
|
let gap = (y - p.y).max(p.y - y - line).max(0.);
|
||||||
|
if gap < r {
|
||||||
|
// 勾股关系求该行带可触及的最大半弦长,整段横向范围都留给圆形障碍。
|
||||||
|
let dx = (r * r - gap * gap).sqrt();
|
||||||
|
blocked.push((p.x - dx, p.x + dx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 多边形先把行带上下扩 padding,随后还会把水平投影左右扩 padding。
|
||||||
|
let (top, bottom) = (y - e.padding, y + line + e.padding);
|
||||||
|
// 收集行带内的顶点以及边与上下边界交点,取最小/最大 x 形成保守阻塞范围。
|
||||||
|
let mut xs = Vec::new();
|
||||||
|
for i in 0..e.polygon.len() {
|
||||||
|
// 当前顶点和下一顶点构成一条边;取模闭合最后一条边,不要求调用者重复首顶点。
|
||||||
|
let a = e.polygon[i];
|
||||||
|
// 空多边形不会进入循环,因此这里的取模不会对零求余。
|
||||||
|
let b = e.polygon[(i + 1) % e.polygon.len()];
|
||||||
|
if a.y >= top && a.y <= bottom {
|
||||||
|
// 行带覆盖的顶点直接参与水平包围,避免水平边/角点没有穿越交点时被漏掉。
|
||||||
|
xs.push(a.x);
|
||||||
|
}
|
||||||
|
// 同一条斜边可能跨越行带的上下两边,两个交点都要计入横向包围,不能找到一个就停止。
|
||||||
|
for edge in [top, bottom] {
|
||||||
|
if (a.y < edge && b.y > edge) || (b.y < edge && a.y > edge) {
|
||||||
|
// 只有真正跨过水平边界才插值,分母 b.y-a.y 在此条件下非零。
|
||||||
|
xs.push(a.x + (b.x - a.x) * (edge - a.y) / (b.y - a.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !xs.is_empty() {
|
||||||
|
// 凹多边形也取整体横向包围,宁可留更宽避让,不在行带内钻进不可靠的局部空洞。
|
||||||
|
blocked.push((
|
||||||
|
xs.iter().copied().fold(f32::INFINITY, f32::min) - e.padding,
|
||||||
|
xs.iter().copied().fold(f32::NEG_INFINITY, f32::max) + e.padding,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 先按左端排序,扫描游标便可同时处理重叠和包含关系,避免输出交叉的可用区间。
|
||||||
|
blocked.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
// x 是尚未被处理阻塞覆盖的最左位置,始终夹在文字行的 left..right 范围内。
|
||||||
|
let mut x = left;
|
||||||
|
// 与函数同名的局部值是结果区间数组,后面 Prepared::row 按这个顺序放词。
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
for (lo, hi) in blocked {
|
||||||
|
if hi <= left || lo >= right {
|
||||||
|
// 完全位于文本列外的形状不消耗可用宽度,也不移动扫描游标。
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if lo > x {
|
||||||
|
// 当前阻塞前的空隙才是可用区间;右端不能超过列右边界。
|
||||||
|
ranges.push((x, lo.min(right)));
|
||||||
|
}
|
||||||
|
// 重叠阻塞只推进到更右处,不因下一个区间的左端较小而倒退或重复输出空间。
|
||||||
|
x = x.max(hi).min(right);
|
||||||
|
}
|
||||||
|
if x < right {
|
||||||
|
// 所有阻塞处理后剩余的尾段仍可放文字,整行无障碍时这里返回完整列宽。
|
||||||
|
ranges.push((x, right));
|
||||||
|
}
|
||||||
|
// 极窄缝不进入字词放置,既降低工作量,也避免在几何边缘生成难以使用的片段。
|
||||||
|
ranges.into_iter().filter(|(l, r)| r - l >= 8.).collect()
|
||||||
|
}
|
||||||
|
/// Function 的 Flow 分支调用,把预排文字放入有限矩形并避开障碍,生成包含图片字形的 DisplayList。
|
||||||
|
fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Result<Value, VmError> {
|
||||||
|
// 第一参必须是 Prepare 返回的资源;只靠对象上的同名 prepared 属性不能替代原生 payload。
|
||||||
|
let resource = object(&args[0])?;
|
||||||
|
// 借 payload 取资源元数据,随后复制 Arc/行高以结束不可变借用,允许下面继续分配 VM 对象。
|
||||||
|
let text = cx
|
||||||
|
.store()
|
||||||
|
.payload::<TextResource>(resource, "PreparedText")?;
|
||||||
|
// 只共享塑形与字形图片,不把每次 Flow 的游标写回资源,多个视图可独立排同一份文字。
|
||||||
|
let prepared = text.prepared.clone();
|
||||||
|
// 行高在 Prepare 时已验证为正,下面 height/line 的行数预算因此有明确含义。
|
||||||
|
let line = text.line;
|
||||||
|
// x/y 是本次文字矩形的逻辑原点,与 Exclusion 中的点必须处于同一坐标空间。
|
||||||
|
let x = number(&args[1])?;
|
||||||
|
// y 也作为首行 top,不按字体 baseline 参数解释。
|
||||||
|
let y = number(&args[2])?;
|
||||||
|
// 列宽约束 ranges 的左右边界,不改变预排字形本身的宽度。
|
||||||
|
let width = number(&args[3])?;
|
||||||
|
// 矩形高度限定完整行带数量,行带放不下时停止而非溢出绘制区域。
|
||||||
|
let height = number(&args[4])?;
|
||||||
|
// 数字开关决定完整单词是否拆成独立字母图像命令;不是修改词/字母换行规则的开关。
|
||||||
|
let letters = number(&args[6])? != 0.;
|
||||||
|
if width < 0. || height < 0. || height / line > 1024. {
|
||||||
|
// 限制布局循环最多 1024 行;可用区间全被遮挡时也不能无界向下寻找空间。
|
||||||
|
return Err(invalid("text flow bounds exceed budget"));
|
||||||
|
}
|
||||||
|
// 障碍必须经宿主 ObjectList 的只读元素接口取得,避免在计算中执行任意源迭代协议。
|
||||||
|
let items = crate::object_list::items(cx.store(), object(&args[5])?)?;
|
||||||
|
if items.len() > 512 {
|
||||||
|
return Err(invalid("text flow exceeds 512 exclusions"));
|
||||||
|
}
|
||||||
|
// 每个元素验证真实 Exclusion payload;普通对象不因拥有 circle/polygon 属性就成为障碍资源。
|
||||||
|
let exclusions = items
|
||||||
|
.iter()
|
||||||
|
.map(|&o| cx.store().payload::<Exclusion>(o, "Exclusion"))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
// 第一阶段只计算词/字母摆放,仍未生成任何 VM PaintCommand。
|
||||||
|
let mut placements = Vec::new();
|
||||||
|
// 每次调用从词首开始,资源可复用但消费位置不跨调用保留。
|
||||||
|
let mut cursor = Cursor::default();
|
||||||
|
// 行扫描从原点向下推进,即使一行全被避让也会消耗 line 高度,保证循环有界。
|
||||||
|
let mut top = y;
|
||||||
|
while top + line <= y + height && cursor.word < prepared.words.len() {
|
||||||
|
// 当前行的可用区间随障碍形状变化,不能沿用上一行的投影宽度。
|
||||||
|
let intervals = ranges(x, x + width, top, line, &exclusions);
|
||||||
|
prepared.row(&mut cursor, &intervals, top, &mut placements);
|
||||||
|
top += line;
|
||||||
|
}
|
||||||
|
// 第二阶段将摆放变成图片命令,字符墨迹已在 Prepare 时形成,无需再次字体塑形。
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
for p in placements {
|
||||||
|
// p.word/p.letter 指回预排资源,同一个词的多个出现共享像素图片。
|
||||||
|
let word = &prepared.words[p.word];
|
||||||
|
// 已拆行的单字母必须只画该字母;完整词可按 letters 开关选择逐字图或整词图。
|
||||||
|
let inks = if let Some(i) = p.letter {
|
||||||
|
std::slice::from_ref(&word.letters[i])
|
||||||
|
} else if letters {
|
||||||
|
// 逐字命令便于源程序后续分别变换,每张图仍来自同一份字形预绘制结果。
|
||||||
|
&word.letters[..]
|
||||||
|
} else {
|
||||||
|
// 整词图命令数量更少,布局宽度仍按同一 word 度量,视觉原点与字母模式一致。
|
||||||
|
std::slice::from_ref(&word.ink)
|
||||||
|
};
|
||||||
|
// 字母模式沿词内 advance 累加 x;整词模式只迭代一张图,不重复应用词总宽。
|
||||||
|
let mut px = p.x;
|
||||||
|
for ink in inks {
|
||||||
|
// 为图片共享像素创建托管资源外壳,PaintCommand 的 image 属性形成明确 VM 强边。
|
||||||
|
let image = cx.allocate(ImageResource(ink.image.clone()))?;
|
||||||
|
attach(cx, image, desktop)?;
|
||||||
|
commands.push(command(
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DisplayList 采用受检查 ObjectList,不把 Rust Vec 或任意用户对象当绘制命令集合。
|
||||||
|
let commands = crate::object_list::collection(cx, commands, desktop)?;
|
||||||
|
Ok(Value::Object(crate::native_visual::record(
|
||||||
|
cx,
|
||||||
|
desktop,
|
||||||
|
"DisplayList",
|
||||||
|
vec![("commands", Value::Object(commands))],
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
/// ui_bridge::bootstrap 在类型/协议安装后调用,注册 ui.graphics 和 math 命名空间,不加载资源或执行绘制。
|
||||||
|
pub(crate) fn install(
|
||||||
|
vm: &mut Vm,
|
||||||
|
roots: &mut Vec<ObjectRef>,
|
||||||
|
ui: ObjectRef,
|
||||||
|
desktop: ObjectRef,
|
||||||
|
) -> Result<(), VmError> {
|
||||||
|
// 命名空间自身是普通 Object,显式根由候选运行时统一管理,安装失败时也会清理。
|
||||||
|
let graphics = vm.new_object()?;
|
||||||
|
roots.push(graphics);
|
||||||
|
attach_vm(vm, graphics, "Object", desktop)?;
|
||||||
|
// 数值辅助函数独立挂在 graphics.math,避免把 sin/now 等名字注入用户全局命名空间。
|
||||||
|
let math = vm.new_object()?;
|
||||||
|
roots.push(math);
|
||||||
|
attach_vm(vm, math, "Object", desktop)?;
|
||||||
|
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),
|
||||||
|
] {
|
||||||
|
// 每个入口有独立 Op 标签及同一个 desktop 捕获,VM 根据 arity 检查数量再进入 Function::invoke。
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// install 使用直接 VM 分配而非 NativeContext,借此把命名空间/函数附上已注册名义类型。
|
||||||
|
fn attach_vm(vm: &mut Vm, o: ObjectRef, name: &str, desktop: ObjectRef) -> Result<(), VmError> {
|
||||||
|
// 查精确 Type 身份,不为每个函数重新创建名为 Function 的类型对象。
|
||||||
|
let t = crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)?;
|
||||||
|
vm.attach_type(o, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
/// UiBridge::dispatch 为一次原始 pointer 输入创建的临时 payload,经 VM 调用进入事件传播。
|
||||||
|
pub(crate) struct PointerDispatch {
|
||||||
|
// 接收事件的业务 Block;同一个 Block 可以有多个 Mount,因此不能只存 owner。
|
||||||
|
pub owner: ObjectRef,
|
||||||
|
// 本次具体出现位置,提供挂载状态、局部变换与后续传播路径上下文。
|
||||||
|
pub mount: ObjectRef,
|
||||||
|
// Event 类型和传播资源域来自当前窗口 desktop,不从 GUI 持有的原生指针取得。
|
||||||
|
pub desktop: ObjectRef,
|
||||||
|
// 保留 pointer-down/move/up 等原始事件种类,不自动合成为 click。
|
||||||
|
pub kind: String,
|
||||||
|
// 输入坐标先保留外层空间表示,invoke 在所属 Mount 几何上逆投影。
|
||||||
|
pub point: Point,
|
||||||
|
}
|
||||||
|
impl NativeCallable for PointerDispatch {
|
||||||
|
/// VM 调用无参数临时驱动,全部输入已捕获在 payload 中,避免调用方再次传不匹配目标。
|
||||||
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||||
|
Some(endor_core::CallArity::exact(0))
|
||||||
|
}
|
||||||
|
/// VM 分派临时 callable 时验证 Mount,生成带局部 x/y 的 Event,再交事件模块执行捕获/目标/冒泡。
|
||||||
|
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||||
|
// 再检查实时 payload,GUI 命中时的可交互标记不能保证当前对象仍挂载或仍订阅指针事件。
|
||||||
|
let data = cx
|
||||||
|
.store()
|
||||||
|
.payload::<crate::mount::Mount>(self.mount, "Mount")?;
|
||||||
|
// 几何属于具体出现位置,源对象属性上的 x/y 不能替代投影后的 Mount 几何。
|
||||||
|
let g = &data.geometry;
|
||||||
|
if !data.mounted || !g.pointer_events {
|
||||||
|
return Err(invalid(
|
||||||
|
"pointer target is detached or has no pointer subscriptions",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// 先撤销嵌套视图/面板映射,再撤销该出现的二维视图,失败时不沿用原屏幕坐标。
|
||||||
|
let local = g
|
||||||
|
.view_point(self.point, false)
|
||||||
|
.map(|p| g.view.unwrap_or_default().inverse(p))
|
||||||
|
.ok_or(invalid("pointer cannot be projected"))?;
|
||||||
|
// 目标事件坐标以该出现外框左上角为原点,不是页面/世界绝对位置。
|
||||||
|
let (x, y) = (local.x - g.x, local.y - g.y);
|
||||||
|
// Event 必须与当前宿主域的名义类型一致,不能借用另一个会话的类型身份。
|
||||||
|
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
|
||||||
|
// NativeContext 负责临时根,返回/委托时提升输出,异常路径也不会遗留新建 Event 根。
|
||||||
|
let event = crate::event_dispatch::new_event(cx, definition, &self.kind)?;
|
||||||
|
cx.set_attr(event, "x", n(x))?;
|
||||||
|
cx.set_attr(event, "y", n(y))?;
|
||||||
|
// 事件模块可以返回 Invoke 延续源处理器,仍使用当前 VM 的预算/调用栈而非递归启动解释器。
|
||||||
|
crate::event_dispatch::dispatch(cx, self.owner, event, Some(self.mount), self.desktop)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
|
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
|
||||||
|
//! endor_host 建立会话时安装 ui_bridge 能力,源程序随后通过名义方法调用宿主;渲染线程不直接运行这些回调。
|
||||||
//!
|
//!
|
||||||
// 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;
|
||||||
|
|
||||||
|
/// 注册图片、字体、文字流与避让形状能力,并把托管绘制参数解码为可跨线程使用的不可变资源。
|
||||||
|
/// ui_bridge 安装时调用注册入口,source_paint 解码 image 命令时复用图像检查。
|
||||||
|
pub(crate) mod graphics;
|
||||||
|
|||||||
@@ -1,39 +1,65 @@
|
|||||||
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
|
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
|
||||||
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
|
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
|
||||||
//!
|
//!
|
||||||
//! Allocate and connect the managed desktop, type tables and host globals.
|
//! 调用链:`endor_host::compile` / `ReplSession::new` 创建候选 VM,
|
||||||
|
//! 随后调用本模块的 `install`;运行时再通过 install_globals 注入源程序使用的 UI 值。
|
||||||
|
//! 本文件只建立宿主环境。源程序稍后调用构造器或实例方法时,才会进入 `calls::Function::invoke`。
|
||||||
|
//! 安装返回的 UiBridge 由运行时保存;传入的 roots 由候选运行时持有,并在失败或退役时统一释放。
|
||||||
|
//! 安装顺序还承载 GC 关系:desktop.types 拥有类型,类型拥有工厂/方法,callable 再追踪签名和显式 captures。
|
||||||
|
//! 两阶段补签名保留原对象身份,因此 ui 属性和 TypeConstructor 不需要重新指向另一批函数。
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
/// 为一次候选程序/REPL 会话安装 Tessera 宿主环境,供运行时后续注入全局值和执行源程序。
|
||||||
|
///
|
||||||
|
/// 类型要先创建,签名才能引用它们;因此先分配没有签名的函数,再原地补齐 payload。
|
||||||
|
/// 所有 `?` 都把安装失败交还候选运行时,不会在这里把半成品替换成活跃程序。
|
||||||
|
/// `roots` 保存明确的 VM 拥有根;Rust 的 ObjectRef 数值和 Arc<Mutex<Session>> 均不能替代它们。
|
||||||
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 句柄保活。
|
||||||
|
// desktop 是此桥接域的类型注册表和窗口资源入口;它不是操作系统桌面或原生窗口。
|
||||||
let desktop = vm.new_object()?;
|
let desktop = vm.new_object()?;
|
||||||
roots.push(desktop);
|
roots.push(desktop);
|
||||||
|
// 安装时尚无窗口尺寸;运行时收到 viewport 后再填充,避免在 VM 引导期间依赖 GUI 状态。
|
||||||
vm.set_attr(desktop, "viewport", Value::Nil)?;
|
vm.set_attr(desktop, "viewport", Value::Nil)?;
|
||||||
|
// tabs 是主窗口的托管页面注册表,Presentation 和 window::install 会使用同一字典身份。
|
||||||
let tabs = vm.new_dict()?;
|
let tabs = vm.new_dict()?;
|
||||||
roots.push(tabs);
|
roots.push(tabs);
|
||||||
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
|
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
|
||||||
|
// ui 是对源代码公开的命名空间;desktop 保存内部记账,二者用途不能混同。
|
||||||
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(),
|
||||||
|
// 先登记主窗口上下文;后续窗口通过 create_window 建立各自的 Presentation。
|
||||||
presentations: std::collections::BTreeMap::from([(
|
presentations: std::collections::BTreeMap::from([(
|
||||||
crate::window::MAIN_WINDOW,
|
crate::window::MAIN_WINDOW,
|
||||||
crate::presentation::Presentation::new(desktop, tabs),
|
crate::presentation::Presentation::new(desktop, tabs),
|
||||||
)]),
|
)]),
|
||||||
|
// Session 的 Deref 按 current 选择布局/挂载状态;初始所有操作都落在主窗口。
|
||||||
current: crate::window::MAIN_WINDOW,
|
current: crate::window::MAIN_WINDOW,
|
||||||
|
// master 始终指向主 desktop,副窗口仍借用同一 VM 的类型和资源域。
|
||||||
master: desktop,
|
master: desktop,
|
||||||
|
// 分配器从现有最高窗口 ID 继续增长;Mount 则尚无任何已分配出现位置。
|
||||||
next_window: crate::window::MAIN_WINDOW,
|
next_window: crate::window::MAIN_WINDOW,
|
||||||
next_mount: 0,
|
next_mount: 0,
|
||||||
|
// 关闭后尚未交给 GUI 的原生命令暂存在这里,不能随 Presentation 删除而丢失。
|
||||||
retired_commands: Vec::new(),
|
retired_commands: Vec::new(),
|
||||||
|
// 累计诊断计数由刷新流程更新,供 ui.stats/开发工具观察本会话的实际工作量。
|
||||||
refresh_yields: 0,
|
refresh_yields: 0,
|
||||||
refresh_restarts: 0,
|
refresh_restarts: 0,
|
||||||
layout_cancels: 0,
|
layout_cancels: 0,
|
||||||
paint_cancels: 0,
|
paint_cancels: 0,
|
||||||
paint_slices: 0,
|
paint_slices: 0,
|
||||||
binding_evaluations: 0,
|
binding_evaluations: 0,
|
||||||
|
// 清理阶段会置位,create_window 等操作据此拒绝在退出期间重新创建资源。
|
||||||
exiting: false,
|
exiting: false,
|
||||||
}));
|
}));
|
||||||
|
// 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。
|
||||||
|
// 保存 ui.name 的函数身份及 Operation,等全部类型注册后再为这些函数补齐精确签名。
|
||||||
let mut public_functions = Vec::new();
|
let mut public_functions = Vec::new();
|
||||||
|
// 另外记录 TypeConstructor 所用的工厂;它们多接收一个 Type 首参,不能套用自由函数签名。
|
||||||
let mut factories = Vec::new();
|
let mut factories = Vec::new();
|
||||||
for (name, operation) in [
|
for (name, operation) in [
|
||||||
("implement", Operation::Implement),
|
("implement", Operation::Implement),
|
||||||
@@ -60,6 +86,8 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
("viewport", Operation::Viewport),
|
("viewport", Operation::Viewport),
|
||||||
("window", Operation::Window),
|
("window", Operation::Window),
|
||||||
] {
|
] {
|
||||||
|
// 每个名字得到自己的 VM callable,实际操作由 Function 根据 operation 分派。
|
||||||
|
// desktop 放在 captures 中,确保函数仍可达时它所依赖的类型注册表也能被 GC 追踪。
|
||||||
let function = vm.allocate(ContextNativeFunction::new(
|
let function = vm.allocate(ContextNativeFunction::new(
|
||||||
name,
|
name,
|
||||||
Function {
|
Function {
|
||||||
@@ -72,17 +100,21 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
vec![desktop],
|
vec![desktop],
|
||||||
))?;
|
))?;
|
||||||
roots.push(function);
|
roots.push(function);
|
||||||
|
// 属性供源程序的 ui.text/ui.present 等查询;列表供本函数第二阶段补签名,职责不同。
|
||||||
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))?;
|
||||||
vm.set_attr(ui, "Type", Value::Object(root_types.type_type))?;
|
vm.set_attr(ui, "Type", Value::Object(root_types.type_type))?;
|
||||||
|
// definitions 汇集全部类型,稍后写入 desktop.types,供 desktop_type 按名称查精确身份。
|
||||||
let mut definitions = vec![
|
let mut definitions = vec![
|
||||||
("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),
|
||||||
@@ -122,9 +154,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
("Task", "Object", None),
|
("Task", "Object", None),
|
||||||
("ObjectList", "Object", Some("object-list")),
|
("ObjectList", "Object", Some("object-list")),
|
||||||
] {
|
] {
|
||||||
|
// 依照表中基类名取得当前 VM 的真实类型,不能用另一个 VM 中的同名 Type 代替。
|
||||||
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") => {
|
||||||
|
// ObjectList 构造要保持托管元素身份与字段规则,由 object_list::Constructor 处理。
|
||||||
let factory = vm.allocate(ContextNativeFunction::new(
|
let factory = vm.allocate(ContextNativeFunction::new(
|
||||||
"object-list",
|
"object-list",
|
||||||
crate::object_list::Constructor {
|
crate::object_list::Constructor {
|
||||||
@@ -134,22 +170,26 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
vec![desktop],
|
vec![desktop],
|
||||||
))?;
|
))?;
|
||||||
roots.push(factory);
|
roots.push(factory);
|
||||||
|
// 工厂本身也是语言中的 Function 实例,挂类型后才能通过名义函数检查。
|
||||||
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
||||||
vm.attach_type(factory, function_type)?;
|
vm.attach_type(factory, function_type)?;
|
||||||
TypeConstructor::NativeTyped(factory)
|
TypeConstructor::NativeTyped(factory)
|
||||||
}
|
}
|
||||||
Some("event") => {
|
Some("event") => {
|
||||||
|
// Event 另有必填事件名;其专用 Constructor 在真正调用时解析事件参数和子类字段。
|
||||||
let factory = vm.allocate(ContextNativeFunction::new(
|
let factory = vm.allocate(ContextNativeFunction::new(
|
||||||
"event",
|
"event",
|
||||||
crate::event_dispatch::Constructor { signature: None },
|
crate::event_dispatch::Constructor { signature: None },
|
||||||
vec![],
|
vec![],
|
||||||
))?;
|
))?;
|
||||||
roots.push(factory);
|
roots.push(factory);
|
||||||
|
// 与列表工厂保持相同函数类型体系;无 desktop 捕获的分配器不额外引入桌面强边。
|
||||||
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
||||||
vm.attach_type(factory, function_type)?;
|
vm.attach_type(factory, function_type)?;
|
||||||
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,
|
||||||
@@ -169,6 +209,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
.map(Operation::SceneObject)
|
.map(Operation::SceneObject)
|
||||||
.ok_or(invalid("unknown native type allocator"))?,
|
.ok_or(invalid("unknown native type allocator"))?,
|
||||||
};
|
};
|
||||||
|
// 通用 NativeFactory 在调用时区分原生参数与源子类字段,并保留 requested Type。
|
||||||
let factory = vm.allocate(ContextNativeFunction::new(
|
let factory = vm.allocate(ContextNativeFunction::new(
|
||||||
constructor_name,
|
constructor_name,
|
||||||
NativeFactory {
|
NativeFactory {
|
||||||
@@ -180,21 +221,26 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
vec![desktop],
|
vec![desktop],
|
||||||
))?;
|
))?;
|
||||||
roots.push(factory);
|
roots.push(factory);
|
||||||
|
// 这个函数属于构造器元数据,不直接放在 ui.name;但仍需标为 Function 实例。
|
||||||
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
||||||
vm.attach_type(factory, function_type)?;
|
vm.attach_type(factory, function_type)?;
|
||||||
TypeConstructor::NativeTyped(factory)
|
TypeConstructor::NativeTyped(factory)
|
||||||
}
|
}
|
||||||
|
// 没有公开分配规则的类型仍可用于类型检查/协议;直接把它当构造器调用会由 VM 拒绝。
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
// 生成新 Type 后同时公开到 ui,并加入待提交注册表,两个入口必须指向同一对象。
|
||||||
let definition = vm.new_type(name, base, constructor)?;
|
let definition = vm.new_type(name, base, constructor)?;
|
||||||
roots.push(definition);
|
roots.push(definition);
|
||||||
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",
|
||||||
@@ -208,18 +254,22 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
("PaintCommand", vec![]),
|
("PaintCommand", vec![]),
|
||||||
("CameraGesture", vec![]),
|
("CameraGesture", vec![]),
|
||||||
] {
|
] {
|
||||||
|
// 表里的字段名转成 VM 可检查的字段声明;map 的每次调用只描述一个字段,不执行布局。
|
||||||
let fields = fields
|
let fields = fields
|
||||||
.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 {
|
||||||
|
// 余下字段都是测量/几何数值,让 VM 在实例化时先拒绝非数字实参。
|
||||||
"Number"
|
"Number"
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
// 这些协议值只携带源字段,使用普通 class 分配;不需要 Block 的原生 payload。
|
||||||
let definition = vm.new_class(
|
let definition = vm.new_class(
|
||||||
name,
|
name,
|
||||||
root_types.object,
|
root_types.object,
|
||||||
@@ -232,17 +282,23 @@ 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 中注册的精确类型身份。
|
||||||
|
// 类型字典由 desktop 属性托管持有;后续桥接调用可从其查找 Block、Event 等精确类型。
|
||||||
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 {
|
||||||
|
// 字符串键是宿主查询约定,值才是参与 is_instance/is_subtype 的真实名义身份。
|
||||||
vm.dict_set(types, Value::Text(name.into()), Value::Object(definition))?;
|
vm.dict_set(types, Value::Text(name.into()), Value::Object(definition))?;
|
||||||
}
|
}
|
||||||
vm.set_attr(desktop, "types", Value::Object(types))?;
|
vm.set_attr(desktop, "types", Value::Object(types))?;
|
||||||
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)?;
|
||||||
|
// 安装后续编译函数的默认类型,也用于下方所有方法 wrapper 的 attach_type。
|
||||||
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,
|
||||||
@@ -260,6 +316,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
for (name, function) in factories {
|
for (name, function) in factories {
|
||||||
|
// 恢复第一阶段记录的分配器类别;Event/ObjectList 不走 NativeFactory,需要单独补外壳。
|
||||||
let operation = match name {
|
let operation = match name {
|
||||||
"text" => Some(Operation::Text),
|
"text" => Some(Operation::Text),
|
||||||
"rect" => Some(Operation::Rect),
|
"rect" => Some(Operation::Rect),
|
||||||
@@ -275,6 +332,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
"button" => Some(Operation::Button),
|
"button" => Some(Operation::Button),
|
||||||
"page" => Some(Operation::Page),
|
"page" => Some(Operation::Page),
|
||||||
"window-create" => Some(Operation::NewWindow),
|
"window-create" => Some(Operation::NewWindow),
|
||||||
|
// None 在本轮只表示专用构造器,不表示前一阶段的 Abstract 类型。
|
||||||
"event" | "object-list" => None,
|
"event" | "object-list" => None,
|
||||||
name => Some(
|
name => Some(
|
||||||
crate::scene3d_model::Constructor::from_function(name)
|
crate::scene3d_model::Constructor::from_function(name)
|
||||||
@@ -283,7 +341,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
if let Some(operation) = operation {
|
if let Some(operation) = operation {
|
||||||
|
// options 只描述原生构造选项,调用时由 NativeFactory 在切掉 Type 和子类字段后校验。
|
||||||
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()],
|
||||||
@@ -291,7 +351,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
returns: options.returns.clone(),
|
returns: options.returns.clone(),
|
||||||
effect: options.effect,
|
effect: options.effect,
|
||||||
};
|
};
|
||||||
|
// 初始强边是注册表;随后追加签名引用的类型,供 ContextNativeFunction::trace 使用。
|
||||||
let mut captures = vec![desktop];
|
let mut captures = vec![desktop];
|
||||||
|
// ContextNativeFunction 自动追踪外壳 signature,但内部 options 不对它暴露;窄参数中的名义类型要额外加入 captures。
|
||||||
|
// 即使 desktop.types 当前也能保活它们,仍显式枚举 options 真正使用的引用,不把内部签名藏在不可见的 Rust 拥有图中。
|
||||||
captures.extend(options.references());
|
captures.extend(options.references());
|
||||||
vm.replace_payload(
|
vm.replace_payload(
|
||||||
function,
|
function,
|
||||||
@@ -307,19 +370,27 @@ 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 和 ObjectList;这里的名字决定返回契约而不是业务实例类型。
|
||||||
"Event"
|
"Event"
|
||||||
} else {
|
} else {
|
||||||
"ObjectList"
|
"ObjectList"
|
||||||
};
|
};
|
||||||
|
// Event 的 Type 之后还有必填字符串事件名;ObjectList 只有 Type 为必填,其余由实现解释。
|
||||||
let signature = CallSignature {
|
let signature = CallSignature {
|
||||||
arity: endor_core::CallArity::range(
|
arity: endor_core::CallArity::range(
|
||||||
|
// 这里包含 Type 首参;源子类字段由构造器验证,所以最大个数保留开放。
|
||||||
if name == "event" { 2 } else { 1 },
|
if name == "event" { 2 } else { 1 },
|
||||||
usize::MAX,
|
usize::MAX,
|
||||||
),
|
),
|
||||||
|
// 固定前缀与 required 数量保持一致,额外实参由 variadic 和字段验证共同约束。
|
||||||
parameters: if name == "event" {
|
parameters: if name == "event" {
|
||||||
|
// Event 的事件名在外壳阶段就要求 String;随后专用构造器再拆出其余子类字段。
|
||||||
vec![AnnotationType::Type.into(), AnnotationType::String.into()]
|
vec![AnnotationType::Type.into(), AnnotationType::String.into()]
|
||||||
} else {
|
} else {
|
||||||
|
// ObjectList 元素和子类字段都在开放尾参中,由构造器按 requested Type 的字段声明分界。
|
||||||
vec![AnnotationType::Type.into()]
|
vec![AnnotationType::Type.into()]
|
||||||
},
|
},
|
||||||
variadic: Some(AnnotationType::Any.into()),
|
variadic: Some(AnnotationType::Any.into()),
|
||||||
@@ -328,6 +399,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
effect: CallEffect::ReadOnly,
|
effect: CallEffect::ReadOnly,
|
||||||
};
|
};
|
||||||
if name == "event" {
|
if name == "event" {
|
||||||
|
// 原地更新 Event 工厂,TypeConstructor 中已保存的对象引用继续有效。
|
||||||
vm.replace_payload(
|
vm.replace_payload(
|
||||||
function,
|
function,
|
||||||
ContextNativeFunction::new(
|
ContextNativeFunction::new(
|
||||||
@@ -339,6 +411,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
),
|
),
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
|
// 这里只有 ObjectList;保留 desktop 捕获,以便专用分配器给结果附加列表类型。
|
||||||
vm.replace_payload(
|
vm.replace_payload(
|
||||||
function,
|
function,
|
||||||
ContextNativeFunction::new(
|
ContextNativeFunction::new(
|
||||||
@@ -353,6 +426,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 对这批公开入口补 Function 类型身份;不重新分配 callable,ui 属性仍指向原对象。
|
||||||
for name in [
|
for name in [
|
||||||
"open",
|
"open",
|
||||||
"text",
|
"text",
|
||||||
@@ -374,9 +448,11 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
"implements",
|
"implements",
|
||||||
"supports",
|
"supports",
|
||||||
] {
|
] {
|
||||||
|
// 从 ui 取回前面已补完签名的函数;名称表与安装表不一致会在这里报错而非静默漏装。
|
||||||
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),
|
||||||
@@ -420,7 +496,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|||||||
("Page", "back", Operation::Back),
|
("Page", "back", Operation::Back),
|
||||||
("Page", "forward", Operation::Forward),
|
("Page", "forward", Operation::Forward),
|
||||||
] {
|
] {
|
||||||
|
// expected 同时用于方法所属类型、receiver 校验和签名中的名义参数身份。
|
||||||
let expected = object(Some(&vm.get_attr(ui, type_name)?))?;
|
let expected = object(Some(&vm.get_attr(ui, type_name)?))?;
|
||||||
|
// VM 的方法分派负责绑定 receiver;TypeMethod 再验证它并转交 Function 执行业务操作。
|
||||||
let method = vm.allocate(ContextNativeFunction::new(
|
let method = vm.allocate(ContextNativeFunction::new(
|
||||||
name,
|
name,
|
||||||
TypeMethod {
|
TypeMethod {
|
||||||
@@ -429,21 +507,31 @@ 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)?;
|
||||||
|
// 默认 measure/arrange/paint 安装到 Block 体系;源代码之后可按协议覆盖它们。
|
||||||
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)?;
|
||||||
|
// 事件方法需要识别 Event 实例;订阅返回值则使用 Subscription 身份管理取消连接。
|
||||||
let event_type = object(Some(&vm.get_attr(ui, "Event")?))?;
|
let event_type = object(Some(&vm.get_attr(ui, "Event")?))?;
|
||||||
|
// token_type 传给事件安装器,不能拿 Event 类型代替,否则取消令牌会违反返回契约。
|
||||||
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。
|
||||||
|
// Scheduler 随 UiBridge 交还运行时;它还未开始执行用户任务,只完成 ui 异步 API 注册。
|
||||||
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)?;
|
||||||
|
// 将语言命名空间、展示会话与任务调度打包,运行时随后通过同一 bridge 推进刷新与回调。
|
||||||
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)?;
|
||||||
Ok(bridge)
|
Ok(bridge)
|
||||||
|
|||||||
@@ -1,44 +1,59 @@
|
|||||||
//! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。
|
//! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。
|
||||||
//! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。
|
//! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。
|
||||||
//!
|
//!
|
||||||
//! Native Block operations executed on the owning VM worker.
|
//! 调用来源:VM 直接执行 bootstrap 注册的 ui 自由函数,或由 TypeMethod/NativeFactory 剥离接收者/Type 后转交。
|
||||||
|
//! Function.owner 保存绑定对象,requested_type 保存原生子类构造请求;本文件 args 只包含显式原生参数。
|
||||||
|
//! 简单分支同步返回 NativeResult::Return;需要执行源协议/事件处理器的分支返回 Invoke,让 VM 接着推进调用栈。
|
||||||
|
//! NativeContext 追踪本次分配和 upgrade 的临时根,返回/委托时提升输出引用,其余在域退出时释放。
|
||||||
|
//! 下面的提前返回按能力分层:相机/场景、查询/状态、构造、展示协议先处理,最后才要求 Block owner 并克隆 Data。
|
||||||
|
//! 这不是统一回滚事务:有些分支会先写托管属性或窗口注册表,末尾 replace_payload 只提交仍需写回的结构副本。
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl NativeCallable for Function {
|
impl NativeCallable for Function {
|
||||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
/// VM 在调用边界查询 bootstrap/bind 写入的签名;由外层已校验的临时转交 Function 可返回 None。
|
||||||
fn signature(&self) -> Option<CallSignature> {
|
fn signature(&self) -> Option<CallSignature> {
|
||||||
self.signature.clone()
|
self.signature.clone()
|
||||||
}
|
}
|
||||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
/// VM 查询自由函数的实参数量,或供转交适配器使用;这里不再给 receiver/Type 首参加一。
|
||||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||||
Some(self.operation.arity())
|
Some(self.operation.arity())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
/// VM 或外层原生调用适配器进入的实际业务分派,返回借用结果或委托;不负责 GUI 帧的原子发布。
|
||||||
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::*;
|
||||||
|
// 外层 TypeMethod/NativeFactory 会直接调用本方法,因此这里仍需防御性检查参数数量。
|
||||||
let arity = self.operation.arity();
|
let arity = self.operation.arity();
|
||||||
|
// 显式区间不包含已剥离的 receiver/Type,后面 args[0] 等索引依赖这次验证。
|
||||||
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"));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, Camera2D) {
|
if matches!(self.operation, Camera2D) {
|
||||||
|
// 独立相机不是 Block,不进入下方的视觉 Data.kind 构造;requested_type 保留源子类。
|
||||||
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 {
|
||||||
|
// 只复制当前类型域身份,放锁后进入三维分配器;该分配器校验 kind 的实参与子类约束。
|
||||||
let desktop = self
|
let desktop = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?
|
.map_err(|_| invalid("UI session poisoned"))?
|
||||||
.desktop;
|
.desktop;
|
||||||
|
// 类型构造保留请求身份,自由工厂选择当前 desktop 中 kind.name 对应的默认类型。
|
||||||
let definition =
|
let definition =
|
||||||
self.requested_type
|
self.requested_type
|
||||||
.unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?);
|
.unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?);
|
||||||
|
// model::scene3d 建立托管几何/场景对象,此刻不投影、不光栅化也不建立 OS 资源。
|
||||||
let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?;
|
let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?;
|
||||||
return Ok(NativeResult::Return(Value::Object(object)));
|
return Ok(NativeResult::Return(Value::Object(object)));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, CameraControl) {
|
if matches!(self.operation, CameraControl) {
|
||||||
|
// receiver 是控制器,两个显式参数是相机与手势;实际状态更新由 scene3d_model::control 解释。
|
||||||
crate::scene3d_model::control(
|
crate::scene3d_model::control(
|
||||||
cx,
|
cx,
|
||||||
self.owner
|
self.owner
|
||||||
@@ -49,6 +64,7 @@ impl NativeCallable for Function {
|
|||||||
return Ok(NativeResult::Return(Value::Nil));
|
return Ok(NativeResult::Return(Value::Nil));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, Translate3D | Rotate3D) {
|
if matches!(self.operation, Translate3D | Rotate3D) {
|
||||||
|
// 三分量实参共用转换入口,末尾布尔值区分旋转/位移;对象缺少 owner 时直接报错。
|
||||||
crate::scene3d_model::transform(
|
crate::scene3d_model::transform(
|
||||||
cx,
|
cx,
|
||||||
self.owner
|
self.owner
|
||||||
@@ -58,10 +74,14 @@ 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) {
|
||||||
|
// 第二实参必须是托管 Trait,注册和查询共用身份,但标量 supports 仍需先验证 Trait 本身。
|
||||||
let capability = object(args.get(1))?;
|
let capability = object(args.get(1))?;
|
||||||
|
// 此分支计算语言返回值,随后统一包 Return;只有 Implement 真正修改类型关系。
|
||||||
let value = match self.operation {
|
let value = match self.operation {
|
||||||
Implement => {
|
Implement => {
|
||||||
|
// 第一参数是 Type,VM 的 implement_trait 检查能力契约并建立托管引用关系。
|
||||||
cx.implement_trait(object(args.first())?, capability)?;
|
cx.implement_trait(object(args.first())?, capability)?;
|
||||||
Value::Nil
|
Value::Nil
|
||||||
}
|
}
|
||||||
@@ -70,71 +90,92 @@ impl NativeCallable for Function {
|
|||||||
.implements_trait(object(args.first())?, capability)?,
|
.implements_trait(object(args.first())?, capability)?,
|
||||||
),
|
),
|
||||||
Supports => {
|
Supports => {
|
||||||
// Validate the contract even for immediate scalar values.
|
// 即使第一参数是标量也先验证第二参数真为 Trait;不能用 false 掩盖错误能力对象。
|
||||||
cx.store()
|
cx.store()
|
||||||
.payload::<endor_core::EndorTraitObject>(capability, "Trait")?;
|
.payload::<endor_core::EndorTraitObject>(capability, "Trait")?;
|
||||||
Value::Bool(match args[0] {
|
Value::Bool(match args[0] {
|
||||||
|
// 对象按实际能力契约查询,标量没有这里的宿主对象能力而返回 false。
|
||||||
Value::Object(value) => cx.store().supports(value, capability)?,
|
Value::Object(value) => cx.store().supports(value, capability)?,
|
||||||
_ => false,
|
_ => false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 外层 matches 已限定三种操作,其他枚举值到这里表示分派实现内部不一致。
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
return Ok(NativeResult::Return(value));
|
return Ok(NativeResult::Return(value));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, IsInstance | IsSubtype) {
|
if matches!(self.operation, IsInstance | IsSubtype) {
|
||||||
|
// 当前实现要求两者都解码成对象:签名中的 Any 不等于可在这里传 nil 后获得 false。
|
||||||
let candidate = object(args.first())?;
|
let candidate = object(args.first())?;
|
||||||
|
// expected 必须属于此 VM 的真实类型体系;关系查询不按类型名字字符串比较。
|
||||||
let expected = object(args.get(1))?;
|
let expected = object(args.get(1))?;
|
||||||
|
// 实例关系检查对象的类型,子类型关系检查两个 Type 的继承关系,不能混为同一查询。
|
||||||
let value = if matches!(self.operation, IsInstance) {
|
let value = if matches!(self.operation, IsInstance) {
|
||||||
cx.store().is_instance(candidate, expected)?
|
cx.store().is_instance(candidate, expected)?
|
||||||
} else {
|
} else {
|
||||||
|
// isSubtype 用于类型间关系,不把 candidate 当实例读取其 type_of。
|
||||||
cx.store().is_subtype(candidate, expected)?
|
cx.store().is_subtype(candidate, expected)?
|
||||||
};
|
};
|
||||||
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() {
|
||||||
|
// measure/paint 等只读域不能靠显式失效反复制造下一轮更新反馈。
|
||||||
return Err(invalid(
|
return Err(invalid(
|
||||||
"invalidation is forbidden during read-only rendering",
|
"invalidation is forbidden during read-only rendering",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// 仅省略参数才默认 layout,错误类型不回退;transpose 保留显式解析失败。
|
||||||
let reason = args
|
let reason = args
|
||||||
.first()
|
.first()
|
||||||
.map(|value| text(Some(value)))
|
.map(|value| text(Some(value)))
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or_else(|| "layout".into());
|
.unwrap_or_else(|| "layout".into());
|
||||||
if !matches!(reason.as_str(), "paint" | "layout" | "content") {
|
if !matches!(reason.as_str(), "paint" | "layout" | "content") {
|
||||||
|
// 精度标签必须已知,否则不能可靠决定清哪些缓存和依赖集合。
|
||||||
return Err(invalid(
|
return Err(invalid(
|
||||||
"invalidation reason must be paint, layout or content",
|
"invalidation reason must be paint, layout or content",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// TypeMethod 已把 receiver 移入 owner,没有绑定对象的自由调用不能执行此方法。
|
||||||
let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?;
|
let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?;
|
||||||
|
// 以解析后的共享 Data 身份归并别名,不能只让调用者这一个包装对象失效。
|
||||||
let (data, _) = block::resolve(cx.store(), owner)?;
|
let (data, _) = block::resolve(cx.store(), owner)?;
|
||||||
|
// 锁内只遍历弱节点并更新派生失效状态,不在这里调用用户布局/绘制方法。
|
||||||
let mut session = self
|
let mut session = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
|
// 去重后的 Block 身份集合供每个窗口判断是否包含同一份受影响的共享业务状态。
|
||||||
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
|
||||||
{
|
{
|
||||||
|
// NativeContext 持有升级临时根直到本次调用结束,不为弱索引永久添加拥有关系。
|
||||||
affected.insert(block);
|
affected.insert(block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for presentation in session.presentations.values_mut() {
|
for presentation in session.presentations.values_mut() {
|
||||||
|
// 同一 Data 可以出现在多个窗口,失效通知必须广播,不能仅清 current 的缓存。
|
||||||
presentation.invalidate(cx, &affected, &reason)?;
|
presentation.invalidate(cx, &affected, &reason)?;
|
||||||
}
|
}
|
||||||
return Ok(NativeResult::Return(Value::Nil));
|
return Ok(NativeResult::Return(Value::Nil));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, Mounts) {
|
if matches!(self.operation, Mounts) {
|
||||||
|
// 返回的是这个 Block 的具体展示出现,不能把底层 Data 相同的其他别名也算到 owner 上。
|
||||||
let owner = self.owner.ok_or(invalid("mount query requires a Block"))?;
|
let owner = self.owner.ok_or(invalid("mount query requires a Block"))?;
|
||||||
|
// 查全部窗口,锁内只读取原生 Mount 表与托管 payload,不执行源回调。
|
||||||
let session = self
|
let session = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
|
// 暂留窗口/出现 ID 作为排序键,最后列表只暴露 Mount 对象,不暴露宿主元组。
|
||||||
let mut positions = Vec::new();
|
let mut positions = Vec::new();
|
||||||
for (window, presentation) in &session.presentations {
|
for (window, presentation) in &session.presentations {
|
||||||
|
// 每窗活动表只有当前仍展示的出现,死亡弱项和其他 Block 的出现都不加入结果。
|
||||||
for (id, weak) in &presentation.mounts.active {
|
for (id, weak) in &presentation.mounts.active {
|
||||||
if let Some(object) = cx.upgrade(*weak)?
|
if let Some(object) = cx.upgrade(*weak)?
|
||||||
&& cx
|
&& cx
|
||||||
@@ -147,7 +188,9 @@ 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));
|
||||||
|
// 用专用托管集合保留结果引用和只读访问契约,不把 Rust Vec 直接当源值返回。
|
||||||
let set = crate::mount::collection(
|
let set = crate::mount::collection(
|
||||||
cx,
|
cx,
|
||||||
positions.into_iter().map(|(_, _, object)| object).collect(),
|
positions.into_iter().map(|(_, _, object)| object).collect(),
|
||||||
@@ -156,29 +199,36 @@ impl NativeCallable for Function {
|
|||||||
return Ok(NativeResult::Return(Value::Object(set)));
|
return Ok(NativeResult::Return(Value::Object(set)));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, NewWindow) {
|
if matches!(self.operation, NewWindow) {
|
||||||
|
// 持锁分配新的窗口 ID 并建立 Presentation,create_window 还会检查只读/退出/数量限制。
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
|
// Window 只能通过 Type 构造入口创建,缺少 requested_type 说明不是合法的类型调用。
|
||||||
let definition = self
|
let definition = self
|
||||||
.requested_type
|
.requested_type
|
||||||
.ok_or(invalid("Window constructor requires a type"))?;
|
.ok_or(invalid("Window constructor requires a type"))?;
|
||||||
|
// 返回托管资源对象,原生窗口由随后发布的帧/命令驱动 GUI 创建。
|
||||||
let window = create_window(cx, &mut state, definition)?;
|
let window = create_window(cx, &mut state, definition)?;
|
||||||
return Ok(NativeResult::Return(Value::Object(window)));
|
return Ok(NativeResult::Return(Value::Object(window)));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, CloseWindow) {
|
if matches!(self.operation, CloseWindow) {
|
||||||
|
// 资源对象来自 bound receiver,不用 current 猜要关闭哪个窗口。
|
||||||
let window = self
|
let window = self
|
||||||
.owner
|
.owner
|
||||||
.ok_or(invalid("window close requires a receiver"))?;
|
.ok_or(invalid("window close requires a receiver"))?;
|
||||||
|
// 用 payload 的资源 ID 定位 Presentation;关闭状态由 window::close 在模型中维护。
|
||||||
let id = cx
|
let id = cx
|
||||||
.store()
|
.store()
|
||||||
.payload::<crate::window::ManagedWindow>(window, "Window")?
|
.payload::<crate::window::ManagedWindow>(window, "Window")?
|
||||||
.id;
|
.id;
|
||||||
crate::window::close(cx, window)?;
|
crate::window::close(cx, window)?;
|
||||||
|
// model 关闭成功后才改宿主展示状态,关闭失败不能提前标记为已退场。
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.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);
|
||||||
@@ -188,6 +238,7 @@ impl NativeCallable for Function {
|
|||||||
return Ok(NativeResult::Return(Value::Nil));
|
return Ok(NativeResult::Return(Value::Nil));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, Window) {
|
if matches!(self.operation, Window) {
|
||||||
|
// ui.window() 查询当前选择窗口的资源对象;和 Window() 分配新窗口的路径不同。
|
||||||
let desktop = self
|
let desktop = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
@@ -198,15 +249,19 @@ impl NativeCallable for Function {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, Viewport) {
|
if matches!(self.operation, Viewport) {
|
||||||
|
// ui.viewport() 从当前窗口的输入对象生成结果,不直接读取 winit/OS 尺寸。
|
||||||
let desktop = self
|
let desktop = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?
|
.map_err(|_| invalid("UI session poisoned"))?
|
||||||
.desktop;
|
.desktop;
|
||||||
|
// 运行时还没提供尺寸时属性为 nil,明确报不可用,不把它伪装成零宽高。
|
||||||
let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else {
|
let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else {
|
||||||
return Err(invalid("window viewport is unavailable"));
|
return Err(invalid("window viewport is unavailable"));
|
||||||
};
|
};
|
||||||
|
// 两个普通属性读取参与 VM 依赖跟踪,尺寸变化可让调用方的测量失效。
|
||||||
let width = cx.store().get_attr(input, "width")?;
|
let width = cx.store().get_attr(input, "width")?;
|
||||||
|
// 返回 MeasureResult 协议值而非把内部输入对象本身暴露给源程序修改。
|
||||||
let height = cx.store().get_attr(input, "height")?;
|
let height = cx.store().get_attr(input, "height")?;
|
||||||
return Ok(NativeResult::Return(Value::Object(
|
return Ok(NativeResult::Return(Value::Object(
|
||||||
crate::native_visual::record(
|
crate::native_visual::record(
|
||||||
@@ -217,28 +272,38 @@ 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() {
|
||||||
|
// 即使签名标 External,这里仍防御直接适配器调用,防止布局自观察计数形成反馈。
|
||||||
return Err(invalid(
|
return Err(invalid(
|
||||||
"runtime statistics are unavailable during read-only rendering",
|
"runtime statistics are unavailable during read-only rendering",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// 各窗口共享的计数与当前展示派生计数一起投影,锁内不推进刷新。
|
||||||
let session = self
|
let session = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
|
// blocks/pages 数的是成功升级的存活业务对象,而不是弱表历史长度或 Mount 出现数。
|
||||||
let mut blocks = 0_i64;
|
let mut blocks = 0_i64;
|
||||||
|
// 页面是 Block 的子集,同一页跨窗口迁移不会增加业务页面计数。
|
||||||
let mut pages = 0_i64;
|
let mut pages = 0_i64;
|
||||||
for weak in &session.nodes {
|
for weak in &session.nodes {
|
||||||
|
// 弱索引可能已死亡;只统计成功升级的对象,不能用 nodes.len() 代表当前存活 Block 数。
|
||||||
if let Some(object) = cx.upgrade(*weak)? {
|
if let Some(object) = cx.upgrade(*weak)? {
|
||||||
blocks += 1;
|
blocks += 1;
|
||||||
|
// Page 是这些存活 Block 的子集,按 payload 标志计一次,不按标签数或 Mount 数重复统计。
|
||||||
if cx.store().payload::<block::Block>(object, "Block")?.page {
|
if cx.store().payload::<block::Block>(object, "Block")?.page {
|
||||||
pages += 1;
|
pages += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 统计对象总数在分配 stats 结果之前采样,避免把本次诊断结果计入自己。
|
||||||
let objects = cx.store().object_count() as i64;
|
let objects = cx.store().object_count() as i64;
|
||||||
|
// session 的 Deref 指向 current,tabs/mounts 与布局计数属于当前窗口,而非跨窗求和。
|
||||||
let tabs = cx.store().dict_items(session.tabs)?.len() as i64;
|
let tabs = cx.store().dict_items(session.tabs)?.len() as i64;
|
||||||
|
// 新普通对象只装原生数值/文本,不把 VM store 或 Session 句柄暴露给程序。
|
||||||
let stats = cx.new_object()?;
|
let stats = cx.new_object()?;
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("objects", objects),
|
("objects", objects),
|
||||||
@@ -265,8 +330,10 @@ impl NativeCallable for Function {
|
|||||||
session.dependency_invalidations as i64,
|
session.dependency_invalidations as i64,
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
|
// 统一转成语言整数,字段名字是 ui.stats 的观察接口,不影响调度逻辑。
|
||||||
cx.set_attr(stats, name, Value::Number(value.into()))?;
|
cx.set_attr(stats, name, Value::Number(value.into()))?;
|
||||||
}
|
}
|
||||||
|
// 单独保留文本失效原因,数值计数不能解释究竟哪条源属性触发了重算。
|
||||||
cx.set_attr(
|
cx.set_attr(
|
||||||
stats,
|
stats,
|
||||||
"lastInvalidDependency",
|
"lastInvalidDependency",
|
||||||
@@ -287,7 +354,9 @@ 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) {
|
||||||
|
// value 及绑定方法都是普通托管属性,GC 能追踪回调和状态之间的真实边。
|
||||||
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)?;
|
||||||
cx.set_attr(state, "value", args[0].clone())?;
|
cx.set_attr(state, "value", args[0].clone())?;
|
||||||
@@ -296,34 +365,45 @@ impl NativeCallable for Function {
|
|||||||
return Ok(NativeResult::Return(Value::Object(state)));
|
return Ok(NativeResult::Return(Value::Object(state)));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, StateSet | Increment) {
|
if matches!(self.operation, StateSet | Increment) {
|
||||||
|
// 状态对象由 bind 捕获,不能把 args[0] 当 receiver 再切一次参数。
|
||||||
let owner = self.owner.ok_or(invalid("state method has no owner"))?;
|
let owner = self.owner.ok_or(invalid("state method has no owner"))?;
|
||||||
|
// set 接受任意新值;increment 则对当前 value 做精确整数读取与溢出检查。
|
||||||
let value = if matches!(self.operation, StateSet) {
|
let value = if matches!(self.operation, StateSet) {
|
||||||
args[0].clone()
|
args[0].clone()
|
||||||
} else {
|
} else {
|
||||||
|
// 先限制 Number 表示,再从中取可表示 i64 的整数;不截断十进制或二进制小数。
|
||||||
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))
|
||||||
.ok_or(invalid("integer state increment exceeds its range"))?;
|
.ok_or(invalid("integer state increment exceeds its range"))?;
|
||||||
Value::Number(next.into())
|
Value::Number(next.into())
|
||||||
};
|
};
|
||||||
|
// 通过统一属性写入口更新版本,textOf/绑定的依赖失效因此能被下一轮刷新观察。
|
||||||
cx.set_attr(owner, "value", value)?;
|
cx.set_attr(owner, "value", value)?;
|
||||||
return Ok(NativeResult::Return(Value::Nil));
|
return Ok(NativeResult::Return(Value::Nil));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, TextOf) {
|
if matches!(self.operation, TextOf) {
|
||||||
|
// 先保存被观察对象和属性名;这里不是把当前值立即格式化成一个永不更新的字符串。
|
||||||
let source = object(args.first())?;
|
let source = object(args.first())?;
|
||||||
|
// 属性名必须是真字符串,不能从任意对象 repr 推导不稳定的名字。
|
||||||
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)?;
|
||||||
|
// 字号可选但必须是等级整数,缺省为 1;颜色在后面按独立可选参数解析。
|
||||||
let size = args
|
let size = args
|
||||||
.get(2)
|
.get(2)
|
||||||
.map(|v| integer(Some(v)))
|
.map(|v| integer(Some(v)))
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
if !(1..=8).contains(&size) {
|
if !(1..=8).contains(&size) {
|
||||||
|
// 数字能转为整数不代表是受支持等级,在分配 TextBlock 之前拒绝越界。
|
||||||
return Err(invalid("text size must be 1 through 8"));
|
return Err(invalid("text size must be 1 through 8"));
|
||||||
}
|
}
|
||||||
|
// 初始文字为空,真正内容留给快照读取 source.field;构造不冻结当前属性值。
|
||||||
let mut data = Data::new(
|
let mut data = Data::new(
|
||||||
Kind::Text {
|
Kind::Text {
|
||||||
value: String::new(),
|
value: String::new(),
|
||||||
@@ -334,6 +414,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,7 +422,9 @@ impl NativeCallable for Function {
|
|||||||
&self.session,
|
&self.session,
|
||||||
)?)));
|
)?)));
|
||||||
}
|
}
|
||||||
|
// 普通组件先组装 Data,再统一由 new_typed_block 安装 Cell、属性和名义类型;不直接写原生窗口。
|
||||||
let constructor = match self.operation {
|
let constructor = match self.operation {
|
||||||
|
// 滚动视口有限宽高和唯一内容 Block;滚动轴/偏移由属性和 Mount 后续处理。
|
||||||
Scroll => Some(Data::new(
|
Scroll => Some(Data::new(
|
||||||
Kind::Scroll {
|
Kind::Scroll {
|
||||||
width: dimension(args.first())?,
|
width: dimension(args.first())?,
|
||||||
@@ -351,6 +434,7 @@ impl NativeCallable for Function {
|
|||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
)),
|
)),
|
||||||
|
// 文本是叶节点,size 的 u64→u32 转换和下面统一 1..=8 检查分别限制表示与语义。
|
||||||
Text => Some(Data::new(
|
Text => Some(Data::new(
|
||||||
Kind::Text {
|
Kind::Text {
|
||||||
value: text(args.first())?,
|
value: text(args.first())?,
|
||||||
@@ -367,6 +451,7 @@ impl NativeCallable for Function {
|
|||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
)),
|
)),
|
||||||
|
// 宽高必填,圆角缺省零,颜色缺省浅蓝;每个尺寸都先经有限非负检查。
|
||||||
Rect => Some(Data::new(
|
Rect => Some(Data::new(
|
||||||
Kind::Rect {
|
Kind::Rect {
|
||||||
width: dimension(args.first())?,
|
width: dimension(args.first())?,
|
||||||
@@ -382,20 +467,26 @@ impl NativeCallable for Function {
|
|||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
)),
|
)),
|
||||||
|
// 空白只提供主轴占位,不带孩子或绘图颜色。
|
||||||
Spacer => Some(Data::new(
|
Spacer => Some(Data::new(
|
||||||
Kind::Spacer(dimension(args.first())?),
|
Kind::Spacer(dimension(args.first())?),
|
||||||
vec![],
|
vec![],
|
||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
)),
|
)),
|
||||||
|
// 三种容器共用参数解码,Kind 决定后续横排/纵排/叠放,不在这里计算子项坐标。
|
||||||
Row | Column | Overlay => Some(Data::new(
|
Row | Column | Overlay => Some(Data::new(
|
||||||
if matches!(self.operation, Row) {
|
if matches!(self.operation, Row) {
|
||||||
|
// 行布局随后沿水平主轴累计孩子位置。
|
||||||
Kind::Row
|
Kind::Row
|
||||||
} else if matches!(self.operation, Overlay) {
|
} else if matches!(self.operation, Overlay) {
|
||||||
|
// 叠放布局共享内容原点,不采用线性 gap 累加位置。
|
||||||
Kind::Overlay
|
Kind::Overlay
|
||||||
} else {
|
} else {
|
||||||
|
// 此入口仅剩 Column,后续沿纵向排列。
|
||||||
Kind::Column
|
Kind::Column
|
||||||
},
|
},
|
||||||
|
// 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。
|
||||||
args[2..]
|
args[2..]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|v| object(Some(v)))
|
.map(|v| object(Some(v)))
|
||||||
@@ -403,7 +494,9 @@ impl NativeCallable for Function {
|
|||||||
dimension(args.first())?,
|
dimension(args.first())?,
|
||||||
dimension(args.get(1))?,
|
dimension(args.get(1))?,
|
||||||
)),
|
)),
|
||||||
|
// 空 Page 尚未登记到任何窗口,直到 render/open 才安排展示。
|
||||||
Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)),
|
Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)),
|
||||||
|
// 二维场景只有孩子对象序列;各对象的 x/y 等世界属性在快照提取时读取。
|
||||||
Scene2D => Some(Data::new(
|
Scene2D => Some(Data::new(
|
||||||
Kind::Scene2D,
|
Kind::Scene2D,
|
||||||
args.iter()
|
args.iter()
|
||||||
@@ -413,6 +506,7 @@ impl NativeCallable for Function {
|
|||||||
0.0,
|
0.0,
|
||||||
)),
|
)),
|
||||||
CanvasViewport => {
|
CanvasViewport => {
|
||||||
|
// 视口自身参加有限布局,第三实参保存 Scene2D 引用,不把场景孩子展开成行列内容。
|
||||||
let mut data = Data::new(
|
let mut data = Data::new(
|
||||||
Kind::CanvasViewport {
|
Kind::CanvasViewport {
|
||||||
width: dimension(args.first())?,
|
width: dimension(args.first())?,
|
||||||
@@ -422,16 +516,20 @@ 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)
|
||||||
}
|
}
|
||||||
Viewport3D => {
|
Viewport3D => {
|
||||||
|
// 当前 desktop 提供相机/控制器的名义类型,所有默认资源在同一 VM 内创建。
|
||||||
let desktop = self
|
let desktop = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?
|
.map_err(|_| invalid("UI session poisoned"))?
|
||||||
.desktop;
|
.desktop;
|
||||||
|
// 默认相机使用透视投影;这里分配模型对象,不立即生成场景渲染快照。
|
||||||
let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?;
|
let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?;
|
||||||
|
// 空参数选择场景模块的默认姿态,之后源属性仍可显式换相机或改参数。
|
||||||
let camera = crate::scene3d_model::allocate(
|
let camera = crate::scene3d_model::allocate(
|
||||||
cx,
|
cx,
|
||||||
desktop,
|
desktop,
|
||||||
@@ -439,6 +537,7 @@ impl NativeCallable for Function {
|
|||||||
crate::scene3d_model::Constructor::Perspective,
|
crate::scene3d_model::Constructor::Perspective,
|
||||||
&[],
|
&[],
|
||||||
)?;
|
)?;
|
||||||
|
// 普通 children 先为空,世界场景独立存于 scene3d 字段;面板 UI 会在提取时派生。
|
||||||
let mut data = Data::new(
|
let mut data = Data::new(
|
||||||
Kind::Viewport3D {
|
Kind::Viewport3D {
|
||||||
width: dimension(args.first())?,
|
width: dimension(args.first())?,
|
||||||
@@ -450,7 +549,9 @@ 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,7 +561,9 @@ impl NativeCallable for Function {
|
|||||||
)?);
|
)?);
|
||||||
Some(data)
|
Some(data)
|
||||||
}
|
}
|
||||||
|
// 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。
|
||||||
Button => {
|
Button => {
|
||||||
|
// 先创建真实文字孩子,让按钮尺寸和文本测量、字体缓存、子树生命周期保持一致。
|
||||||
let label = new_block(
|
let label = new_block(
|
||||||
cx,
|
cx,
|
||||||
Data::new(
|
Data::new(
|
||||||
@@ -477,12 +580,15 @@ impl NativeCallable for Function {
|
|||||||
)?;
|
)?;
|
||||||
Some(Data::new(Kind::Button, vec![label], 10.0, 0.0))
|
Some(Data::new(Kind::Button, vec![label], 10.0, 0.0))
|
||||||
}
|
}
|
||||||
|
// 非构造操作继续进入页面/实例方法分派;None 不是失败,也不创建占位对象。
|
||||||
_ => 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)
|
||||||
{
|
{
|
||||||
|
// 两条文本工厂共用语义限制;防止类型工厂参数能转 u32 却超出实际支持字号等级。
|
||||||
return Err(invalid("text exceeds size limits"));
|
return Err(invalid("text exceeds size limits"));
|
||||||
}
|
}
|
||||||
return Ok(NativeResult::Return(Value::Object(new_typed_block(
|
return Ok(NativeResult::Return(Value::Object(new_typed_block(
|
||||||
@@ -495,13 +601,16 @@ impl NativeCallable for Function {
|
|||||||
if matches!(self.operation, OpenValue | Present)
|
if matches!(self.operation, OpenValue | Present)
|
||||||
&& let Value::Object(value) = args[0]
|
&& let Value::Object(value) = args[0]
|
||||||
{
|
{
|
||||||
|
// 对象值可能实现 Presentable;标量不可能走这一协议路径,稍后按普通文字转换处理。
|
||||||
let (desktop, current) = {
|
let (desktop, current) = {
|
||||||
|
// master 用于共享能力类型,current 用于传给源 present 的窗口资源,二者不能混用。
|
||||||
let state = self
|
let state = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.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()
|
||||||
@@ -511,15 +620,20 @@ impl NativeCallable for Function {
|
|||||||
.is::<block::Block>()
|
.is::<block::Block>()
|
||||||
&& cx.store().supports(value, capability)?
|
&& cx.store().supports(value, capability)?
|
||||||
{
|
{
|
||||||
|
// 只有非 Block 且显式实现能力的对象才执行源 present,不能凭同名属性鸭子类型推断。
|
||||||
|
// 能力实现依附于类型,需从该类型的方法表取 present,而非随意的实例属性。
|
||||||
let definition = cx
|
let definition = cx
|
||||||
.store()
|
.store()
|
||||||
.type_of(value)?
|
.type_of(value)?
|
||||||
.ok_or(invalid("untyped Presentable"))?;
|
.ok_or(invalid("untyped Presentable"))?;
|
||||||
|
// lookup_method 处理继承规则,缺少实现即能力契约错误。
|
||||||
let function = cx
|
let function = cx
|
||||||
.store()
|
.store()
|
||||||
.lookup_method(definition, "present")?
|
.lookup_method(definition, "present")?
|
||||||
.ok_or(invalid("missing present method"))?;
|
.ok_or(invalid("missing present method"))?;
|
||||||
|
// 源 present 接收实际目标窗口,不能总把主窗口传给副窗口中的调用。
|
||||||
let window = object(Some(&cx.store().get_attr(current, "window")?))?;
|
let window = object(Some(&cx.store().get_attr(current, "window")?))?;
|
||||||
|
// 源方法返回后由 PresentResult 检查真实 Block,并按 open 标志决定是否包装 Page。
|
||||||
let complete = cx.allocate(ContextNativeFunction::new(
|
let complete = cx.allocate(ContextNativeFunction::new(
|
||||||
"validate presentation",
|
"validate presentation",
|
||||||
PresentResult {
|
PresentResult {
|
||||||
@@ -528,6 +642,9 @@ impl NativeCallable for Function {
|
|||||||
},
|
},
|
||||||
vec![desktop],
|
vec![desktop],
|
||||||
))?;
|
))?;
|
||||||
|
// 小驱动先调用 present(value, window),再把结果交 PresentResult 验证/可选打开,所有委托共用当前 VM 预算。
|
||||||
|
// 两个 PushNull 填 Call 的空接收者槽,避免 VM 额外插入 receiver;value/window 已作为 present 的两个显式参数加载。
|
||||||
|
// 栈先保留 complete 的调用,再嵌套两参 present,最后把其结果作 complete 的一个参数,形成验证续接。
|
||||||
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),
|
||||||
@@ -539,6 +656,7 @@ impl NativeCallable for Function {
|
|||||||
endor_core::Instruction::new(endor_core::Opcode::Call, 1),
|
endor_core::Instruction::new(endor_core::Opcode::Call, 1),
|
||||||
endor_core::Instruction::simple(endor_core::Opcode::ReturnValue),
|
endor_core::Instruction::simple(endor_core::Opcode::ReturnValue),
|
||||||
];
|
];
|
||||||
|
// 常量表按字节码的 0..3 索引依次提供续接、源方法、receiver 和 Window。
|
||||||
let code = endor_core::EndorCode::from_instructions(
|
let code = endor_core::EndorCode::from_instructions(
|
||||||
&instructions,
|
&instructions,
|
||||||
vec![
|
vec![
|
||||||
@@ -550,8 +668,11 @@ impl NativeCallable for Function {
|
|||||||
endor_core::CodeLayout::default(),
|
endor_core::CodeLayout::default(),
|
||||||
)?
|
)?
|
||||||
.with_location("Presentable.present", "<tessera>", 1);
|
.with_location("Presentable.present", "<tessera>", 1);
|
||||||
|
// 模板包装成托管代码后,驱动函数的强边负责维持常量/代码存活。
|
||||||
let code = cx.allocate(endor_core::EndorCodeObject::new(code))?;
|
let code = cx.allocate(endor_core::EndorCodeObject::new(code))?;
|
||||||
|
// 驱动只访问常量,不需要用户命名空间,私有空字典避免污染全局。
|
||||||
let globals = cx.new_dict()?;
|
let globals = cx.new_dict()?;
|
||||||
|
// 返回 Invoke 把驱动接入同一 VM 帧循环,不在 NativeContext 内递归进入 VM。
|
||||||
let driver = cx.new_function("Presentable.present", code, globals)?;
|
let driver = cx.new_function("Presentable.present", code, globals)?;
|
||||||
attach_desktop_type(cx, driver, "Function", desktop)?;
|
attach_desktop_type(cx, driver, "Function", desktop)?;
|
||||||
return Ok(NativeResult::Invoke {
|
return Ok(NativeResult::Invoke {
|
||||||
@@ -560,11 +681,15 @@ impl NativeCallable for Function {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ui.open 把普通结果转换为根组件,再在必要时包一层 Page 并展示;ui.present 仅转换,不自动打开页面。
|
||||||
if matches!(self.operation, OpenValue) {
|
if matches!(self.operation, OpenValue) {
|
||||||
|
// 协议对象已在上面返回 Invoke;这里仅处理已有 Block 或普通值的文字回退。
|
||||||
let root = present_value(cx, &args[0], &self.session)?;
|
let root = present_value(cx, &args[0], &self.session)?;
|
||||||
|
// Page 可直接保留,普通 Block 外包一层新 Page,以便加入窗口的页面注册表。
|
||||||
let page = if cx.store().payload::<block::Block>(root, "Block")?.page {
|
let page = if cx.store().payload::<block::Block>(root, "Block")?.page {
|
||||||
root
|
root
|
||||||
} else {
|
} else {
|
||||||
|
// 包装持有原 root,不复制业务对象,因此源对象/状态仍可继续被其他位置展示。
|
||||||
new_block(
|
new_block(
|
||||||
cx,
|
cx,
|
||||||
Data::new(Kind::Page, vec![root], 0.0, 0.0),
|
Data::new(Kind::Page, vec![root], 0.0, 0.0),
|
||||||
@@ -575,15 +700,19 @@ impl NativeCallable for Function {
|
|||||||
return Ok(NativeResult::Return(Value::Object(page)));
|
return Ok(NativeResult::Return(Value::Object(page)));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, Present) {
|
if matches!(self.operation, Present) {
|
||||||
|
// 只做值到视觉的转换,不调用 show,不改变活动页或窗口归属。
|
||||||
return Ok(NativeResult::Return(Value::Object(present_value(
|
return Ok(NativeResult::Return(Value::Object(present_value(
|
||||||
cx,
|
cx,
|
||||||
&args[0],
|
&args[0],
|
||||||
&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) {
|
||||||
|
// 属性名与计算函数分开解码;connect 建立依赖和可取消 Subscription,不立即变成静态值。
|
||||||
let field = text(args.first())?;
|
let field = text(args.first())?;
|
||||||
|
// 函数对象的签名/效果由绑定模块检查,不能只因它是 Object 就当作合法计算闭包。
|
||||||
let compute = object(args.get(1))?;
|
let compute = object(args.get(1))?;
|
||||||
return Ok(NativeResult::Return(Value::Object(
|
return Ok(NativeResult::Return(Value::Object(
|
||||||
crate::binding::connect(
|
crate::binding::connect(
|
||||||
@@ -598,64 +727,89 @@ impl NativeCallable for Function {
|
|||||||
)?,
|
)?,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
// moveTab 重排同窗口页面索引并强制下一帧更新标签;移动的是原 Page,非新页面副本。
|
||||||
if matches!(self.operation, MoveTab) {
|
if matches!(self.operation, MoveTab) {
|
||||||
|
// 页面位置是当前顺序下标,不是 Tab ID 或子组成 slot,先做平台 usize 表示检查。
|
||||||
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"))?;
|
||||||
|
// 同锁内找所属窗口并重排,不能只用 current:源代码可持有其他窗口的 Page。
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
|
// 未登记的 Page 没有可重排标签,不能自动打开它再进行移动。
|
||||||
let id = page_window(cx.store(), &state, owner)?.ok_or(invalid("Page is not open"))?;
|
let id = page_window(cx.store(), &state, owner)?.ok_or(invalid("Page is not open"))?;
|
||||||
|
// 对真实所属窗口设置强制帧,确保即使内容根没变,标签顺序仍会发布。
|
||||||
let view = state
|
let view = state
|
||||||
.presentations
|
.presentations
|
||||||
.get_mut(&id)
|
.get_mut(&id)
|
||||||
.ok_or(invalid("missing window"))?;
|
.ok_or(invalid("missing window"))?;
|
||||||
|
// model/window 维护实际顺序,Presentation 只负责其派生投影和刷新标记。
|
||||||
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
|
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
|
||||||
crate::window::move_page(cx, window, owner, position)?;
|
crate::window::move_page(cx, window, owner, position)?;
|
||||||
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)?;
|
||||||
|
// data_ref 是最终 Data,不是 owner 包装或它的 Cell;新子组成用此身份防回边,末尾也替换这一对象的 payload。
|
||||||
|
// 在副本上编辑结构,再经末尾 replace_payload 更新强边与版本,避免直接篡改借用 payload。
|
||||||
let mut data = data.clone();
|
let mut data = data.clone();
|
||||||
|
// 默认返回 Unit/Nil,append/insert/订阅等分支会改成自己的业务结果;查询分支直接返回。
|
||||||
let mut result = Value::Nil;
|
let mut result = Value::Nil;
|
||||||
match self.operation {
|
match self.operation {
|
||||||
SetText => match &mut data.kind {
|
SetText => match &mut data.kind {
|
||||||
Kind::Text { value, .. } => {
|
Kind::Text { value, .. } => {
|
||||||
|
// 新文字必须为真实字符串且受 UTF-8 字节预算约束,再替换 payload/普通属性。
|
||||||
let next = text(args.first())?;
|
let next = text(args.first())?;
|
||||||
if next.len() > 16_384 {
|
if next.len() > 16_384 {
|
||||||
|
// 检查在写属性前,错误输入不会先清掉旧 text_binding 或部分改文字。
|
||||||
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())?;
|
||||||
}
|
}
|
||||||
|
// receiver 的名义类型校验之外再验证 payload,避免对不兼容原生种类写文本。
|
||||||
_ => return Err(invalid("setText requires a text Block")),
|
_ => return Err(invalid("setText requires a text Block")),
|
||||||
},
|
},
|
||||||
SetColor => match &mut data.kind {
|
SetColor => match &mut data.kind {
|
||||||
Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => {
|
Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => {
|
||||||
|
// 颜色格式先校验,随后把普通 color 属性与本地 payload 副本保持一致。
|
||||||
*value = color(args.first(), "#242422")?;
|
*value = color(args.first(), "#242422")?;
|
||||||
cx.set_attr(owner, "color", Value::Text(value.clone()))?;
|
cx.set_attr(owner, "color", Value::Text(value.clone()))?;
|
||||||
}
|
}
|
||||||
|
// 容器/空白不支持这一颜色修改接口,不能往无关 payload 中补出虚构颜色字段。
|
||||||
_ => return Err(invalid("this Block has no color")),
|
_ => return Err(invalid("this Block has no color")),
|
||||||
},
|
},
|
||||||
Append | Insert | Remove | Replace | Move => {
|
Append | Insert | Remove | Replace | Move => {
|
||||||
if !data.is_container() {
|
if !data.is_container() {
|
||||||
|
// 只允许有组成语义的容器;叶 Block 不因调用 append 而隐式变成 Column。
|
||||||
return Err(invalid("expected a container Block"));
|
return Err(invalid("expected a container Block"));
|
||||||
}
|
}
|
||||||
if matches!(self.operation, Append | Insert) {
|
if matches!(self.operation, Append | Insert) {
|
||||||
|
// 新增边需要新 slot;append 取末尾,insert 的首参是可插入缝位置。
|
||||||
let (position, argument) = if matches!(self.operation, Insert) {
|
let (position, argument) = if matches!(self.operation, Insert) {
|
||||||
|
// 平台索引转换和容器长度检查分开,len 本身允许表示“插到末尾”。
|
||||||
let position = usize::try_from(integer(args.first())?)
|
let position = usize::try_from(integer(args.first())?)
|
||||||
.map_err(|_| invalid("child position out of range"))?;
|
.map_err(|_| invalid("child position out of range"))?;
|
||||||
|
// 插入位置是孩子之间的缝,允许等于 len 表示末尾;大于 len 则在分配 slot 和修改子序列之前拒绝。
|
||||||
if position > data.children.len() {
|
if position > data.children.len() {
|
||||||
return Err(invalid("child position out of range"));
|
return Err(invalid("child position out of range"));
|
||||||
}
|
}
|
||||||
(position, args.get(1))
|
(position, args.get(1))
|
||||||
} else {
|
} else {
|
||||||
|
// append 不接位置,唯一实参就是新孩子;不会误把它解析成索引。
|
||||||
(data.children.len(), args.first())
|
(data.children.len(), args.first())
|
||||||
};
|
};
|
||||||
|
// 统一孩子对象解码;validate 再检查真实 Block 和无环组成。
|
||||||
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))?;
|
||||||
|
// 保存当前高水位作为返回身份,再推进 next_slot,删除旧边也不回退该计数。
|
||||||
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,7 +817,9 @@ 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())?;
|
||||||
|
// 在当前顺序中查该稳定身份的位置,不能直接把 slot 转 usize 当数组下标。
|
||||||
let index = data
|
let index = data
|
||||||
.children
|
.children
|
||||||
.iter()
|
.iter()
|
||||||
@@ -671,29 +827,39 @@ impl NativeCallable for Function {
|
|||||||
.ok_or(invalid("child slot does not exist"))?;
|
.ok_or(invalid("child slot does not exist"))?;
|
||||||
match self.operation {
|
match self.operation {
|
||||||
Remove => {
|
Remove => {
|
||||||
|
// 删除这一稳定边,不重新编号其余孩子;是否销毁 Block 由托管引用关系决定。
|
||||||
data.children.remove(index);
|
data.children.remove(index);
|
||||||
}
|
}
|
||||||
Replace => {
|
Replace => {
|
||||||
|
// 新孩子先过无环/类型校验,再替换旧边对象;边 slot 保持不变。
|
||||||
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 => {
|
||||||
|
// 第二参是目标顺序下标,首参仍是稳定 slot;目标必须是已有序列内位置。
|
||||||
let to = usize::try_from(integer(args.get(1))?)
|
let to = usize::try_from(integer(args.get(1))?)
|
||||||
.map_err(|_| invalid("child position out of range"))?;
|
.map_err(|_| invalid("child position out of range"))?;
|
||||||
if to >= data.children.len() {
|
if to >= data.children.len() {
|
||||||
|
// move 不新增孩子,因此 len 是越界,不同于 insert 允许插到末尾的 len。
|
||||||
return Err(invalid("child position out of range"));
|
return Err(invalid("child position out of range"));
|
||||||
}
|
}
|
||||||
|
// 移动完整 Child 值,slot 与对象身份都保持,不分配新的出现组成键。
|
||||||
let child = data.children.remove(index);
|
let child = data.children.remove(index);
|
||||||
data.children.insert(to, child);
|
data.children.insert(to, child);
|
||||||
}
|
}
|
||||||
|
// 外层已排除 append/insert,此处只可能是 remove/replace/move。
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。
|
||||||
Children => {
|
Children => {
|
||||||
|
// 查询创建新字典但不改原容器;key 是 slot 文本,迭代顺序不作为下标身份保证。
|
||||||
let result = cx.new_dict()?;
|
let result = cx.new_dict()?;
|
||||||
for child in data.children {
|
for child in data.children {
|
||||||
|
// VM 字典赋值记录真实 Block 强边,调用结果可安全独立持有孩子而非引用 Rust 临时切片。
|
||||||
cx.dict_set(
|
cx.dict_set(
|
||||||
result,
|
result,
|
||||||
Value::Text(child.slot.to_string()),
|
Value::Text(child.slot.to_string()),
|
||||||
@@ -704,21 +870,27 @@ impl NativeCallable for Function {
|
|||||||
}
|
}
|
||||||
Render | Navigate => {
|
Render | Navigate => {
|
||||||
if !matches!(data.kind, Kind::Page) {
|
if !matches!(data.kind, Kind::Page) {
|
||||||
|
// 只有 Page 有根替换与导航历史,不能对普通 ContainerBlock 偷用页面语义。
|
||||||
return Err(invalid("expected a Page"));
|
return Err(invalid("expected a Page"));
|
||||||
}
|
}
|
||||||
|
// 新内容须真实 Block 且不能包含当前 Page 的共享 Data,防止出现循环组成。
|
||||||
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() {
|
||||||
|
// 首次导航才把现有根作为历史起点;空 Page 没有可保留的起始根。
|
||||||
data.history.push(old.block);
|
data.history.push(old.block);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 从后退位置发起新导航会丢弃原前进分支,不能保留两条互相矛盾的线性历史。
|
||||||
data.history.truncate(data.history_index + 1);
|
data.history.truncate(data.history_index + 1);
|
||||||
}
|
}
|
||||||
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,
|
||||||
@@ -728,11 +900,16 @@ impl NativeCallable for Function {
|
|||||||
}
|
}
|
||||||
Back | Forward => {
|
Back | Forward => {
|
||||||
if data.history.is_empty() {
|
if data.history.is_empty() {
|
||||||
|
// 无历史是正常无动作,不把索引减法做在空列表上,也不制造一次空展示请求。
|
||||||
return Ok(NativeResult::Return(Value::Nil));
|
return Ok(NativeResult::Return(Value::Nil));
|
||||||
}
|
}
|
||||||
|
// 后退/前进钳到历史首尾,复用已保留根对象而非重新执行旧源码。
|
||||||
|
// 这里不增删历史;导航根通过 Data.trace 保活,show 仅安排后续刷新,不立即切换 active 或产生 Mount。
|
||||||
let next = if matches!(self.operation, Back) {
|
let next = if matches!(self.operation, Back) {
|
||||||
|
// saturating_sub 在首项停住,不绕回列表尾部。
|
||||||
data.history_index.saturating_sub(1)
|
data.history_index.saturating_sub(1)
|
||||||
} else {
|
} else {
|
||||||
|
// 前进上界为最后已有项,不为不存在的未来页分配对象。
|
||||||
(data.history_index + 1).min(data.history.len() - 1)
|
(data.history_index + 1).min(data.history.len() - 1)
|
||||||
};
|
};
|
||||||
data.history_index = next;
|
data.history_index = next;
|
||||||
@@ -742,12 +919,16 @@ impl NativeCallable for Function {
|
|||||||
}];
|
}];
|
||||||
show(cx, owner, &self.session)?;
|
show(cx, owner, &self.session)?;
|
||||||
}
|
}
|
||||||
|
// 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。
|
||||||
Detach => {
|
Detach => {
|
||||||
|
// 短锁中只创建资源,锁释放后再 show_in,避免内部二次锁定同一个 Session。
|
||||||
let window = {
|
let window = {
|
||||||
|
// 新窗口编号和 Presentation 同步建立,使用主注册域的 Window 默认类型。
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
|
// detach 创建默认 Window,不继承 Page 的源类型作为窗口类型。
|
||||||
let definition = desktop_type(cx.store(), "Window", state.master)?;
|
let definition = desktop_type(cx.store(), "Window", state.master)?;
|
||||||
create_window(cx, &mut state, definition)?
|
create_window(cx, &mut state, definition)?
|
||||||
};
|
};
|
||||||
@@ -755,24 +936,29 @@ impl NativeCallable for Function {
|
|||||||
return Ok(NativeResult::Return(Value::Object(window)));
|
return Ok(NativeResult::Return(Value::Object(window)));
|
||||||
}
|
}
|
||||||
PageWindow => {
|
PageWindow => {
|
||||||
|
// 这是查询:按真实注册表找 owner 的归属,不能直接返回当前窗口冒充其宿主。
|
||||||
let state = self
|
let state = self
|
||||||
.session
|
.session
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
|
// 未打开/已关闭 Page 正常返回 nil,签名为 nullable(Window)。
|
||||||
let value = if let Some(id) = page_window(cx.store(), &state, owner)? {
|
let value = if let Some(id) = page_window(cx.store(), &state, owner)? {
|
||||||
cx.store()
|
cx.store()
|
||||||
.get_attr(state.presentations[&id].desktop, "window")?
|
.get_attr(state.presentations[&id].desktop, "window")?
|
||||||
} else {
|
} else {
|
||||||
|
// 仍存活的 Page 可以暂时没有窗口归属,不因此新建窗口或报错。
|
||||||
Value::Nil
|
Value::Nil
|
||||||
};
|
};
|
||||||
return Ok(NativeResult::Return(value));
|
return Ok(NativeResult::Return(value));
|
||||||
}
|
}
|
||||||
Open | Activate | MoveWindow => {
|
Open | Activate | MoveWindow => {
|
||||||
|
// moveToWindow 必须明确目标;open/activate 的缺省则保留原归属或使用当前窗口。
|
||||||
if matches!(self.operation, MoveWindow)
|
if matches!(self.operation, MoveWindow)
|
||||||
&& matches!(args.first(), None | Some(Value::Nil))
|
&& matches!(args.first(), None | Some(Value::Nil))
|
||||||
{
|
{
|
||||||
return Err(invalid("Page.moveToWindow requires a Window"));
|
return Err(invalid("Page.moveToWindow requires a Window"));
|
||||||
}
|
}
|
||||||
|
// None 与显式 nil 在 open 路径等价;非空实参先解码对象,show_in 再检查 Window payload/域。
|
||||||
let target = match args.first() {
|
let target = match args.first() {
|
||||||
None | Some(Value::Nil) => None,
|
None | Some(Value::Nil) => None,
|
||||||
Some(value) => Some(object(Some(value))?),
|
Some(value) => Some(object(Some(value))?),
|
||||||
@@ -780,20 +966,25 @@ 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 => {
|
||||||
|
// 内容强边在统一 replace_payload 时撤销,Page 自己仍在标签注册表中可再次 render。
|
||||||
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
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| invalid("UI session poisoned"))?;
|
.map_err(|_| invalid("UI session poisoned"))?;
|
||||||
if let Some(id) = page_window(cx.store(), &state, owner)? {
|
if let Some(id) = page_window(cx.store(), &state, owner)? {
|
||||||
// Closing the tab ends its presentation even if source
|
// 关闭即终止旧出现的任务归属,不等待下一次布局实际卸载才取消。
|
||||||
// reopens it before the next layout can detach the Mounts.
|
// 弱 Page 身份用于精确筛选这个页面的 Mount,不能取消整个窗口其他页面任务。
|
||||||
let page = cx.store().downgrade(owner)?;
|
let page = cx.store().downgrade(owner)?;
|
||||||
for weak in state.presentations[&id].mounts.ordered() {
|
for weak in state.presentations[&id].mounts.ordered() {
|
||||||
|
// 升级成功且 Mount.page 匹配才取消;不会把共享 Block 的其他出现一并取消。
|
||||||
if let Some(mount) = cx.upgrade(weak)?
|
if let Some(mount) = cx.upgrade(weak)?
|
||||||
&& cx
|
&& cx
|
||||||
.store()
|
.store()
|
||||||
@@ -801,9 +992,11 @@ impl NativeCallable for Function {
|
|||||||
.page
|
.page
|
||||||
== Some(page)
|
== Some(page)
|
||||||
{
|
{
|
||||||
|
// 取消 owner/key 调度资格,旧 I/O 即使稍后完成也不能再向此出现交付。
|
||||||
crate::tasks::cancel_owner(cx, mount)?;
|
crate::tasks::cancel_owner(cx, mount)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 撤注册后为原窗口选择替代页或安排清空,生命周期卸载仍交后续刷新处理。
|
||||||
remove_page(
|
remove_page(
|
||||||
cx,
|
cx,
|
||||||
state
|
state
|
||||||
@@ -816,18 +1009,22 @@ impl NativeCallable for Function {
|
|||||||
return Ok(NativeResult::Return(Value::Nil));
|
return Ok(NativeResult::Return(Value::Nil));
|
||||||
}
|
}
|
||||||
OnClick => {
|
OnClick => {
|
||||||
|
// 简便 onClick 保存单个处理器,validate_handler 检查真实 callable 契约而非仅有对象身份。
|
||||||
let handler = object(args.first())?;
|
let handler = object(args.first())?;
|
||||||
crate::event_dispatch::validate_handler(cx.store(), handler)?;
|
crate::event_dispatch::validate_handler(cx.store(), handler)?;
|
||||||
data.click = Some(handler);
|
data.click = Some(handler);
|
||||||
result = Value::Object(owner);
|
result = Value::Object(owner);
|
||||||
}
|
}
|
||||||
On => {
|
On => {
|
||||||
|
// 字符串事件名用于传播匹配,按 UTF-8 字节限制长度,不允许空事件名。
|
||||||
let kind = text(args.first())?;
|
let kind = text(args.first())?;
|
||||||
if kind.is_empty() || kind.len() > 128 {
|
if kind.is_empty() || kind.len() > 128 {
|
||||||
return Err(invalid("invalid event kind"));
|
return Err(invalid("invalid event kind"));
|
||||||
}
|
}
|
||||||
|
// 先校验回调再建立 Connection,避免非法处理器留下不可取消的半条订阅。
|
||||||
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,25 +1032,31 @@ 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 {
|
||||||
|
// data.subscriptions 实际保存 Connection 身份,已断开者 handler=None 不再占容量。
|
||||||
if cx
|
if cx
|
||||||
.store()
|
.store()
|
||||||
.payload::<crate::event_dispatch::Connection>(*token, "Connection")?
|
.payload::<crate::event_dispatch::Connection>(*token, "Connection")?
|
||||||
.handler
|
.handler
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
|
// 按原序保留仍有效连接,事件路由中的监听顺序因此不因清理而改变。
|
||||||
active.push(*token);
|
active.push(*token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if active.len() >= MAX_UI_NODES {
|
if active.len() >= MAX_UI_NODES {
|
||||||
|
// 清理后仍满才拒绝,避免死亡连接永久占满一个 Block 的订阅预算。
|
||||||
return Err(invalid("too many event subscriptions"));
|
return Err(invalid("too many event subscriptions"));
|
||||||
}
|
}
|
||||||
|
// Connection 强持 handler,由 Block 的 subscriptions 拥有;任务和事件生命周期可据此断开。
|
||||||
let connection = cx.allocate(crate::event_dispatch::Connection {
|
let connection = cx.allocate(crate::event_dispatch::Connection {
|
||||||
kind,
|
kind,
|
||||||
handler: Some(handler),
|
handler: Some(handler),
|
||||||
capture,
|
capture,
|
||||||
})?;
|
})?;
|
||||||
|
// 返回的 Subscription 仅弱指连接,不应因用户保留令牌而让整个已卸载组件和回调常驻。
|
||||||
let token = cx.allocate(crate::event_dispatch::Subscription {
|
let token = cx.allocate(crate::event_dispatch::Subscription {
|
||||||
connection: cx.store().downgrade(connection)?,
|
connection: cx.store().downgrade(connection)?,
|
||||||
})?;
|
})?;
|
||||||
@@ -862,27 +1065,37 @@ 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 => {
|
||||||
|
// 事件/订阅类型来自当前展示域,传播上下文仍由明确 owner/可选 Mount 指定。
|
||||||
let desktop = self
|
let desktop = self
|
||||||
.session
|
.session
|
||||||
.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) {
|
||||||
|
// click 总创建一份新 Event,调用方只可补具体 Mount;不会把旧事件对象重复分发。
|
||||||
let definition = desktop_type(cx.store(), "Event", desktop)?;
|
let definition = desktop_type(cx.store(), "Event", desktop)?;
|
||||||
(
|
(
|
||||||
crate::event_dispatch::new_event(cx, definition, "click")?,
|
crate::event_dispatch::new_event(cx, definition, "click")?,
|
||||||
args.first(),
|
args.first(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// dispatch 接现成事件,第二实参才是可选出现;传播模块继续验证两者语义。
|
||||||
(object(args.first())?, args.get(1))
|
(object(args.first())?, args.get(1))
|
||||||
};
|
};
|
||||||
|
// None 保留“未指定出现”含义,显式非法值传播错误;不从任意当前 Mount 猜目标。
|
||||||
let mount = mount.map(|value| object(Some(value))).transpose()?;
|
let mount = mount.map(|value| object(Some(value))).transpose()?;
|
||||||
|
// 返回 Invoke/Return 由事件模块决定,源捕获/目标/冒泡回调仍走同一 VM 帧循环。
|
||||||
return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop);
|
return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop);
|
||||||
}
|
}
|
||||||
|
// 其余操作应在前面的专用分支处理;漏分派必须显式报错,不能悄悄返回成功 Unit。
|
||||||
_ => return Err(invalid("unsupported Block operation")),
|
_ => return Err(invalid("unsupported Block operation")),
|
||||||
}
|
}
|
||||||
|
// 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。
|
||||||
|
// 只覆盖解析到的 Data;原 Block/Cell 身份保持,所有转发到同一 Data 的别名因此观察到本次结构修改。
|
||||||
cx.replace_payload(data_ref, data)?;
|
cx.replace_payload(data_ref, data)?;
|
||||||
Ok(NativeResult::Return(result))
|
Ok(NativeResult::Return(result))
|
||||||
}
|
}
|
||||||
|
|||||||