Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ceb83320 | |||
| f2f19ef87b | |||
| 09c1090339 | |||
| 2715c1ce2c | |||
| 45ff965b01 | |||
| e315172ce1 | |||
| cd23e70db1 | |||
| d29a1be329 | |||
| 723e41706b | |||
| caea8c62a2 | |||
| 0b1394e407 | |||
| 1fedd21d3c | |||
| 534f6e03c1 | |||
| 4c3fc8b5dd |
@@ -18,6 +18,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -92,6 +98,12 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -308,6 +320,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -434,12 +455,32 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "font-types"
|
||||
version = "0.11.3"
|
||||
@@ -779,6 +820,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -809,6 +870,45 @@ dependencies = [
|
||||
"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]]
|
||||
name = "num_enum"
|
||||
version = "0.7.6"
|
||||
@@ -1104,6 +1204,11 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "objects"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
@@ -1201,6 +1306,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -1466,6 +1584,12 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
@@ -1655,10 +1779,12 @@ dependencies = [
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation 0.2.2",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"softbuffer",
|
||||
"tiny_http",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
"winit",
|
||||
]
|
||||
@@ -2325,6 +2451,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Cargo 包元数据与编译器最低版本;二进制入口默认取 src/main.rs。
|
||||
[package]
|
||||
name = "tessera"
|
||||
version = "0.1.0"
|
||||
@@ -6,24 +7,43 @@ rust-version = "1.91"
|
||||
description = "Cross-platform desktop rendering client"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
# 跨平台运行依赖:语言执行、文字排版、序列化、窗口/像素表面与轻量 HTTP 服务。
|
||||
# Aster 三个 crate 使用固定在本仓库 vendor 子模块中的本地源码,不从注册表选择同名实现。
|
||||
[dependencies]
|
||||
# 应用边界的错误聚合与上下文,保留底层失败原因。
|
||||
anyhow = "1.0"
|
||||
# Aster 字节码编译器,把前端结果交给同一套 VM 执行。
|
||||
compiler = { path = "vendor/aster/crates/compiler" }
|
||||
# 系统字体发现、塑形与换行,用于测量和软件文字绘制。
|
||||
cosmic-text = "0.19"
|
||||
# 解码脚本加载的 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 源语法解析与原始位置诊断。
|
||||
frontend = { path = "vendor/aster/crates/frontend" }
|
||||
# 派生序列化协议,rc 特性允许发布模型中的 Arc/Rc 数据参与序列化。
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
# HTTP 请求/响应与事件信封的 JSON 编解码。
|
||||
serde_json = "1.0"
|
||||
# 把宿主软件光栅化得到的像素缓冲呈现到原生窗口。
|
||||
softbuffer = "0.4.8"
|
||||
# 本地 Agent API 的 HTTP 监听与请求响应,不引入异步 Web 运行时。
|
||||
tiny_http = "0.12"
|
||||
# 按 Unicode 字素边界处理编辑/选择,避免把组合字符拆开。
|
||||
unicode-segmentation = "1.12"
|
||||
# 跨平台窗口、事件循环、DPI 与输入法事件。
|
||||
winit = "0.30.13"
|
||||
|
||||
# 仅 macOS 编译原生标题栏适配,显式开启所用 AppKit/Foundation API,其他平台不引入这些依赖。
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5.2"
|
||||
objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSApplication", "NSButton", "NSEvent", "NSView", "NSWindow"] }
|
||||
objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry", "NSString", "NSUserDefaults"] }
|
||||
|
||||
# 发布构建:thin LTO 与单代码生成单元偏重最终优化,strip 去除可执行文件符号以控制体积。
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
Use Rust 1.91 or newer. Initialize the source submodule following the dedicated
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# Tessera 中文源码注释与阅读索引
|
||||
|
||||
本轮进一步细化 Tessera 自有代码的中文注释:覆盖所有 Rust 模块、运行时/API 测试、内置起始页、45 个 Endor 示例、Cargo 依赖配置及两个工具脚本。说明聚焦具体输入/输出、状态归属、计算步骤、依赖观察、取消/失败路径和资源预算;不修改接口、算法、常量或非注释程序文本。Aster 是独立维护的 vendor 子模块,其固定版本与内容沿用主线。
|
||||
|
||||
## 建议阅读顺序
|
||||
|
||||
1. [main](../src/main.rs):创建原生循环、共享文档与执行 worker。
|
||||
2. [原生应用](../src/shell/app.rs):理解窗口生命周期、可用视口、输入边界和最终 present。
|
||||
3. [API worker](../src/runtime/api/worker.rs) 与 [运行时](../src/runtime/endor_host.rs):理解候选/活跃 VM、输入串行化、分片刷新和发布。
|
||||
4. [桥接层](../src/bridge/ui_bridge.rs)、[Block](../src/model/block.rs) 与 [Mount](../src/model/mount.rs):区分共享业务对象、每次出现和展示事务。
|
||||
5. [快照](../src/model/snapshot.rs)、[源布局](../src/render/source_layout.rs) 和 [源绘制](../src/render/source_paint.rs):理解依赖缓存、只读执行及挂起工作。
|
||||
6. [二维视口](../src/render/viewport.rs)、[3D](../src/render/scene3d.rs) 和 [面板映射](../src/render/scene3d/panel.rs):理解布局/世界/视图的边界。
|
||||
7. [任务](../src/runtime/tasks.rs)、[provider](../src/runtime/io_tasks.rs) 和 [事件传播](../src/runtime/event_dispatch.rs):理解异步交付资格与生命周期。
|
||||
|
||||
## 阅读时需要保留的边界
|
||||
|
||||
- Block/Data 属于共享业务组成;Mount 属于一个展示位置,不能把一次出现的尺寸写回共享对象来代替排版。
|
||||
- ObjectRef/WeakObjectRef 的含义由真实托管拥有关系决定;派生缓存中的身份索引不能成为隐藏根。
|
||||
- 窗口线程消费完整发布值;VM、源回调和托管堆操作只在执行 worker 内推进。
|
||||
- submission 防止旧请求覆盖新输入;session 防止跨 VM 资源误用;layout_version 防止旧几何上的离散操作误触。
|
||||
- Pending Work 同时拥有续体与上下文;取消必须关闭原 VM 的只读执行并恢复未提交 Mount,不能只丢弃 Rust 值。
|
||||
- 场景世界不是无限尺寸的排版盒子;有限视口参加布局,摄像机负责投影,命中沿相同链逆变换。
|
||||
- 现有 CPU 绘制、缓存范围与输入队列限制仍按当前代码执行;说明性注释不代表性能优化已实现。
|
||||
|
||||
## Rust 逐文件说明
|
||||
|
||||
每行说明该文件本次深化注释的职责和目的。生产函数/方法的入口说明与内部关键逻辑互相补充;VM 可调用适配器说明实际参数约定,而非仅重复 trait 名称。集成测试中的每个顶层测试/辅助函数都有具体场景和断言说明,测试程序字符串与断言保持原样。
|
||||
|
||||
| 文件 | 注释解释的职责与目的 |
|
||||
| --- | --- |
|
||||
| [src/bridge/mod.rs](../src/bridge/mod.rs) | 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。 |
|
||||
| [src/bridge/ui_bridge.rs](../src/bridge/ui_bridge.rs) | Endor 与 Tessera 的展示事务协调层:维护每个窗口的 Page、Mount、布局缓存和发布版本。 |
|
||||
| [src/bridge/ui_bridge/bootstrap.rs](../src/bridge/ui_bridge/bootstrap.rs) | 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。 |
|
||||
| [src/bridge/ui_bridge/calls.rs](../src/bridge/ui_bridge/calls.rs) | 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。 |
|
||||
| [src/bridge/ui_bridge/developer_pages.rs](../src/bridge/ui_bridge/developer_pages.rs) | 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。 |
|
||||
| [src/bridge/ui_bridge/page_input.rs](../src/bridge/ui_bridge/page_input.rs) | 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。 |
|
||||
| [src/bridge/ui_bridge/signatures.rs](../src/bridge/ui_bridge/signatures.rs) | 声明宿主方法在 Endor 类型系统中的参数和返回契约。 |
|
||||
| [src/bridge/ui_bridge/tree.rs](../src/bridge/ui_bridge/tree.rs) | 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。 |
|
||||
| [src/main.rs](../src/main.rs) | 应用组合入口。先创建原生事件循环和共享发布状态,再启动 API/VM worker,最后把窗口事件交给 TesseraApp。 |
|
||||
| [src/model/binding.rs](../src/model/binding.rs) | 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。 |
|
||||
| [src/model/block.rs](../src/model/block.rs) | Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。 |
|
||||
| [src/model/document.rs](../src/model/document.rs) | 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。 |
|
||||
| [src/model/mod.rs](../src/model/mod.rs) | 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。 |
|
||||
| [src/model/mount.rs](../src/model/mount.rs) | 每次展示的托管上下文:同一个 Block 出现在两个位置时,有两个独立 Mount。 |
|
||||
| [src/model/object_list.rs](../src/model/object_list.rs) | 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。 |
|
||||
| [src/model/presentation.rs](../src/model/presentation.rs) | 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。 |
|
||||
| [src/model/protocols.rs](../src/model/protocols.rs) | 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。 |
|
||||
| [src/model/scene3d.rs](../src/model/scene3d.rs) | 将 Endor 托管场景对象转换成可供渲染线程读取的 3D 数据。 |
|
||||
| [src/model/snapshot.rs](../src/model/snapshot.rs) | 从可变托管对象图提取不可变 UiNode,并维护反向依赖与父子失效传播。 |
|
||||
| [src/model/window.rs](../src/model/window.rs) | 托管 Window 的页面所有权、原生命令与关闭生命周期。 |
|
||||
| [src/render/box_model.rs](../src/render/box_model.rs) | 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。 |
|
||||
| [src/render/display_list.rs](../src/render/display_list.rs) | 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。 |
|
||||
| [src/render/geometry.rs](../src/render/geometry.rs) | 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。 |
|
||||
| [src/render/layout.rs](../src/render/layout.rs) | 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。 |
|
||||
| [src/render/mod.rs](../src/render/mod.rs) | 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。 |
|
||||
| [src/render/native_visual.rs](../src/render/native_visual.rs) | 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。 |
|
||||
| [src/render/page.rs](../src/render/page.rs) | 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。 |
|
||||
| [src/render/scene.rs](../src/render/scene.rs) | 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。 |
|
||||
| [src/render/scene3d.rs](../src/render/scene3d.rs) | 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。 |
|
||||
| [src/render/scene3d/math.rs](../src/render/scene3d/math.rs) | 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。 |
|
||||
| [src/render/scene3d/panel.rs](../src/render/scene3d/panel.rs) | 连接 3D 平面与普通 UI 的坐标和绘制桥梁。 |
|
||||
| [src/render/scene3d/spatial.rs](../src/render/scene3d/spatial.rs) | 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。 |
|
||||
| [src/render/scroll.rs](../src/render/scroll.rs) | 逐 Mount 的滚动状态、裁剪和输入目标选择。 |
|
||||
| [src/render/software_renderer.rs](../src/render/software_renderer.rs) | 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。 |
|
||||
| [src/render/source_hit.rs](../src/render/source_hit.rs) | 通过真正的源/原生 hitTest 协议确定指针目标。 |
|
||||
| [src/render/source_layout.rs](../src/render/source_layout.rs) | 源 measure/arrange 协议的分片布局驱动器。自底向上的测量与自顶向下的安排共享一个只读 VM 调用域。 |
|
||||
| [src/render/source_paint.rs](../src/render/source_paint.rs) | 执行源 paint 覆盖并解码受检查的 PaintCommand。 |
|
||||
| [src/render/text.rs](../src/render/text.rs) | 统一字体发现、文字整形、测量与抗锯齿字形绘制。 |
|
||||
| [src/render/viewport.rs](../src/render/viewport.rs) | 独立 2D 相机与可逆投影。视口尺寸属于布局;平移、缩放属于视图,不改变世界对象的逻辑位置。 |
|
||||
| [src/render/viewport/spatial.rs](../src/render/viewport/spatial.rs) | 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。 |
|
||||
| [src/runtime/api.rs](../src/runtime/api.rs) | 本机 HTTP API 入口:路由请求、限制输入大小、观察活动,并将执行工作送入串行 worker。 |
|
||||
| [src/runtime/api/guide.rs](../src/runtime/api/guide.rs) | 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。 |
|
||||
| [src/runtime/api/tests.rs](../src/runtime/api/tests.rs) | API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 |
|
||||
| [src/runtime/api/worker.rs](../src/runtime/api/worker.rs) | 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。 |
|
||||
| [src/runtime/endor_host.rs](../src/runtime/endor_host.rs) | 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。 |
|
||||
| [src/runtime/endor_host/tests.rs](../src/runtime/endor_host/tests.rs) | 说明全部运行时测试和辅助函数的场景/断言,并解释新主线性能基准的 DPI 分组、预热、三类计时边界和统计方式。 |
|
||||
| [src/runtime/endor_script.rs](../src/runtime/endor_script.rs) | 区分完整 Module 与顶层 Script,并保留输入源码位置。 |
|
||||
| [src/runtime/event_dispatch.rs](../src/runtime/event_dispatch.rs) | 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 |
|
||||
| [src/runtime/events.rs](../src/runtime/events.rs) | 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 |
|
||||
| [src/runtime/io_tasks.rs](../src/runtime/io_tasks.rs) | 后台文件/网络 provider。线程池只携带纯数据、取消控制与弱 Task 身份,不持有 VM。 |
|
||||
| [src/runtime/mod.rs](../src/runtime/mod.rs) | 运行时模块入口。VM 生命周期、API、源程序组织、事件与异步任务在这里组合。 |
|
||||
| [src/runtime/tasks.rs](../src/runtime/tasks.rs) | owner/key 管理的异步任务与协作式源函数调度。弱索引用于查找,回调、等待对象和协程帧是托管图中的真实边。 |
|
||||
| [src/runtime/vm_sequence.rs](../src/runtime/vm_sequence.rs) | 构造共享 VM 调用序列与同帧循环驱动的字节码。 |
|
||||
| [src/shell/activity.rs](../src/shell/activity.rs) | 有界 HTTP 活动记录。保存请求/响应的可显示预览供 Network 工具读取,不负责执行请求。 |
|
||||
| [src/shell/app.rs](../src/shell/app.rs) | winit 原生应用生命周期和窗口事件入口。这里创建窗口、发布逻辑视口、转发输入,并把不可变文档画到原生表面。 |
|
||||
| [src/shell/clipboard.rs](../src/shell/clipboard.rs) | 把 UTF-8 文本读写适配到当前平台的系统剪贴板命令。 |
|
||||
| [src/shell/developer_tools.rs](../src/shell/developer_tools.rs) | Console/Sources/Network/Settings 的宿主交互协调与绘制状态。 |
|
||||
| [src/shell/developer_tools/console.rs](../src/shell/developer_tools/console.rs) | 绘制 Console 输入、结果历史和对应命中区域。 |
|
||||
| [src/shell/developer_tools/network.rs](../src/shell/developer_tools/network.rs) | 绘制已观察到的 HTTP 活动、请求列表与详情。 |
|
||||
| [src/shell/developer_tools/painter.rs](../src/shell/developer_tools/painter.rs) | 开发工具画面的绘制辅助:文字行、按钮、分隔线和滚动条。 |
|
||||
| [src/shell/developer_tools/selection.rs](../src/shell/developer_tools/selection.rs) | 多行文本选择和光标导航。选择端点映射到合法字符边界,避免把 UTF-8 字节中间当成一个字符。 |
|
||||
| [src/shell/developer_tools/settings.rs](../src/shell/developer_tools/settings.rs) | 绘制宿主设置页面和主题选择区域。 |
|
||||
| [src/shell/developer_tools/sources.rs](../src/shell/developer_tools/sources.rs) | 绘制 AST、当前源码快照和云文件视图。 |
|
||||
| [src/shell/mod.rs](../src/shell/mod.rs) | 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。 |
|
||||
| [src/shell/native_chrome.rs](../src/shell/native_chrome.rs) | macOS 原生标题栏适配。定位系统窗口按钮并沿用用户的标题栏双击设置。 |
|
||||
| [src/shell/sidebar.rs](../src/shell/sidebar.rs) | 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。 |
|
||||
| [src/shell/sources.rs](../src/shell/sources.rs) | 云文件浏览的 provider 抽象与工作区状态。默认 provider 未连接,不假装已有外部文件。 |
|
||||
| [src/shell/system_ui/interaction.rs](../src/shell/system_ui/interaction.rs) | 即时绘制控件的跨帧交互状态:稳定 WidgetId、悬停、按下捕获、键盘焦点和释放激活。 |
|
||||
| [src/shell/system_ui/mod.rs](../src/shell/system_ui/mod.rs) | 宿主管理控件的共享组件入口。主题、绘制原语和跨帧交互状态分离,用户 Endor 页面不依赖这里的控件树。 |
|
||||
| [src/shell/system_ui/theme.rs](../src/shell/system_ui/theme.rs) | 宿主控件的颜色与状态调色。根据背景、强调色和交互状态生成可读颜色,不改变业务状态。 |
|
||||
| [src/shell/system_ui/widgets.rs](../src/shell/system_ui/widgets.rs) | 用统一主题绘制宿主管理控件,并同步注册交互目标。 |
|
||||
| [src/shell/tabs.rs](../src/shell/tabs.rs) | 发布页面标签的数据结构。保留稳定页面身份、标题、选中状态和开发工具类别;页面的托管所有权由模型层管理。 |
|
||||
|
||||
## 其他文件
|
||||
|
||||
| 文件 | 本次改动及目的 |
|
||||
| --- | --- |
|
||||
| [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) | 说明 macOS 构建环境前提、失败退出、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 |
|
||||
| [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 |
|
||||
| [Cargo.toml](../Cargo.toml) | 解释每个依赖负责的能力、Aster 本地路径依赖、macOS 条件依赖与 release 优化配置。 |
|
||||
| [docs/code-comments.md](code-comments.md) | 更新本逐文件说明、示例索引和实际核对方式,使本次注释范围与可验证结果对应。 |
|
||||
|
||||
## Endor 示例逐文件说明
|
||||
|
||||
每个示例的类、方法及关键逻辑旁增加中文说明。`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.
|
||||
|
||||
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,18 +1,25 @@
|
||||
// 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。
|
||||
// owner/key 相同的新读取替换旧任务;失败只记录 Task.error,不执行 apply。
|
||||
// 持有三个显示对象与当前任务;task 初值占位,第一次 read 后替换为真实 Task。
|
||||
class FileReader {
|
||||
output: TextBlock
|
||||
problem: TextBlock
|
||||
status: TextBlock
|
||||
task: Object
|
||||
// 仅成功时调用,result 在成功回调期间已可读;写原 TextBlock 触发显示刷新。
|
||||
function apply(self: FileReader): Unit {
|
||||
self.output.text = self.task.result()
|
||||
}
|
||||
// 以 self 为 owner、file 为 key 启动读取,清空旧输出,并把状态/错误文字绑定到当前任务。
|
||||
function read(self: FileReader, path: String): Unit {
|
||||
self.task = ui.readText(self, "file", path, self.apply)
|
||||
self.output.text = ""
|
||||
// Task.status/error 是零参数只读方法,可直接作为绑定计算;换任务后绑定也随本次调用更新。
|
||||
self.status.bind("text", self.task.status)
|
||||
self.problem.bind("text", self.task.error)
|
||||
}
|
||||
}
|
||||
// 先建立界面对象,保持 reader 为可从 Console 操作的全局业务入口。
|
||||
output = TextBlock("尚未读取文件")
|
||||
problem = TextBlock("")
|
||||
status = TextBlock("")
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
// 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。
|
||||
// 完整响应保存为 String,界面只显示完成提示,避免把可能很大的响应放进文字控件。
|
||||
// response 是结果业务存储;output/problem/status 仅呈现任务进度与提示。
|
||||
class TextFetcher {
|
||||
output: TextBlock
|
||||
problem: TextBlock
|
||||
status: TextBlock
|
||||
task: Object
|
||||
response: String
|
||||
// 成功后取出完整 UTF-8 结果,写 response 供 Console 查询;provider 失败不会进入此方法。
|
||||
function apply(self: TextFetcher): Unit {
|
||||
self.response = self.task.result()
|
||||
self.output.text = "请求完成;在 REPL 查看 fetcher.response"
|
||||
}
|
||||
// 同一 self/request 身份的新请求取消旧交付,重置旧内容并把显示绑定到新任务。
|
||||
function fetch(self: TextFetcher, url: String): Unit {
|
||||
self.task = ui.fetchText(self, "request", url, self.apply)
|
||||
self.response = ""
|
||||
self.output.text = ""
|
||||
// 绑定读取当前 Task 方法,完成和失败由 worker 的任务状态变更传播。
|
||||
self.status.bind("text", self.task.status)
|
||||
self.problem.bind("text", self.task.error)
|
||||
}
|
||||
}
|
||||
// 界面不自动访问网络,用户以 Console 明确指定 URL 后才创建任务。
|
||||
output = TextBlock("尚未请求网络")
|
||||
problem = TextBlock("")
|
||||
status = TextBlock("")
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
// 把 callback-free 文件 Task 与 ui.spawn 协作函数组合:await 保存执行位置,等待完成后继续,不阻塞 VM worker。
|
||||
// readText 的 file 与 spawn 的 workflow 使用不同 key,分别管理读取和等待流程。
|
||||
// 保存 provider Task、协程 Task 和结果;两种任务都是托管对象,可以从 Console 独立查看。
|
||||
class AwaitReader {
|
||||
output: TextBlock
|
||||
status: TextBlock
|
||||
task: Object
|
||||
file: Object
|
||||
response: String
|
||||
// await 在文件未完成时挂起并保留 self/操作数;恢复后只执行后半段一次,再把字符串作为 workflow.result。
|
||||
function apply(self: AwaitReader): String {
|
||||
self.response = await self.file
|
||||
self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()"
|
||||
return self.response
|
||||
}
|
||||
// 先创建读任务,再调度普通函数 apply;spawn 而非新 async 语法提供可暂停执行上下文。
|
||||
function read(self: AwaitReader, path: String): Unit {
|
||||
self.file = ui.readText(self, "file", path)
|
||||
self.task = ui.spawn(self, "workflow", self.apply)
|
||||
self.response = ""
|
||||
self.output.text = "等待文件"
|
||||
// 显示的是 workflow 状态,等待 provider 时它仍报告 running;结束后 result 可从 Task 或 response 读取。
|
||||
self.status.bind("text", self.task.status)
|
||||
}
|
||||
}
|
||||
output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)")
|
||||
status = TextBlock("")
|
||||
// 占位值只用于满足初始字段构造;在 read 内被真实 Task 替换,页面建立不自动读文件。
|
||||
reader = AwaitReader(output,status,ui.state(0),ui.state(0),"")
|
||||
page = Page()
|
||||
page.title = "await 文件"
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// Containers compose independently of Page and of any camera.
|
||||
// 盒模型组合:容器与 Page/相机独立,border-box 尺寸、padding、margin 和对齐分别参与布局。
|
||||
// 同一文字共享内容,在左右不同有限分配中各自换行;外层 Overlay 负责叠放固定角标并裁剪溢出。
|
||||
// 内部布局基于容器自己的约束;Page 和相机只提供外部展示/投影上下文。
|
||||
// 文字 boxWidth=fill 占父分配的可用宽度,marginBottom 在文字边框盒外参与排布。
|
||||
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
|
||||
shared.boxWidth = "fill"
|
||||
shared.marginBottom = 8
|
||||
|
||||
// 左 Column 使用 fill、min/max 宽度限制和四边边框;非对称 paddingLeft 覆盖统一 padding。
|
||||
left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索"))
|
||||
left.boxWidth = "fill"
|
||||
left.minWidth = 180
|
||||
@@ -15,24 +19,29 @@ left.borderColor = "#CBD5E1"
|
||||
left.paddingLeft = 24
|
||||
left.crossAlign = "center"
|
||||
|
||||
// 右 Column 也引用同一 shared,但最大宽只有 240,因此对应 Mount 的换行高度可以不同。
|
||||
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
|
||||
right.boxWidth = "fill"
|
||||
right.maxWidth = 240
|
||||
right.crossAlign = "end"
|
||||
|
||||
// Row 分配有限 760 主轴空间,扣 padding/gap 后在两侧 fill 子项之间分配并服从上限。
|
||||
content = Row(24, 24, left, right)
|
||||
content.boxWidth = 760
|
||||
content.crossAlign = "start"
|
||||
// 角标是 Overlay 的另一子项,alignSelf 与 margin 控制它在容器交叉轴上的位置。
|
||||
badge = TextBlock("Endorphin · layout")
|
||||
badge.marginRight = 12
|
||||
badge.marginBottom = 12
|
||||
badge.alignSelf = "end"
|
||||
// 固定 800x400 的 Overlay 不按线性顺序挤开兄弟,overflow=clip 同时约束绘制和命中。
|
||||
surface = Overlay(0, 0, content, badge)
|
||||
surface.boxWidth = 800
|
||||
surface.boxHeight = 400
|
||||
surface.mainAlign = "end"
|
||||
surface.overflow = "clip"
|
||||
|
||||
// Page 只提供展示入口,根容器自身的盒模型规则不依赖页面导航方式。
|
||||
page = Page()
|
||||
page.title = "嵌套盒模型"
|
||||
page.render(surface)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Endor Campus — a multi-page learning environment inspired by Canvas LMS.
|
||||
// Fictional student/course data. Paste the complete source into Console and Enter.
|
||||
// All seven tabs are ordinary managed Pages. There is no server or Canvas login.
|
||||
// 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。
|
||||
// 无服务器、账号登录或真实上传;提交/评分按钮只应用预设字段值,其他页通过绑定读同一状态。
|
||||
// 作为一次完整输入提交;七页共用同一VM,会话重置后数据消失。
|
||||
// 白色圆角卡片容器,继承 Column 的排版,只覆盖自身背景绘制。
|
||||
class CampusPanel: Column {
|
||||
// 按当前出现位置 geometry 创建一条背景命令,子项由展示遍历继续绘制。
|
||||
function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList {
|
||||
fill = PaintCommand()
|
||||
fill.kind = "roundedRect"
|
||||
@@ -14,18 +16,23 @@ class CampusPanel: Column {
|
||||
return DisplayList(ObjectList(fill))
|
||||
}
|
||||
}
|
||||
// 通过额外 width 字段固定表格列的期望宽,保持成绩表头和行对齐。
|
||||
class CampusCell: Column {
|
||||
width: Number
|
||||
// 先算子内容自然高度,再改新测量结果宽度,保留真实字体引起的高度。
|
||||
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
result = Column.measure(self, mount, constraints)
|
||||
result.width = self.width
|
||||
return result
|
||||
}
|
||||
}
|
||||
// 页面内容里的演示导航栏,与应用自身的原生页面侧栏是两棵不同控件树。
|
||||
class CampusSidebar: Column {
|
||||
// 返回固定168x620期望尺寸,外层 ScrollBlock 承担超出区域的滚动。
|
||||
function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(168, 620)
|
||||
}
|
||||
// 为导航区创建深红背景,位置/大小来自当前Mount,文字等子项保持独立命令。
|
||||
function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList {
|
||||
fill = PaintCommand()
|
||||
fill.kind = "roundedRect"
|
||||
@@ -38,11 +45,14 @@ class CampusSidebar: Column {
|
||||
return DisplayList(ObjectList(fill))
|
||||
}
|
||||
}
|
||||
// Button 子类增加背景字段,固定导航按钮尺寸,并保留原生事件目标能力。
|
||||
class CampusNav: Button {
|
||||
background: String
|
||||
// 导航按钮期望144x40,确保四个入口宽高一致。
|
||||
function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(144, 40)
|
||||
}
|
||||
// 取原生按钮背景命令改色/圆角;文字由后面 replace 后的子项绘制,不在此重复生成。
|
||||
function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList {
|
||||
fill = Button.paint(self, mount, geometry).commands.at(0)
|
||||
fill.color = self.background
|
||||
@@ -50,6 +60,7 @@ class CampusNav: Button {
|
||||
return DisplayList(ObjectList(fill))
|
||||
}
|
||||
}
|
||||
// 课程业务记录,grade/progress 被多个页面绑定读取,其余静态字段构建时直接读取。
|
||||
class CampusCourse {
|
||||
title: String
|
||||
code: String
|
||||
@@ -57,6 +68,7 @@ class CampusCourse {
|
||||
grade: String
|
||||
progress: String
|
||||
}
|
||||
// 实验状态集中在一个对象,course 指向同一课程模型,评分因此能影响全站课程摘要。
|
||||
class CampusWork {
|
||||
status: String
|
||||
score: String
|
||||
@@ -65,12 +77,14 @@ class CampusWork {
|
||||
todo: String
|
||||
result: String
|
||||
course: CampusCourse
|
||||
// 模拟提交只改状态、文件名提示、待办和结果文字,不访问磁盘或网络。
|
||||
function submit(self: CampusWork, event: Event): Unit {
|
||||
self.status = "已提交 · 等待评分"
|
||||
self.submission = "lab01.endor · 已提交示例内容"
|
||||
self.todo = "已完成本次实验提交"
|
||||
self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
|
||||
}
|
||||
// 应用预设92分和反馈,并更新所属课程成绩/进度;绑定让仪表盘、课程、成绩页同步观察。
|
||||
function assess(self: CampusWork, event: Event): Unit {
|
||||
self.status = "已评分"
|
||||
self.score = "92 / 100"
|
||||
@@ -81,6 +95,7 @@ class CampusWork {
|
||||
self.course.progress = "已完成 4 / 6 个学习单元"
|
||||
self.result = "已应用预设评分;仪表盘、课程主页和成绩总览同步更新。"
|
||||
}
|
||||
// 恢复初始实验与课程状态,保留所有原对象和绑定,便于反复演示。
|
||||
function reset(self: CampusWork, event: Event): Unit {
|
||||
self.status = "未提交"
|
||||
self.score = "— / 100"
|
||||
@@ -92,20 +107,25 @@ class CampusWork {
|
||||
self.result = "已恢复实验、课程成绩和学习进度的示例状态。"
|
||||
}
|
||||
}
|
||||
// 用统一包装构造表格单元格;传入的content是已有Block引用。
|
||||
function campusCell(content: Block, width: Number): Block {
|
||||
return CampusCell(0, 0, content, width)
|
||||
}
|
||||
// 显式事件适配器把click的Event与Page.open可选Window参数分开,不能直接把Event误当窗口。
|
||||
class CampusTarget {
|
||||
page: Page
|
||||
// Consume the click Event here; Page.open's optional argument is a Window.
|
||||
// 事件参数在适配器这一层消化,Page.open 只接收其声明的可选 Window。
|
||||
// 消费事件但不传给Page.open;无参open表示在合适窗口打开/激活目标页。
|
||||
function open(self: CampusTarget, event: Event): Unit { self.page.open() }
|
||||
}
|
||||
// 为普通按钮创建持有目标Page的适配器,并把其绑定方法注册为click回调。
|
||||
function campusLink(label: String, target: Page): Button {
|
||||
button = Button(label)
|
||||
action = CampusTarget(target)
|
||||
button.on("click", action.open)
|
||||
return button
|
||||
}
|
||||
// 构造带指定背景的导航按钮,替换原生文字子槽为白字,然后复用事件适配器连接目标页。
|
||||
function campusNav(label: String, target: Page, background: String): Button {
|
||||
button = CampusNav(label, background)
|
||||
button.replace(0, TextBlock(label, 1, "#FFFFFF"))
|
||||
@@ -113,6 +133,7 @@ function campusNav(label: String, target: Page, background: String): Button {
|
||||
button.on("click", action.open)
|
||||
return button
|
||||
}
|
||||
// 用目标Page和四个色调参数构建整块导航;不同页面复用目标资源但用不同高亮背景。
|
||||
function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block {
|
||||
return CampusSidebar(12, 10,
|
||||
TextBlock("Endor", 2, "#FFFFFF"),
|
||||
@@ -125,6 +146,7 @@ function campusNavigation(home: Page, course: Page, assignments: Page, grades: P
|
||||
TextBlock("2026 秋季学期", 1, "#E7BAC0"),
|
||||
TextBlock("本地演示", 1, "#E7BAC0"))
|
||||
}
|
||||
// 把演示导航与标题/正文横排,外层有限ScrollBlock启用双轴,容纳固定宽度演示内容。
|
||||
function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block {
|
||||
scroll = ScrollBlock(1120, 680,
|
||||
Row(24, 28, navigation,
|
||||
@@ -135,6 +157,7 @@ function campusShell(navigation: Block, title: String, subtitle: String, body: B
|
||||
scroll.axis = "both"
|
||||
return scroll
|
||||
}
|
||||
// 卡片静态显示课程代码/标题/教师,grade 用绑定随评分变化,入口按钮引用真实课程Page。
|
||||
function campusCourseCard(course: CampusCourse, page: Page, color: String): Block {
|
||||
return CampusPanel(0, 0,
|
||||
RectBlock(248, 44, 8, color),
|
||||
@@ -145,6 +168,7 @@ function campusCourseCard(course: CampusCourse, page: Page, color: String): Bloc
|
||||
ui.textOf(course, "grade", 2, color),
|
||||
campusLink("进入课程", page)))
|
||||
}
|
||||
// 成绩表一行复用固定列宽单元格,当前grade为响应式文字,详情链接指向指定课程页。
|
||||
function campusGradeRow(course: CampusCourse, target: Page): Block {
|
||||
return Column(0, 12,
|
||||
Row(0, 0,
|
||||
@@ -154,6 +178,7 @@ function campusGradeRow(course: CampusCourse, target: Page): Block {
|
||||
campusLink("课程详情", target)),
|
||||
RectBlock(752, 1, 0, "#EDF0F3"))
|
||||
}
|
||||
// 组合课程概况/进度和静态学习单元;上方两个链接复用作业/成绩Page,不创建新页面。
|
||||
function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block {
|
||||
return Column(0, 20,
|
||||
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
|
||||
@@ -171,11 +196,13 @@ function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String
|
||||
RectBlock(752, 1, 0, "#EDF0F3"),
|
||||
TextBlock(moduleThree, 1, "#526372")))
|
||||
}
|
||||
// The shared object graph is created once; each Page projects those same objects.
|
||||
// 共享对象图只构造一次,各Page的文字绑定投影同一业务字段。
|
||||
// 只创建三份课程模型和一份实验模型,后续各页读取这些对象,避免页面间状态副本不一致。
|
||||
campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师 · 周二 / 周四", "86.0%", "已完成 3 / 6 个学习单元")
|
||||
campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元")
|
||||
campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元")
|
||||
campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming)
|
||||
// 先创建全部七个Page,允许导航按钮互相引用;随后逐一render建立内容。
|
||||
campusDashboard = Page()
|
||||
campusDashboard.title = "Campus · 仪表盘"
|
||||
campusProgrammingPage = Page()
|
||||
@@ -190,10 +217,12 @@ campusGrades = Page()
|
||||
campusGrades.title = "Campus · 成绩总览"
|
||||
campusWorkPage = Page()
|
||||
campusWorkPage.title = "实验 01 · 提交详情"
|
||||
// 预建四种高亮导航树,课程三页共享course导航,Page切换时各自拥有独立Mount。
|
||||
campusHomeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#A64350", "#852B38", "#852B38", "#852B38")
|
||||
campusCourseNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#A64350", "#852B38", "#852B38")
|
||||
campusAssignmentNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#A64350", "#852B38")
|
||||
campusGradeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#852B38", "#A64350")
|
||||
// 仪表盘展示课程卡和当前实验摘要,status/todo均从同一campusWork绑定读取。
|
||||
campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎回来。这里是你的课程与近期学习安排。",
|
||||
Column(0, 20,
|
||||
Row(0, 16,
|
||||
@@ -206,6 +235,7 @@ campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎
|
||||
ui.textOf(campusWork, "todo", 1, "#7C8793"),
|
||||
campusLink("打开作业详情", campusWorkPage)),
|
||||
TextBlock("虚构课程与学生数据 · Endor 本地多页面演示", 1, "#8C959F"))))
|
||||
// 程序设计课程附加实验入口;数学和英语页复用相同概况结构但读取不同课程模型。
|
||||
campusProgrammingPage.render(campusShell(campusCourseNav, "计算机程序设计", "课程 / CS 101 / 主页",
|
||||
Column(0, 16,
|
||||
campusCourseBody(campusProgramming, "通过实际编程理解对象、函数与交互。", "01 对象与字段 · 已完成", "02 函数与方法 · 已完成", "03 事件与可视界面 · 进行中", campusAssignments, campusGrades),
|
||||
@@ -214,6 +244,7 @@ campusMathPage.render(campusShell(campusCourseNav, "高等数学 I", "课程 / M
|
||||
campusCourseBody(campusMathematics, "学习极限、微分与积分的基本方法。", "01 函数与极限 · 已完成", "02 导数及其应用 · 已完成", "03 积分方法 · 进行中", campusAssignments, campusGrades)))
|
||||
campusEnglishPage.render(campusShell(campusCourseNav, "学术英语", "课程 / ENG 102 / 主页",
|
||||
campusCourseBody(campusEnglish, "阅读学术文本,练习清晰的论证与表达。", "01 Academic reading · 已完成", "02 Evidence and arguments · 已完成", "03 Research writing · 进行中", campusAssignments, campusGrades)))
|
||||
// 作业中心只有程序设计实验为共享动态模型,其余两项是固定演示文字。
|
||||
campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本学期 / 全部课程 / 作业与提交",
|
||||
Column(0, 20,
|
||||
CampusPanel(20, 12,
|
||||
@@ -227,6 +258,7 @@ campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本
|
||||
CampusPanel(20, 12,
|
||||
TextBlock("Reading response", 1, "#263444"),
|
||||
TextBlock("ENG 102 · 已评分 · 88 / 100", 1, "#7C8793")))))
|
||||
// 成绩总览复用课程grade绑定和实验score/feedback,评分无需逐页手动写UI。
|
||||
campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学期 / 当前课程成绩",
|
||||
Column(0, 20,
|
||||
CampusPanel(20, 16,
|
||||
@@ -242,12 +274,14 @@ campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学
|
||||
Row(0, 24, TextBlock("实验 01:对象与函数"), ui.textOf(campusWork, "score", 1, "#315E86")),
|
||||
ui.textOf(campusWork, "feedback", 1, "#6D7987"),
|
||||
campusLink("打开作业与反馈", campusWorkPage)))))
|
||||
// 三个操作按钮连接同一个实验对象的submit/assess/reset,均为真实源方法回调。
|
||||
campusSubmitButton = Button("模拟提交实验")
|
||||
campusSubmitButton.on("click", campusWork.submit)
|
||||
campusAssessButton = Button("应用示例评分")
|
||||
campusAssessButton.on("click", campusWork.assess)
|
||||
campusResetButton = Button("恢复演示数据")
|
||||
campusResetButton.on("click", campusWork.reset)
|
||||
// 详情页组合要求、提交状态、操作按钮和反馈;所有输入仍只是预设本地状态转换。
|
||||
campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情",
|
||||
Column(0, 20,
|
||||
Row(0, 16, campusLink("返回课程", campusProgrammingPage), campusLink("全部作业", campusAssignments), campusLink("成绩总览", campusGrades)),
|
||||
@@ -265,4 +299,5 @@ campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函
|
||||
Row(0, 24, TextBlock("成绩与反馈"), ui.textOf(campusWork, "score", 2, "#315E86")),
|
||||
ui.textOf(campusWork, "feedback", 1, "#6D7987")),
|
||||
ui.textOf(campusWork, "result", 1, "#7C8793"))))
|
||||
// 逐页render会改变当前活动页,最后显式选择仪表盘作为打开后的首页。
|
||||
campusDashboard.activate()
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
// 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。
|
||||
// 两个 textOf 共享同一模型字段,修改一次业务值会更新两个展示位置。
|
||||
// 定义业务状态和公共事件入口,Number/String 字段在构造与赋值时按声明契约检查。
|
||||
class Counter {
|
||||
value: Number
|
||||
lastEvent: String
|
||||
|
||||
// 保存实际事件种类,再通过 self 调用可覆盖的方法;接收者保持具体子类身份。
|
||||
function activate(self: Counter, event: Event): Unit {
|
||||
self.lastEvent = event.kind
|
||||
self.change()
|
||||
}
|
||||
|
||||
// 基类默认行为把值置为 10,子类可复用入口并替换这部分策略。
|
||||
function change(self: Counter): Unit {
|
||||
self.value = 10
|
||||
}
|
||||
}
|
||||
|
||||
// 继承 Counter 的字段与 activate,仅替换 change;构造参数仍按继承字段顺序传入。
|
||||
class DemoCounter: Counter {
|
||||
// 覆盖把值设为 12,演示从继承方法内部发起的调用也会选中实际接收者的方法。
|
||||
function change(self: DemoCounter): Unit {
|
||||
self.value = 12
|
||||
}
|
||||
}
|
||||
|
||||
// 创建唯一模型,之后所有绑定和按钮回调都指向它,无需把 GUI 状态复制到另一份存储。
|
||||
model = DemoCounter(9, "等待点击")
|
||||
first = ui.textOf(model, "value", 2)
|
||||
second = ui.textOf(model, "value", 2)
|
||||
eventName = ui.textOf(model, "lastEvent")
|
||||
button = Button("调用继承方法,再分派到覆盖方法")
|
||||
// 注册绑定方法,运行时将 click 的 Event 传给 activate;最后 Page.render 才把对象树显示出来。
|
||||
button.onClick(model.activate)
|
||||
page = Page()
|
||||
page.render(Column(24, 16, TextBlock("Endor class:继承、self 与动态方法分派", 2), Row(0, 16, first, second), button, eventName))
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
// 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。
|
||||
// 省略 RectBlock 的 radius/color 不应使字符串 tag 被错当成原生颜色参数。
|
||||
// 在原生矩形后增加 tag 字段;位置构造仍保留 width/height 及可选圆角/颜色规则。
|
||||
class TaggedRect: RectBlock { tag: String }
|
||||
// 在可变数量 Block 子项之后接收源 tag,子项列表与字段不混用。
|
||||
class TaggedColumn: Column { tag: String }
|
||||
// 保留原生事件 kind,再追加业务字符串字段。
|
||||
class TaggedEvent: Event { tag: String }
|
||||
// 原生列表成员之后追加 tag,列表元素仍可保存不同托管类型。
|
||||
class TaggedList: ObjectList { tag: String }
|
||||
// 将需要更新的矩形作为强业务引用保存,按钮回调只修改它的颜色。
|
||||
class Actions {
|
||||
rect: TaggedRect
|
||||
// 写原对象的 color,挂载显示由依赖跟踪刷新,不需要重新构造整个页面。
|
||||
function recolor(self: Actions): Unit { self.rect.color = "#237A45" }
|
||||
}
|
||||
// 这里刻意省略原生可选项,用末尾字符串初始化子类字段;后面的列表/Column 做对应可变参数演示。
|
||||
rect = TaggedRect(180, 60, "省略 radius/color 后的字段")
|
||||
event = TaggedEvent("click", "事件字段")
|
||||
items = TaggedList(rect, event, "列表成员之后的字段")
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。
|
||||
// 这里刻意重复增量语句,方便看到暂停点;取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
|
||||
// 每个实例有独立 steps 状态,两个任务不会把局部帧或计数存储混在一起。
|
||||
class CooperativeWork {
|
||||
steps: Object
|
||||
// 普通零参数方法被 spawn 调度后可跨量子继续;每条 increment 更新同一实例的计数。
|
||||
// 连续语句无需手工 yield,VM 按累计预算暂停,恢复时不会从第一条重放。
|
||||
function run(self: CooperativeWork): Unit {
|
||||
self.steps.increment()
|
||||
self.steps.increment()
|
||||
@@ -67,10 +72,12 @@ class CooperativeWork {
|
||||
self.steps.increment()
|
||||
}
|
||||
}
|
||||
// 创建两个独立 owner,虽然 key 都叫 work,也不会相互替换;owner/key 组合才是任务身份。
|
||||
left = CooperativeWork(ui.state(0))
|
||||
right = CooperativeWork(ui.state(0))
|
||||
leftTask = ui.spawn(left,"work",left.run)
|
||||
rightTask = ui.spawn(right,"work",right.run)
|
||||
// 状态文字绑定到 Task.status,业务进度可在 Console 查询 left/right 的 steps.value。
|
||||
leftStatus = TextBlock("")
|
||||
rightStatus = TextBlock("")
|
||||
leftStatus.bind("text",leftTask.status)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。
|
||||
// EventLog 用一个递增序列记录实际次序;改变监听器/传播标记可对比跳过与停止的语义。
|
||||
// 保存各阶段序号与最近 phase;count 是共享状态,其他字段直接写在本模型中。
|
||||
class EventLog {
|
||||
count: Object
|
||||
captureStep: Number
|
||||
@@ -5,24 +8,29 @@ class EventLog {
|
||||
targetStep: Number
|
||||
bubbleStep: Number
|
||||
phase: String
|
||||
// 祖先捕获阶段先递增序列,再保存 captureStep 和 Event.phase。
|
||||
function capture(self: EventLog, event: Event): Unit {
|
||||
self.count.increment()
|
||||
self.captureStep = self.count.value
|
||||
self.phase = event.phase
|
||||
}
|
||||
// 目标监听器的执行点;它排在目标类型 onEvent 方法之后。
|
||||
function target(self: EventLog, event: Event): Unit {
|
||||
self.count.increment()
|
||||
self.targetStep = self.count.value
|
||||
self.phase = event.phase
|
||||
}
|
||||
// 祖先冒泡阶段记录最后序号,未被 stopPropagation 时才到达。
|
||||
function bubble(self: EventLog, event: Event): Unit {
|
||||
self.count.increment()
|
||||
self.bubbleStep = self.count.value
|
||||
self.phase = event.phase
|
||||
}
|
||||
}
|
||||
// 覆盖目标方法,比额外 on(click,...) 监听器更早处理目标阶段事件。
|
||||
class EventButton: Button {
|
||||
model: EventLog
|
||||
// 把模型序列递增后写 methodStep,供断言与 capture/target/bubble 比较。
|
||||
function onEvent(self: EventButton, event: Event): Unit {
|
||||
self.model.count.increment()
|
||||
self.model.methodStep = self.model.count.value
|
||||
@@ -32,6 +40,8 @@ class EventButton: Button {
|
||||
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
|
||||
button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model)
|
||||
root = Column(24, 16, TextBlock("同一对象系统中的事件分派", 2), button, ui.textOf(model.count, "value"), ui.textOf(model, "phase"))
|
||||
// capture 选项显式选择捕获;不带选项的祖先监听器默认在冒泡阶段执行。
|
||||
// 返回的令牌可 disconnect,同次派发中断开尚未执行的监听器也应生效。
|
||||
capture = root.on("click", model.capture, "capture")
|
||||
target = button.on("click", model.target)
|
||||
bubble = root.on("click", model.bubble)
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
// 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。
|
||||
// 退出先取消任务、关闭窗口,再进行允许的卸载回调;已停止 VM 的清理不能重新进入源代码。
|
||||
// 在原生文字类型上增加共享日志,用来观察每个 Mount 的卸载通知。
|
||||
class ExitText: TextBlock {
|
||||
log: Object
|
||||
// 每次真实卸载递增同一 log;普通重绘不调用,重复 shutdown 也不应重复调用。
|
||||
function onUnmount(self: ExitText, mount: Mount): Unit {
|
||||
self.log.increment()
|
||||
}
|
||||
}
|
||||
log = ui.state(0)
|
||||
// 文字业务对象只创建一次,Page 与另一窗都引用它,但两边有各自的 Mount 生命周期。
|
||||
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
|
||||
page = Page()
|
||||
page.title = "退出生命周期"
|
||||
page.render(Column(24,16,watched,ui.textOf(log,"value")))
|
||||
// 创建同 VM 内的另一原生窗口;迁移 other 后仍保留 watched 的业务身份。
|
||||
second = Window()
|
||||
second.setTitle("Exit lifecycle secondary")
|
||||
other = Page()
|
||||
other.render(watched)
|
||||
other.moveToWindow(second)
|
||||
// 应用级长定时任务用于观察退出会取消尚未完成的工作,不让退出过程等满 30 秒。
|
||||
pending = ui.sleep(ui,"pending",30000)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。
|
||||
// 绑定显示读取该字段,GUI 回调与 Console 赋值受同一契约约束。
|
||||
// 增加 value:Number;构造时先传原生 text,再传源字段值。
|
||||
class CounterText: TextBlock {
|
||||
value: Number
|
||||
// 收到 click 后把已有数值改为 10,依赖文字随之更新,不修改原生 text 内容。
|
||||
function change(self: CounterText, event: Event): Unit {
|
||||
self.value = 10
|
||||
}
|
||||
}
|
||||
// 声明 target 必须是 Block,可接收 CounterText,因为它是 TextBlock 的子类。
|
||||
class Model {
|
||||
target: Block
|
||||
}
|
||||
// 构造唯一受检对象,Model、事件回调和 textOf 均引用它。
|
||||
counter = CounterText("共享的原生子类对象", 9)
|
||||
model = Model(counter)
|
||||
button = Button("把 Number 字段改成 10")
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。
|
||||
// 增长达到上限或收缩达到下限后,剩余空间/缺额重新分给其他尚可调整的项,固定项保持 60。
|
||||
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
|
||||
class Strip: Row {
|
||||
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
|
||||
function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(340, 60)
|
||||
}
|
||||
}
|
||||
// 分别持有两个可调整矩形,按钮切换增长/收缩策略,而不是手动写最终 bounds。
|
||||
class Actions {
|
||||
first: RectBlock
|
||||
second: RectBlock
|
||||
// 两项 basis=100,按1:3分增长;首项 max=105 触顶后,多余增长转给第二项。
|
||||
function grow(self: Actions): Unit {
|
||||
self.first.flexMin = 0
|
||||
self.first.flexMax = 105
|
||||
@@ -14,6 +20,7 @@ class Actions {
|
||||
self.first.flex = 1
|
||||
self.second.flex = 3
|
||||
}
|
||||
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
|
||||
function shrink(self: Actions): Unit {
|
||||
self.first.flexMax = 300
|
||||
self.first.flexMin = 150
|
||||
@@ -25,6 +32,7 @@ class Actions {
|
||||
self.second.flexShrink = 1
|
||||
}
|
||||
}
|
||||
// 自然宽20/40与basis可不同;fixed不设置权重,保持原始60宽。
|
||||
first = RectBlock(20, 20, 4, "#A4C8F0")
|
||||
second = RectBlock(40, 20, 4, "#A6D8B2")
|
||||
fixed = RectBlock(60, 20, 4, "#E8C990")
|
||||
@@ -33,6 +41,7 @@ grow = Button("增长:上限后重新分配")
|
||||
grow.onClick(actions.grow)
|
||||
shrink = Button("收缩:最小尺寸后重新分配")
|
||||
shrink.onClick(actions.shrink)
|
||||
// 先初始化增长策略再挂载,首次发布即采用105/135/60,不需等待按钮点击。
|
||||
actions.grow()
|
||||
strip = Strip(10, 10, first, second, fixed)
|
||||
page = Page()
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。
|
||||
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
|
||||
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
|
||||
class Actions {
|
||||
filler: RectBlock
|
||||
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
|
||||
function balanced(self: Actions): Unit { self.filler.flex = 1 }
|
||||
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
|
||||
function narrow(self: Actions): Unit { self.filler.flex = 3 }
|
||||
}
|
||||
// 共享文字的 flex=1 是业务布局策略,具体几何与自然尺寸缓存仍属于各 Mount。
|
||||
paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
|
||||
paragraph.flex = 1
|
||||
filler = RectBlock(20, 20)
|
||||
@@ -12,6 +18,7 @@ balanced = Button("等比例分配")
|
||||
balanced.onClick(actions.balanced)
|
||||
narrow = Button("文字只占四分之一")
|
||||
narrow.onClick(actions.narrow)
|
||||
// 首排两个可伸缩子项,次排只有文字 flex 与固定 300px 兄弟,因此首排按钮不会改次排宽度。
|
||||
first = Row(10, 10, paragraph, filler)
|
||||
second = Row(10, 10, paragraph, RectBlock(300, 20))
|
||||
page = Page()
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。
|
||||
// 布局使用实际字形宽度,iiii 与 WWWW 后的色块位置应不同,不能用字符数量估算宽度。
|
||||
// 字号2与独立颜色用于标题,中文是否可见取决于运行环境可用CJK字体。
|
||||
let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422")
|
||||
// 同一文字控件包含中英文和重音字符,用于观察字体回退与基线。
|
||||
let detail = ui.text("中文 English 123 — café", 1, "#606060")
|
||||
// 两组相同尺寸色块跟在窄/宽字符后,12px gap 保持一致,差异来自真实文字测量。
|
||||
let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF"))
|
||||
// 根Column统一padding和间距,render后这些对象才有具体Mount与绘图命令。
|
||||
let page = ui.page()
|
||||
page.render(ui.column(24, 12, title, detail, row))
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
// Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。
|
||||
// 参数和返回注解在真实调用边界校验,事件注册同样使用这些签名。
|
||||
// Number 输入原样返回,用于说明参数类型在进入函数体前检查。
|
||||
function identity(value: Number): Number { return value }
|
||||
// 接受 Function 值并零参数调用,结果仍需符合本函数的 Number 返回契约。
|
||||
function callNumber(callback: Function): Number { return callback() }
|
||||
// 接受声明 Visual 能力的对象,返回 Block;调用时两侧契约都要成立。
|
||||
function visual(value: Visual): Block { return value }
|
||||
// 保留共享 state,read 作为普通绑定函数使用,click 作为接收 Event 的事件回调。
|
||||
class Actions {
|
||||
state: Object
|
||||
// 读取模型值而不修改业务状态,保存 actions.read 时接收者已绑定。
|
||||
function read(self: Actions): Number { return self.state.value }
|
||||
// 事件入口可忽略 Event 内容,但参数契约仍要求它是 Event,再修改同一 state。
|
||||
function click(self: Actions, event: Event): Unit { self.state.increment() }
|
||||
}
|
||||
state = ui.state(0)
|
||||
actions = Actions(state)
|
||||
// 保存绑定方法对象,之后无参调用仍使用 actions 作为 self;不是重新按字符串查找对象。
|
||||
savedFunction = actions.read
|
||||
initial = identity(callNumber(savedFunction))
|
||||
button = Button("调用带类型检查的对象方法")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Record ui.stats().objects, call mountCycle(), then inspect phase.value and
|
||||
// pending.value.status(). Close the tab: the referenced Block still waits.
|
||||
// releaseCycle() removes both external references; the next objects query
|
||||
// should return to the same baseline before the five-minute wait finishes.
|
||||
// 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。
|
||||
// 关闭临时页仍可因业务引用保留等待任务;releaseCycle 清掉外部引用后,缓存不应让整个引用环常驻。
|
||||
// 比较操作前后对象数时,关闭页面只解除展示根;holder/pending 外部引用也释放后才应回到基线。
|
||||
holder = ui.state(0)
|
||||
pending = ui.state(0)
|
||||
phase = ui.state(0)
|
||||
// 通过 peer 指回包含自己的 Page,配合等待协程形成包含视觉缓存读依赖的引用环。
|
||||
class CycleRect: RectBlock {
|
||||
peer: Object
|
||||
// sleep 归属该 rect,await 保留协程帧;先写 phase=1,只有实际恢复后才写 2 并改 Page 标题。
|
||||
function wait(self: CycleRect): Unit {
|
||||
gate = ui.sleep(self,"sleep",300000)
|
||||
phase.set(1)
|
||||
@@ -14,15 +15,18 @@ class CycleRect: RectBlock {
|
||||
phase.set(2)
|
||||
self.peer.title = "等待已结束"
|
||||
}
|
||||
// 故意读取 peer.title,让布局依赖包含循环中的 Page;缓存应借用观察,不增加长期 GC 根。
|
||||
function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
observed = self.peer.title
|
||||
return MeasureResult(180,40)
|
||||
}
|
||||
// 绘制也读取 peer.title,检查绘制缓存与等待帧不会共同意外保活循环。
|
||||
function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList {
|
||||
observed = self.peer.title
|
||||
return RectBlock.paint(self,mount,geometry)
|
||||
}
|
||||
}
|
||||
// 创建临时 Page 和 CycleRect,相互引用后挂载,再把 Page/Task 分别放入两个外部 holder。
|
||||
function mountCycle(): Unit {
|
||||
transient = Page()
|
||||
transient.title = "临时引用环"
|
||||
@@ -32,11 +36,13 @@ function mountCycle(): Unit {
|
||||
phase.set(0)
|
||||
pending.set(ui.spawn(rect,"workflow",rect.wait))
|
||||
}
|
||||
// 先关闭临时页,随后去掉两个外部引用;等待依赖无需等满五分钟,失去可达性后应整体回收。
|
||||
function releaseCycle(): Unit {
|
||||
holder.value.close()
|
||||
holder.set(0)
|
||||
pending.set(0)
|
||||
}
|
||||
// 基线页面始终存在,用它比较操作前后的托管对象数,不把临时页面当作基线。
|
||||
base = Page()
|
||||
base.title = "GC 基线"
|
||||
base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。
|
||||
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使绘制失效。
|
||||
color = "#ED6A5A"
|
||||
// 保持原生几何,覆盖颜色策略,使示例只观察全局读取的依赖粒度。
|
||||
class GlobalPaint: RectBlock {
|
||||
// 调用原生基类产出新命令,读取当前 color 并写到新命令;不在只读阶段修改全局。
|
||||
function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList {
|
||||
drawing = RectBlock.paint(self,mount,geometry)
|
||||
drawing.commands.at(0).color = color
|
||||
return drawing
|
||||
}
|
||||
}
|
||||
// 显示固定大小矩形和操作提示,后续通过 Console 改同一个会话的 color。
|
||||
rect = GlobalPaint(480,120,12,"#DDEEFF")
|
||||
page = Page()
|
||||
page.title = "全局名字依赖"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
function heading(value: Text): View {
|
||||
return ui.text(value, 2, "#242422")
|
||||
function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。
|
||||
return ui.text(value, 2, "#242422") // 这里只创建文字对象,具体宽高由实际字体和出现位置约束测量。
|
||||
}
|
||||
|
||||
function main(): View {
|
||||
let title = heading("Hello Endor")
|
||||
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
|
||||
let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI"))
|
||||
return ui.column(24, 12, title, rectangle, detail)
|
||||
function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。
|
||||
let title = heading("Hello Endor") // 调用上述辅助函数,局部变量持有同一托管文字对象。
|
||||
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 矩形使用逻辑尺寸,圆角与颜色由原生载荷保存。
|
||||
let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI")) // Row 依次摆放文字、固定间隔和文字;共享几何仍由每次出现位置承担。
|
||||
return ui.column(24, 12, title, rectangle, detail) // Column 的 24px 内边距和 12px 间隔决定最终命令位置;返回后发布快照拥有所需数据。
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// All application state, motion, fire, hit reactions and scheduling live here.
|
||||
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
|
||||
g = ui.graphics
|
||||
math = g.math
|
||||
assets = "examples/assets/manuscript/"
|
||||
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
|
||||
story = g.textSlice(g.readText(assets + "story.txt"), 1, 1000000)
|
||||
bodyText = g.prepareText(story, font, 21, 34, "#2A1A0A", 2)
|
||||
smallText = g.prepareText(story, font, 16, 26, "#2A1A0A", 2)
|
||||
hint = g.prepareText("Move the pointer. Hold to breathe fire.", font, 14, 22, "#705B44", 2)
|
||||
|
||||
function sprite(image: Object, x: Number, y: Number, scale: Number, angle: Number, ax: Number, ay: Number): Object {
|
||||
return g.sprite(image, x, y, image.width * scale, image.height * scale, angle, ax, ay, 1)
|
||||
}
|
||||
function wrappedAngle(angle: Number): Number {
|
||||
result = angle
|
||||
while result > math.pi { result = result - math.pi * 2 }
|
||||
while result < -math.pi { result = result + math.pi * 2 }
|
||||
return result
|
||||
}
|
||||
class Segment {
|
||||
x: Number
|
||||
y: Number
|
||||
angle: Number
|
||||
image: Object
|
||||
}
|
||||
// Asset orientation is application data: angles are radians, clockwise in
|
||||
// screen coordinates. Each PNG is calibrated against its own long axis.
|
||||
class FireArt {
|
||||
image: Object
|
||||
axis: Number
|
||||
}
|
||||
class Direction { angle: Number }
|
||||
class Flame {
|
||||
x: Number
|
||||
y: Number
|
||||
vx: Number
|
||||
vy: Number
|
||||
size: Number
|
||||
age: Number
|
||||
art: FireArt
|
||||
}
|
||||
class Dragon {
|
||||
segments: Object
|
||||
flames: Object
|
||||
fireImages: Object
|
||||
tongue: Object
|
||||
frontWing: Object
|
||||
backWing: Object
|
||||
targetX: Number
|
||||
targetY: Number
|
||||
pressed: Number
|
||||
time: Number
|
||||
lastMove: Number
|
||||
seed: Number
|
||||
function random(self: Dragon): Number {
|
||||
self.seed = self.seed + 1
|
||||
value = math.sin(self.seed * 12.9898) * 43758.5453
|
||||
return value - math.floor(value)
|
||||
}
|
||||
function pointer(self: Dragon, event: Event): Unit {
|
||||
bounds = event.mount.bounds()
|
||||
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||||
self.targetX = (event.x - (bounds.width - 700 * scale) / 2) / scale
|
||||
self.targetY = (event.y - 30) / scale
|
||||
self.lastMove = math.now()
|
||||
}
|
||||
function down(self: Dragon, event: Event): Unit {
|
||||
self.pointer(event)
|
||||
self.pressed = 1
|
||||
}
|
||||
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
|
||||
function leave(self: Dragon, event: Event): Unit {
|
||||
self.pressed = 0
|
||||
self.lastMove = -10
|
||||
}
|
||||
function step(self: Dragon): Unit {
|
||||
self.time = math.now()
|
||||
if self.time - self.lastMove > 2 {
|
||||
x = math.float(150)
|
||||
y = math.float(110)
|
||||
i = 0
|
||||
while i < self.segments.count {
|
||||
s = self.segments.at(i)
|
||||
angle = -i / 19 * math.pi * 0.7
|
||||
if i > 0 {
|
||||
x = x - math.cos(angle) * 30
|
||||
y = y - math.sin(angle) * 30
|
||||
}
|
||||
s.x = s.x + (x - s.x) * 0.12
|
||||
s.y = s.y + (y - s.y) * 0.12
|
||||
s.angle = s.angle + wrappedAngle(angle - s.angle) * 0.12
|
||||
i = i + 1
|
||||
}
|
||||
} else {
|
||||
head = self.segments.at(0)
|
||||
dx = self.targetX - head.x
|
||||
dy = self.targetY - head.y
|
||||
distance = math.sqrt(dx * dx + dy * dy)
|
||||
if distance > 4 {
|
||||
speed = math.min(distance, math.max(12, distance * 0.15))
|
||||
head.x = head.x + dx / distance * speed
|
||||
head.y = head.y + dy / distance * speed
|
||||
head.angle = math.atan2(dy, dx)
|
||||
}
|
||||
i = 1
|
||||
while i < self.segments.count {
|
||||
previous = self.segments.at(i - 1)
|
||||
s = self.segments.at(i)
|
||||
angle = math.atan2(previous.y - s.y, previous.x - s.x)
|
||||
s.angle = previous.angle + math.clamp(wrappedAngle(angle - previous.angle), -0.25, 0.25)
|
||||
s.x = previous.x - math.cos(s.angle) * 30
|
||||
s.y = previous.y - math.sin(s.angle) * 30
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
i = self.flames.count
|
||||
while i > 0 {
|
||||
i = i - 1
|
||||
p = self.flames.at(i)
|
||||
p.age = p.age + 1
|
||||
p.x = p.x + p.vx
|
||||
p.y = p.y + p.vy
|
||||
p.vx = p.vx * 0.95
|
||||
p.vy = p.vy * 0.95 - 0.6
|
||||
if p.age >= 12 { self.flames.remove(i) }
|
||||
}
|
||||
if self.pressed > 0 {
|
||||
head = self.segments.at(0)
|
||||
i = 0
|
||||
while i < 1 {
|
||||
if self.flames.count < 12 {
|
||||
angle = head.angle + (self.random() - 0.5) * 0.25
|
||||
speed = 20 + self.random() * 12
|
||||
size = 12 + self.random() * 8
|
||||
art = self.fireImages.at(math.floor(self.random() * self.fireImages.count))
|
||||
self.flames.append(Flame(head.x + math.cos(head.angle) * 30, head.y + math.sin(head.angle) * 30, math.cos(angle) * speed, math.sin(angle) * speed, size, 0, art))
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
|
||||
commands = ObjectList()
|
||||
wing = self.segments.at(5)
|
||||
commands.append(sprite(self.backWing, ox + wing.x * scale, oy + wing.y * scale, scale * 0.24, wing.angle + math.sin(self.time * 3) * 0.4, 1, 1))
|
||||
i = self.segments.count
|
||||
while i > 0 {
|
||||
i = i - 1
|
||||
s = self.segments.at(i)
|
||||
ax = math.float(0.5)
|
||||
if i == 0 {
|
||||
ax = 0.45
|
||||
commands.append(sprite(self.tongue, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, -s.image.width * 0.3 / self.tongue.width, 0.5))
|
||||
}
|
||||
commands.append(sprite(s.image, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, ax, 0.5))
|
||||
if i == 5 { commands.append(sprite(self.frontWing, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle - math.sin(self.time * 3 + 0.5) * 0.4, 1, 1)) }
|
||||
}
|
||||
i = 0
|
||||
while i < self.flames.count {
|
||||
p = self.flames.at(i)
|
||||
image = p.art.image
|
||||
imageScale = p.size * scale / math.max(image.width, image.height)
|
||||
angle = math.atan2(p.vy, p.vx) - p.art.axis
|
||||
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
|
||||
drawing.opacity = 1 - p.age / 12
|
||||
if p.age > 4 { drawing.tint = "#E08A30" }
|
||||
if p.age > 8 { drawing.tint = "#F0C030" }
|
||||
commands.append(drawing)
|
||||
i = i + 1
|
||||
}
|
||||
return commands
|
||||
}
|
||||
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
|
||||
i = 0
|
||||
while i < commands.count {
|
||||
c = commands.at(i)
|
||||
j = 0
|
||||
while j < self.flames.count {
|
||||
p = self.flames.at(j)
|
||||
dx = c.x - ox - p.x * scale
|
||||
if math.abs(dx) < 60 * scale {
|
||||
dy = c.y - oy - p.y * scale
|
||||
if math.abs(dy) < 60 * scale {
|
||||
distance = math.sqrt(dx * dx + dy * dy)
|
||||
if distance > 0.1 {
|
||||
if distance < 60 * scale {
|
||||
weight = (1 - distance / (60 * scale)) * (1 - p.age / 12)
|
||||
c.x = c.x + dx / distance * weight * 8 * scale
|
||||
c.y = c.y + dy / distance * weight * 8 * scale
|
||||
c.angle = c.angle + weight * 0.12
|
||||
c.opacity = math.max(0.3, c.opacity - weight * 0.2)
|
||||
c.tint = "#C4402A"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
j = j + 1
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
class Manuscript: RectBlock {
|
||||
dragon: Dragon
|
||||
page: Object
|
||||
prepared: Object
|
||||
small: Object
|
||||
initial: Object
|
||||
timer: Object
|
||||
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
|
||||
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
|
||||
}
|
||||
function onMount(self: Manuscript, mount: Mount): Unit {
|
||||
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||||
}
|
||||
function onUnmount(self: Manuscript, mount: Mount): Unit { self.timer.cancel() }
|
||||
function tick(self: Manuscript): Unit {
|
||||
self.dragon.step()
|
||||
self.timer = ui.after(self.page, "animation", 80, self.tick)
|
||||
}
|
||||
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
|
||||
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
|
||||
ox = bounds.x + (bounds.width - 700 * scale) / 2
|
||||
oy = bounds.y + 30
|
||||
margin = 45 * scale
|
||||
text = self.prepared
|
||||
line = 34
|
||||
if bounds.width < 540 {
|
||||
text = self.small
|
||||
line = 26
|
||||
}
|
||||
initialHeight = line * 7
|
||||
initialWidth = self.initial.width / self.initial.height * initialHeight
|
||||
art = self.dragon.artwork(ox, oy, scale)
|
||||
exclusions = ObjectList(g.rectExclusion(ox + margin, oy + margin, initialWidth + 12, initialHeight))
|
||||
i = 0
|
||||
while i < art.count {
|
||||
exclusions.append(g.imageExclusion(art.at(i), 6 * scale))
|
||||
i = i + 1
|
||||
}
|
||||
letters = 0
|
||||
if self.dragon.flames.count > 0 { letters = 1 }
|
||||
drawing = g.flowText(text, ox + margin, oy + margin, 700 * scale - margin * 2, math.max(0, bounds.height - 90 - margin), exclusions, letters)
|
||||
if self.dragon.flames.count > 0 { self.dragon.heat(drawing.commands, ox, oy, scale) }
|
||||
commands = RectBlock.paint(self, mount, bounds).commands
|
||||
commands.append(g.sprite(self.initial, ox + margin, oy + margin, initialWidth, initialHeight, 0, 0, 0, 1))
|
||||
commands.extend(drawing.commands)
|
||||
commands.extend(art)
|
||||
footer = g.flowText(hint, bounds.x + 20, bounds.y + bounds.height - 28, math.max(0, bounds.width - 40), 24, ObjectList(), 0)
|
||||
commands.extend(footer.commands)
|
||||
return DisplayList(commands)
|
||||
}
|
||||
}
|
||||
|
||||
segments = ObjectList()
|
||||
headImage = g.loadImage(assets + "dragon-sprites/head.png")
|
||||
segments.append(Segment(math.float(150), math.float(110), math.float(0), headImage))
|
||||
function makeBody(): Unit {
|
||||
i = 1
|
||||
x = math.float(150)
|
||||
y = math.float(110)
|
||||
while i < 20 {
|
||||
angle = -i / 19 * math.pi * 0.7
|
||||
x = x - math.cos(angle) * 30
|
||||
y = y - math.sin(angle) * 30
|
||||
image = g.loadImage(assets + "dragon-sprites/body-" + g.string(i) + ".png")
|
||||
segments.append(Segment(x, y, angle, image))
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
makeBody()
|
||||
fireImages = ObjectList()
|
||||
// Layer 2..11: baseline directions differ; these values belong to this artwork.
|
||||
fireDirections = ObjectList(Direction(-0.50), Direction(-0.30), Direction(-0.55), Direction(0.40), Direction(-1.35), Direction(-1.57), Direction(-1.57), Direction(0.05), Direction(-1.40), Direction(1.12))
|
||||
function loadFire(): Unit {
|
||||
i = 2
|
||||
while i <= 11 {
|
||||
image = g.loadImage(assets + "fire-sprites/Layer " + g.string(i) + ".png")
|
||||
fireImages.append(FireArt(image, fireDirections.at(i - 2).angle))
|
||||
i = i + 1
|
||||
}
|
||||
}
|
||||
loadFire()
|
||||
dragon = Dragon(segments, ObjectList(), fireImages, g.loadImage(assets + "dragon-sprites/tongue.png"), g.loadImage(assets + "dragon-sprites/wing-front.png"), g.loadImage(assets + "dragon-sprites/wing-back.png"), math.float(150), math.float(110), 0, math.now(), -10, math.float(42))
|
||||
page = Page()
|
||||
page.title = "Illustrated Manuscript · Endor"
|
||||
view = Manuscript(700, 800, 0, "#F4EEE0", dragon, page, bodyText, smallText, g.loadImage(assets + "dropcap.png"), ObjectList())
|
||||
view.flex = 1
|
||||
view.boxWidth = "fill"
|
||||
page.crossAlign = "stretch"
|
||||
moveConnection = view.on("pointermove", dragon.pointer)
|
||||
downConnection = view.on("pointerdown", dragon.down)
|
||||
upConnection = view.on("pointerup", dragon.up)
|
||||
leaveConnection = view.on("pointerleave", dragon.leave)
|
||||
cancelConnection = view.on("pointercancel", dragon.leave)
|
||||
page.render(view)
|
||||
@@ -1,32 +1,44 @@
|
||||
// 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。
|
||||
// ViewportBand 还显式读取实际页面视口,使原生 resize 可通过依赖触发重测。
|
||||
// 只负责测量需要的宽度,和绘制颜色分成两个业务对象以便观察独立失效。
|
||||
class WidthModel {
|
||||
width: Number
|
||||
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
|
||||
function expand(self: WidthModel, event: Event): Unit {
|
||||
self.width = 260
|
||||
}
|
||||
}
|
||||
// 保存 paint 使用的颜色,不参与 CachedRect 的测量。
|
||||
class Tint {
|
||||
color: String
|
||||
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
|
||||
function blue(self: Tint, event: Event): Unit {
|
||||
self.color = "#526AC7"
|
||||
}
|
||||
}
|
||||
// 原生矩形的尺寸/颜色策略分别由源协议覆盖,业务模型通过字段持有。
|
||||
class CachedRect: RectBlock {
|
||||
dimensions: WidthModel
|
||||
tint: Tint
|
||||
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
|
||||
function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(self.dimensions.width, 60)
|
||||
}
|
||||
// 复用原生矩形命令,再改新命令的颜色;修改新结果被允许,但 paint 不能修改已有业务对象。
|
||||
function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList {
|
||||
drawing = RectBlock.paint(self, mount, geometry)
|
||||
drawing.commands.at(0).color = self.tint.color
|
||||
return drawing
|
||||
}
|
||||
}
|
||||
// 宽度来自 ui.viewport 的当前逻辑页面区域,显示窗口变化也能成为测量依赖。
|
||||
class ViewportBand: RectBlock {
|
||||
// 返回可用视口宽度,最终仍服从父容器约束;外层 padding 会减少分配宽度。
|
||||
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(ui.viewport().width, 24)
|
||||
}
|
||||
}
|
||||
// 创建相互独立的宽度/颜色模型并绑定两个按钮,用相同对象观察缓存与可见结果。
|
||||
dimensions = WidthModel(200)
|
||||
tint = Tint("#ED6A5A")
|
||||
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
// 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。
|
||||
// paragraph 被两个容器共享;每个 Mount 使用独立约束,不能把新宽度写回共享文字状态。
|
||||
// 保存第一个段落出现位置的分配宽度,按钮只改策略值。
|
||||
class Policy {
|
||||
width: Number
|
||||
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
|
||||
function narrow(self: Policy, event: Event): Unit { self.width = 280 }
|
||||
// 选择 560 逻辑像素宽度,段落变矮;另一个容器宽度不跟着改变。
|
||||
function wide(self: Policy, event: Event): Unit { self.width = 560 }
|
||||
}
|
||||
// 在原生 Column 排列基础上覆盖第一个稳定子槽的宽度。
|
||||
class ArrangedColumn: Column {
|
||||
policy: Policy
|
||||
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
|
||||
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
|
||||
result = Column.arrange(self, mount, geometry)
|
||||
result.children.at(0).width = self.policy.width
|
||||
return result
|
||||
}
|
||||
}
|
||||
// 通过源 measure 改整个容器的期望宽度,以对比 arrange 分配路径。
|
||||
class MeasuredColumn: Column {
|
||||
width: Number
|
||||
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
|
||||
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
result = Column.measure(self, mount, constraints)
|
||||
result.width = self.width
|
||||
return result
|
||||
}
|
||||
}
|
||||
// 两个容器使用同一个 paragraph,分别由 Policy=280 与 measure.width=360 约束。
|
||||
policy = Policy(280)
|
||||
paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。")
|
||||
tail = TextBlock("这一行跟随上方段落。")
|
||||
@@ -26,10 +36,12 @@ arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
|
||||
measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360)
|
||||
narrow = Button("arrange 为 280")
|
||||
wide = Button("arrange 为 560")
|
||||
// 保留事件订阅引用,两个按钮调用同一 Policy 的不同方法。
|
||||
narrowConnection = narrow.on("click", policy.narrow)
|
||||
wideConnection = wide.on("click", policy.wide)
|
||||
page = Page()
|
||||
page.title = "约束协商"
|
||||
page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured))
|
||||
// 成功输入结束后宿主才消费原生窗口命令;窗口实际尺寸再回传为下一次布局约束。
|
||||
ui.window().resize(850, 700)
|
||||
ui.window().setTitle("测量与排列直到稳定")
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。
|
||||
// 返回 Column 视觉对象,宿主选择/挂载页面;这里的 title 和 rectangle 只在 VM 内建立业务引用。
|
||||
function main(): View {
|
||||
// 文字内容与矩形参数和 page 示例一致,最终绘图命令应等价。
|
||||
let heading = ui.text("Hello Endor", 2, "#242422")
|
||||
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
|
||||
// 布局边距与间隔保持一致;不同 VM 的对象/Mount 身份独立,命令相同不表示共享对象。
|
||||
return ui.column(24, 12, heading, rectangle)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
// 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。
|
||||
// 两个文字绑定读同一 counter;原生点击和 Console 都能操作该对象并传播显示变化。
|
||||
// state 提供可写 value 和 increment 方法,textOf 建立显式字段到文本的依赖。
|
||||
counter = ui.state(9)
|
||||
first = ui.textOf(counter, "value", 2)
|
||||
second = ui.textOf(counter, "value", 2)
|
||||
title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2)
|
||||
button = Button("增加计数")
|
||||
// 零参数源回调委托原生 state.increment;事件分发器根据签名调用,不强塞 Event 参数。
|
||||
function increment(): Unit { counter.increment() }
|
||||
// 把实际函数作为回调而非函数名文本;render 之前只创建业务对象,render 后才有出现位置。
|
||||
button.onClick(increment)
|
||||
page = Page()
|
||||
page.render(Column(24, 16, title, Row(0, 16, first, second, button)))
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
// 方法覆盖遵守参数可更宽、返回可更具体的规则。
|
||||
// WideRect 复用基类原生测量后返回 Sized 子类结果;事件改变基础 width 后按同一路径重测。
|
||||
// MeasureResult 的名义子类,不增加字段,用于演示返回值协变。
|
||||
class Sized: MeasureResult {}
|
||||
// 用源方法显式委托原生测量,保留 Mount/Constraints 的基类契约。
|
||||
class BaseRect: RectBlock {
|
||||
// 原生方法按给定出现位置与约束测量,不把几何存进共享对象。
|
||||
function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return RectBlock.measure(self, mount, constraints)
|
||||
}
|
||||
}
|
||||
// 覆盖参数放宽为 Object,返回缩窄为 Sized;不能反过来缩窄参数或放宽返回。
|
||||
class WideRect: BaseRect {
|
||||
// 调用基类后复制测量数值到新的 Sized;新结果仍满足 MeasureResult 协议。
|
||||
function measure(self: WideRect, mount: Object, constraints: Object): Sized {
|
||||
measured = BaseRect.measure(self, mount, constraints)
|
||||
return Sized(measured.width, measured.height)
|
||||
}
|
||||
}
|
||||
// 保存具体 WideRect 和计数器,使宽度修改和点击次数同时作为业务变化传播。
|
||||
class Actions {
|
||||
rect: WideRect
|
||||
count: Object
|
||||
// 先改 width 再递增计数,新的布局刷新会使用上述动态测量覆盖。
|
||||
function widen(self: Actions): Unit {
|
||||
self.rect.width = 260
|
||||
self.count.increment()
|
||||
}
|
||||
}
|
||||
// 初始矩形宽 180;按钮事件完成后应变为 260,同时文字显示计数。
|
||||
rect = WideRect(180, 60, 8, "#ED6A5A")
|
||||
count = ui.state(0)
|
||||
actions = Actions(rect, count)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。
|
||||
// 普通数据变化不会重新挂载;页面关闭时子项先卸载,父项随后卸载,旧 Mount 不因重开而复活。
|
||||
// order 是统一时序,mounted/unmounted 是子项次数;latest 保存最近通知的实际 Mount。
|
||||
class LifecycleLog {
|
||||
order: Object
|
||||
mounted: Object
|
||||
@@ -8,8 +11,10 @@ class LifecycleLog {
|
||||
childUnmounted: Number
|
||||
latest: Object
|
||||
}
|
||||
// 共享文字用 log 记录两个出现位置,源钩子接收的是当前具体 Mount。
|
||||
class WatchedText: TextBlock {
|
||||
log: LifecycleLog
|
||||
// 记录顺序和次数,保存 Mount 并修改原文字;运行时在回调后重新收敛才发布。
|
||||
function onMount(self: WatchedText, mount: Mount): Unit {
|
||||
self.log.order.increment()
|
||||
self.log.mounted.increment()
|
||||
@@ -17,6 +22,7 @@ class WatchedText: TextBlock {
|
||||
self.log.latest.set(mount)
|
||||
self.setText("同一个对象,两个独立挂载位置")
|
||||
}
|
||||
// 记录已经脱离的出现位置;保留 Mount 可查询 mounted,但不能再当有效 bounds 使用。
|
||||
function onUnmount(self: WatchedText, mount: Mount): Unit {
|
||||
self.log.order.increment()
|
||||
self.log.unmounted.increment()
|
||||
@@ -24,17 +30,21 @@ class WatchedText: TextBlock {
|
||||
self.log.latest.set(mount)
|
||||
}
|
||||
}
|
||||
// 父容器也记录钩子序号,便于对比挂载与子先父后的卸载顺序。
|
||||
class WatchedColumn: Column {
|
||||
log: LifecycleLog
|
||||
// 记录父挂载顺序,不修改容器结构;子项随后各自执行通知。
|
||||
function onMount(self: WatchedColumn, mount: Mount): Unit {
|
||||
self.log.order.increment()
|
||||
self.log.parentMounted = self.log.order.value
|
||||
}
|
||||
// 父卸载时子项卸载已完成,序列号应位于所有子项通知之后。
|
||||
function onUnmount(self: WatchedColumn, mount: Mount): Unit {
|
||||
self.log.order.increment()
|
||||
self.log.parentUnmounted = self.log.order.value
|
||||
}
|
||||
}
|
||||
// 多个 state 容器分别保存计数/最后 Mount,root 最后参数是源 log 字段,前面才是可变视觉子项。
|
||||
log = LifecycleLog(ui.state(0), ui.state(0), ui.state(0), 0, 0, 0, 0, ui.state(0))
|
||||
child = WatchedText("等待挂载", 2, log)
|
||||
root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log)
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
// 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。
|
||||
// 同一事件注册机制按实际签名调用;label 绑定再把事件 kind 转成可显示的字符串。
|
||||
// 保留最后一次收到的 Event 对象,初值 kind=waiting;不是只存一份格式化事件文本。
|
||||
received = ui.state(Event("waiting"))
|
||||
count = ui.state(0)
|
||||
// 只读计算提取当前事件种类,作为 label.text 绑定的依赖。
|
||||
function eventKind(): String { return received.value.kind }
|
||||
label = TextBlock("waiting")
|
||||
subscription = label.bind("text", eventKind)
|
||||
withEvent = Button("传递 Event 给原生 set")
|
||||
// received.set 是一参数原生方法,分发器传入实际 click Event。
|
||||
firstConnection = withEvent.on("click", received.set)
|
||||
withoutEvent = Button("调用零参数原生 increment")
|
||||
// count.increment 的签名为零参数,分发器调用时不传多余 Event。
|
||||
secondConnection = withoutEvent.on("click", count.increment)
|
||||
// 把已绑定状态与两种回调放到同一页面,便于对比输入效果。
|
||||
page = Page()
|
||||
page.title = "原生方法签名"
|
||||
page.render(Column(24, 16, TextBlock("源码和原生回调共用参数契约"), label, ui.textOf(count, "value"), withEvent, withoutEvent))
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
// 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。
|
||||
// label 在树中出现多次但仍是同一对象,原生方法和源字段写入共同更新各出现位置。
|
||||
// 扩展 TextBlock 的业务字段;其文本/颜色仍由原生父类存储与绘制。
|
||||
class StatusText: TextBlock {
|
||||
activeColor: String
|
||||
|
||||
// 用继承的 setText/setColor 改原对象;activeColor 是构造时追加的源字段。
|
||||
function activate(self: StatusText, event: Event): Unit {
|
||||
self.setText("继承的原生文本已更新")
|
||||
self.setColor(self.activeColor)
|
||||
}
|
||||
}
|
||||
|
||||
// 继续继承源子类,添加 message 并覆盖事件入口,保留 TextBlock 的原生能力。
|
||||
class CustomStatus: StatusText {
|
||||
message: String
|
||||
|
||||
// 显式调用基类 setText 以示范原生未绑定方法的接收者传递,再用继承方法改颜色。
|
||||
function activate(self: CustomStatus, event: Event): Unit {
|
||||
TextBlock.setText(self, self.message)
|
||||
self.setColor(self.activeColor)
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮保存目标 Block,事件到来时把同一 Event 转交给目标的动态 activate。
|
||||
class ActionButton: Button {
|
||||
target: Block
|
||||
|
||||
// 不复制目标或创建新展示对象,直接调用目标业务方法,选择实际类型的覆盖实现。
|
||||
function activate(self: ActionButton, event: Event): Unit {
|
||||
self.target.activate(event)
|
||||
}
|
||||
}
|
||||
|
||||
// Page 也可增加源字段;caption 用于构建页内标题,不替代原生窗口标题。
|
||||
class CustomPage: Page {
|
||||
caption: String
|
||||
}
|
||||
|
||||
// 前面位置参数供原生 TextBlock 构造,后面的颜色/消息供继承字段;所有引用都保留托管类型。
|
||||
label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作")
|
||||
button = ActionButton("调用源码子类方法", label)
|
||||
button.onClick(button.activate)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
// owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。
|
||||
// 每个 owner 下同 key 的重复点击替换旧定时器;任务生命周期不由画面是否可见推断。
|
||||
// 把 owner 与要修改的 state 分开持有,使同一个调度方法可用于三个作用域。
|
||||
class Actions {
|
||||
owner: Object
|
||||
state: Object
|
||||
// 定时任务成功交付后修改共享计数,零参数签名符合 timer 回调要求。
|
||||
function complete(self: Actions): Unit { self.state.increment() }
|
||||
// 每次启动 1500ms 的 load 定时器;相同 owner/key 只有最新任务继续等待。
|
||||
function schedule(self: Actions, event: Event): Unit {
|
||||
ui.after(self.owner, "load", 1500, self.complete)
|
||||
}
|
||||
}
|
||||
state = ui.state(0)
|
||||
// 该 Block 从未进入页面树,但全局引用仍持有它,因此它拥有的任务可执行。
|
||||
hidden = TextBlock("这个 Block 没有挂载,也能拥有任务")
|
||||
page = Page()
|
||||
page.title = "任务生命周期"
|
||||
// 三个 Action 共享一个 state,却分别归属 Page、Block、应用,便于比较关闭页面的取消边界。
|
||||
pageActions = Actions(page, state)
|
||||
blockActions = Actions(hidden, state)
|
||||
appActions = Actions(ui, state)
|
||||
@@ -20,5 +27,6 @@ pageConnection = pageButton.on("click", pageActions.schedule)
|
||||
blockConnection = blockButton.on("click", blockActions.schedule)
|
||||
appConnection = appButton.on("click", appActions.schedule)
|
||||
page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton))
|
||||
// 额外应用级任务延迟 500ms,用于展示没有点击也可按 worker 空闲轮询交付。
|
||||
initial = ui.after(ui, "initial", 500, appActions.complete)
|
||||
ui.window().setTitle("Owned Tasks")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
let heading = ui.text("Hello Endor", 2, "#242422")
|
||||
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
|
||||
let myui = ui.page()
|
||||
myui.render(ui.column(24, 12, heading, rectangle))
|
||||
let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。
|
||||
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 创建固定逻辑尺寸矩形;12 为圆角半径,业务对象尚未出现在任何页面中。
|
||||
let myui = ui.page() // 创建托管 Page 资源;它负责持有展示根和页面生命周期。
|
||||
myui.render(ui.column(24, 12, heading, rectangle)) // 把文字和矩形放入 Column,padding=24、gap=12;render 选择此页并生成出现位置及发布帧。
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
// 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。
|
||||
// 生成的视图保存 source 指向业务模型,绑定和按钮继续使用原对象,展示不是业务数据副本。
|
||||
// name/color 是业务字段,零参读取方法提供只读绑定,update 提供事件修改。
|
||||
class Product {
|
||||
name: String
|
||||
color: String
|
||||
// 返回名称并记录实际属性读取,后续 name 修改会使文本绑定重算。
|
||||
function label(self: Product): String { return self.name }
|
||||
// 只读返回颜色,绑定到新建色块的 color。
|
||||
function shade(self: Product): String { return self.color }
|
||||
// 点击更新原业务对象,现有绑定会同步文字和矩形,不重新调用整个 present 构造树。
|
||||
function update(self: Product, event: Event): Unit {
|
||||
self.name = "原业务对象已更新"
|
||||
self.color = "#237A45"
|
||||
}
|
||||
// 在指定 Window 上下文创建文字、色块和按钮,连接原 self 的方法后返回组合根。
|
||||
function present(self: Product, window: Window): Block {
|
||||
title = TextBlock(self.name)
|
||||
swatch = RectBlock(180,48,8,self.color)
|
||||
// 绑定令牌本身不承载另一份业务状态,绑定计算仍调用同一 Product。
|
||||
titleBinding = title.bind("text",self.label)
|
||||
colorBinding = swatch.bind("color",self.shade)
|
||||
button = Button("调用业务对象方法")
|
||||
connection = button.on("click",self.update)
|
||||
result = Column(24,16,title,swatch,button)
|
||||
// 保留原始业务身份,便于代码从展示根回到来源;contextWindow 仅记录创建时的窗口编号。
|
||||
result.source = self
|
||||
result.contextWindow = window.id()
|
||||
return result
|
||||
}
|
||||
}
|
||||
// 显式声明能力并验证方法契约;只有同名 present 不会自动成为 Presentable。
|
||||
ui.implement(Product,Presentable)
|
||||
product = Product("普通对象的显式展示能力", "#526AC7")
|
||||
// 先获得视觉根,再由 ui.open 把它挂载到页面;最后设置 Page 标题。
|
||||
view = ui.present(product)
|
||||
page = ui.open(view)
|
||||
page.title = "Presentable"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。
|
||||
// 绑定在目标隐藏时也会更新;两个页面共享同一组 Block,不需要两份模型。
|
||||
// width/color/label 是唯一业务来源,绑定计算只读,expand 是显式事件写入入口。
|
||||
class Model {
|
||||
width: Number
|
||||
color: String
|
||||
label: String
|
||||
// 返回 Number,目标 width 属性同时做类型/尺寸验证。
|
||||
function getWidth(self: Model): Number { return self.width }
|
||||
// 返回颜色文本;依赖只包含实际读取的模型属性。
|
||||
function getColor(self: Model): String { return self.color }
|
||||
// 返回当前说明文字,供 TextBlock.text 绑定使用。
|
||||
function getLabel(self: Model): String { return self.label }
|
||||
// 一个事件同时改三个属性,随后绑定传播,布局与绘制在完整刷新后发布。
|
||||
function expand(self: Model, event: Event): Unit {
|
||||
self.width = 260
|
||||
self.color = "#237A45"
|
||||
@@ -14,6 +21,7 @@ class Model {
|
||||
model = Model(120, "#526AC7", "点击更新共享对象")
|
||||
rect = RectBlock(120, 40, 8, "#526AC7")
|
||||
label = TextBlock("初始化")
|
||||
// 保存三个可断开的绑定令牌;令牌不强持有隐藏目标,目标仍需由业务对象图保留。
|
||||
widthBinding = rect.bind("width", model.getWidth)
|
||||
colorBinding = rect.bind("color", model.getColor)
|
||||
labelBinding = label.bind("text", model.getLabel)
|
||||
@@ -23,6 +31,7 @@ root = Column(24, 16, label, rect, button)
|
||||
page = Page()
|
||||
page.title = "通用属性绑定"
|
||||
page.render(root)
|
||||
// 第二页面使用同一根容器,切页只更换 Mount 展示上下文;最后 activate 回第一页面。
|
||||
second = Page()
|
||||
second.title = "同一组 Block"
|
||||
second.render(root)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// The scene has world positions; each finite viewport has its own camera.
|
||||
// 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。
|
||||
// 内层视口也可作为世界子项;HUD 在外层 Overlay 中,所以不会随世界相机变换。
|
||||
// 场景保存世界对象,视口负责有限窗口与相机变换。
|
||||
count = ui.state(0)
|
||||
increment = Button("共享计数 +1")
|
||||
increment.onClick(count.increment)
|
||||
// 业务卡片带世界 x/y 和边框,count 文字与按钮共同保留同一 state。
|
||||
card = Column(20, 12, TextBlock("世界里的 UI", 2), ui.textOf(count, "value", 3), increment)
|
||||
card.boxWidth = 220
|
||||
card.x = -60
|
||||
@@ -9,17 +12,21 @@ card.y = 40
|
||||
card.borderLeft = 2
|
||||
card.borderColor = "#8CA5B8"
|
||||
|
||||
// 有限内层视口看自己的小场景,再以世界坐标放进外层 Scene2D。
|
||||
inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9")))
|
||||
inner.x = 260
|
||||
inner.y = 40
|
||||
// 两个外层视口引用同一 world,卡片/内层视口因此各产生两个出现位置。
|
||||
world = Scene2D(card, inner)
|
||||
// 左右视口尺寸和相机平移/缩放不同;显式赋同一 camera 才会共享导航状态。
|
||||
left = CanvasViewport(460, 320, world)
|
||||
left.camera.x = -100
|
||||
right = CanvasViewport(320, 320, world)
|
||||
right.camera.x = -80
|
||||
right.camera.zoom = 0.65
|
||||
// Set right.camera = left.camera to share a view explicitly.
|
||||
// 如需联动,在 Console 将 right.camera 赋为 left.camera;共享场景本身不会自动共享相机。
|
||||
|
||||
// 先横排两个视图,再把固定 HUD 叠在它们之上,输入命中也使用组合后的几何。
|
||||
views = Row(0, 20, left, right)
|
||||
hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放"))
|
||||
hud.alignSelf = "end"
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
// 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。
|
||||
// 三维内容与普通 Endor 二维控件一起排版,viewport 有限尺寸不限制场景世界坐标。
|
||||
// 保存左侧视口和共享立方体;动作可改相机位置或真实网格变换。
|
||||
class SceneActions {
|
||||
view: Viewport3D
|
||||
cube: Mesh3D
|
||||
// 把左相机放到 z=6 正面,保持相机既有 target。
|
||||
function front(self: SceneActions): Unit {
|
||||
self.view.camera.x = 0
|
||||
self.view.camera.y = 0
|
||||
self.view.camera.z = 6
|
||||
}
|
||||
// 把同一相机移到侧上方,右侧正交相机保持原设置。
|
||||
function side(self: SceneActions): Unit {
|
||||
self.view.camera.x = 6
|
||||
self.view.camera.y = 2
|
||||
self.view.camera.z = 0
|
||||
}
|
||||
// 修改共享 cube 的欧拉旋转,因此两个视口都看到物体变化。
|
||||
function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) }
|
||||
// 示范 Event.hit.mesh 的真实托管网格材质写入;此文件尚未把 select 注册到视口。
|
||||
function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" }
|
||||
}
|
||||
// 先构造并变换两个 Box3D,再由 Group3D 组成场景节点;几何与相机是不同对象。
|
||||
cube = Box3D(1.6, 1.6, 1.6, "#6687AB")
|
||||
cube.rotationY = 25
|
||||
cube.rotationX = 15
|
||||
@@ -21,11 +29,13 @@ small = Box3D(0.7, 0.7, 0.7, "#DBA36E")
|
||||
small.x = 1.8
|
||||
small.z = -0.8
|
||||
group = Group3D(cube, small)
|
||||
// 薄盒作地面,方向光与 ambient 一起影响材质颜色,不是二维背景矩形。
|
||||
floor = Box3D(6, 0.08, 4, "#D8DEE6")
|
||||
floor.y = -1.1
|
||||
light = DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9)
|
||||
scene = Scene3D(group, floor, light)
|
||||
scene.ambient = 0.35
|
||||
// 左视口使用默认透视相机,右视口显式换成 OrthographicCamera 并设置可见高度。
|
||||
perspective = Viewport3D(480, 360, scene)
|
||||
perspective.camera.z = 6
|
||||
orthographic = Viewport3D(320, 360, scene)
|
||||
@@ -34,6 +44,7 @@ orthographic.camera.height = 5
|
||||
orthographic.camera.x = 4
|
||||
orthographic.camera.y = 2
|
||||
orthographic.camera.z = 5
|
||||
// 按钮绑定零参数方法;页面文字说明原生默认控制器的交互,代码可继续替换 controller。
|
||||
actions = SceneActions(perspective, cube)
|
||||
front = Button("正面")
|
||||
front.onClick(actions.front)
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
// 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。
|
||||
// 同一 paragraph 出现两次,文字内容共享,换行/偏移/范围在各 Mount 中独立。
|
||||
// 通过具体出现位置的 Mount 操作偏移;ScrollBlock.axis 则是对象级策略,可影响其所有出现位置。
|
||||
class Actions {
|
||||
vertical: ScrollBlock
|
||||
horizontal: ScrollBlock
|
||||
// 只对 vertical 第一个 Mount 累加纵偏移,运行时按当前范围夹取。
|
||||
function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) }
|
||||
// 只对 horizontal 第一个 Mount 累加横偏移,禁止轴仍保持零。
|
||||
function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) }
|
||||
// 分别把两个出现位置移回原点,不修改共享段落文字。
|
||||
function reset(self: Actions): Unit {
|
||||
self.vertical.mounts().at(0).scrollTo(0, 0)
|
||||
self.horizontal.mounts().at(0).scrollTo(0, 0)
|
||||
}
|
||||
// 把左对象策略改为双轴,下一次求解重新计算约束和范围。
|
||||
function both(self: Actions): Unit { self.vertical.axis = "both" }
|
||||
}
|
||||
paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。")
|
||||
// 纵向区域padding后内容宽为244,长文字按此有限宽换行。
|
||||
vertical = ScrollBlock(260, 140, paragraph)
|
||||
vertical.padding = 8
|
||||
vertical.axis = "vertical"
|
||||
// 横向区域沿滚动轴允许内容自然展开,所以同一文字可以比左侧更宽、更矮。
|
||||
horizontal = ScrollBlock(260, 140, paragraph)
|
||||
horizontal.padding = 8
|
||||
horizontal.axis = "horizontal"
|
||||
// 绑定四个零参数按钮回调;最终把两视图与控制区放在普通 Column/Row 中。
|
||||
actions = Actions(vertical, horizontal)
|
||||
down = Button("向下滚动")
|
||||
down.onClick(actions.down)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。
|
||||
// 滚动只改变投影/裁剪,不复制业务计数;被裁剪内容不能通过原始矩形接受点击。
|
||||
// 保留一个源 paint 委托,检验源命令与原生命令应用相同滚动裁剪。
|
||||
class Painted: RectBlock {
|
||||
// 使用当前 geometry 交给原生基类;外层滚动变换/clip 由展示流程统一组合。
|
||||
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
|
||||
return RectBlock.paint(self,mount,geometry)
|
||||
}
|
||||
}
|
||||
// 三个按钮复用同一个 Action,任意可见按钮点击都递增同一 count。
|
||||
class Actions {
|
||||
count: Object
|
||||
// 签名接受 Event,业务逻辑只需修改计数,不直接处理滚动位置。
|
||||
function click(self: Actions, event: Event): Unit { self.count.increment() }
|
||||
}
|
||||
count = ui.state(0)
|
||||
@@ -15,12 +21,14 @@ last = Button("底部按钮")
|
||||
firstConnection = first.on("click",actions.click)
|
||||
middleConnection = middle.on("click",actions.click)
|
||||
lastConnection = last.on("click",actions.click)
|
||||
// 高内容夹有三个按钮与矩形,使固定320x180 scroller产生可观察的横纵滚动范围。
|
||||
body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last)
|
||||
scroller = ScrollBlock(320,180,body)
|
||||
root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller)
|
||||
page = Page()
|
||||
page.title = "滚动示例"
|
||||
page.render(root)
|
||||
// 另一窗口复用 root/scroller;它们的第二个 Mount 偏移与主窗口第一个 Mount 无关。
|
||||
other = Window()
|
||||
other.setTitle("Scroll Secondary")
|
||||
second = Page()
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
// 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。
|
||||
// 圆外部分可落到父容器监听器;按下与释放须经相同合法目标复核。
|
||||
// 扩展矩形的绘制、命中和事件方法,用同一个 hits 状态观察交互结果。
|
||||
class CircleTarget: RectBlock {
|
||||
hits: Object
|
||||
// point 已转换到该出现位置局部坐标;使用圆心(40,40)、半径40,而不是整个 160x80 外框。
|
||||
function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool {
|
||||
return point.inCircle(40, 40, 40)
|
||||
}
|
||||
// 把原生命令宽度缩为 80、圆角半径设为 40,得到与 hitTest 对应的圆形区域。
|
||||
function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList {
|
||||
drawing = RectBlock.paint(self, mount, geometry)
|
||||
drawing.commands.at(0).width = 80
|
||||
drawing.commands.at(0).radius = 40
|
||||
return drawing
|
||||
}
|
||||
// 圆内点击递增自身计数并停止向祖先传播;父容器不会为同一次点击再递增。
|
||||
function onEvent(self: CircleTarget, event: Event): Unit {
|
||||
self.hits.increment()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
hits = ui.state(0)
|
||||
// 父监听器单独计数;它和圆形目标显示在同页,便于比较穿过透明区的输入。
|
||||
background = ui.state(0)
|
||||
circle = CircleTarget(160, 80, 0, "#237A45", hits)
|
||||
root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value"))
|
||||
// 订阅在父 Column 上,圆外有效区域可回退到父目标;源命中只读检查不执行这些业务回调。
|
||||
connection = root.on("click", background.increment)
|
||||
page = Page()
|
||||
page.render(root)
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
// 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。
|
||||
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
|
||||
// 保存宽度与左偏移;两字段只在事件回调中修改。
|
||||
class Metrics {
|
||||
width: Number
|
||||
left: Number
|
||||
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
|
||||
function expand(self: Metrics, event: Event): Unit {
|
||||
self.width = 260
|
||||
self.left = 68
|
||||
}
|
||||
}
|
||||
// 复用原生矩形载荷,仅覆盖尺寸策略;同一 rect 放在不同父容器可得到不同测量。
|
||||
class MeasuredRect: RectBlock {
|
||||
// 首次测量前结构 Mount 链接已建立,因此可从 parent.block 读取当前出现位置的父策略。
|
||||
// 此阶段还不能查询最终 bounds,返回值使用 MeasureResult 名义类型。
|
||||
function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
parent = mount.parent()
|
||||
return MeasureResult(parent.block.metrics.width, 60)
|
||||
}
|
||||
}
|
||||
// 在原生排列结果上改第一个子槽 x,其他子项仍用 Column 的堆叠规则。
|
||||
class OffsetColumn: Column {
|
||||
metrics: Metrics
|
||||
// Arrangement 里的 x 是当前排列使用的坐标;读取 metrics.left 成为排列依赖。
|
||||
function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement {
|
||||
positions = Column.arrange(self, mount, geometry)
|
||||
positions.children.at(0).x = self.metrics.left
|
||||
return positions
|
||||
}
|
||||
}
|
||||
// 将原生参数与源字段传给具体子类,button 事件更新模型,Page.render 建立完整展示关系。
|
||||
metrics = Metrics(200, 44)
|
||||
rect = MeasuredRect(80, 20, 0, "#526AC7")
|
||||
button = Button("更新布局")
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
// 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。
|
||||
// 同一 rect 出现两次,两份命令随同一 tint 更新;业务身份共享,几何按 Mount 分开。
|
||||
// 原生矩形测量不变,仅将绘制颜色切换为源字段 tint。
|
||||
class TintRect: RectBlock {
|
||||
tint: String
|
||||
// 从原生基类取得当前出现位置的矩形命令,再修改本次返回列表中的颜色并交回。
|
||||
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
|
||||
drawing = RectBlock.paint(self, mount, geometry)
|
||||
drawing.commands.at(0).color = self.tint
|
||||
return drawing
|
||||
}
|
||||
}
|
||||
// 在原生文字前增加圆角底色,背景大小使用实际分配 geometry 而不是固定猜测值。
|
||||
class Badge: TextBlock {
|
||||
background: String
|
||||
// 先完整填写 roundedRect 命令,再取原生文字命令改成白色;列表顺序决定先画背景后画文字。
|
||||
function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList {
|
||||
fill = PaintCommand()
|
||||
// 命令 kind 决定宿主解码所需字段,坐标/宽高来自本次 Mount,radius/color 由业务策略提供。
|
||||
fill.kind = "roundedRect"
|
||||
fill.x = geometry.x
|
||||
fill.y = geometry.y
|
||||
@@ -17,17 +24,21 @@ class Badge: TextBlock {
|
||||
fill.height = geometry.height
|
||||
fill.radius = 6
|
||||
fill.color = self.background
|
||||
// 复用原生文字塑形和换行所需参数,避免手工文字命令漏掉边界。
|
||||
text = TextBlock.paint(self, mount, geometry).commands.at(0)
|
||||
text.color = "#FFFFFF"
|
||||
return DisplayList(ObjectList(fill, text))
|
||||
}
|
||||
}
|
||||
// 保存目标 TintRect,onEvent 是托管事件目标方法覆盖,不额外创建另一份状态。
|
||||
class RecolorButton: Button {
|
||||
target: TintRect
|
||||
// 事件阶段可修改已有业务字段;依赖跟踪检测 tint 变化后重绘两个出现位置。
|
||||
function onEvent(self: RecolorButton, event: Event): Unit {
|
||||
self.target.tint = "#526AC7"
|
||||
}
|
||||
}
|
||||
// 两次使用 rect 是同一对象,Badge 与按钮独立;根容器只组合它们,不复制业务数据。
|
||||
rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A")
|
||||
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
|
||||
button = RecolorButton("通过对象方法改变绘制颜色", rect)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// Student grades demo. Fictional records; in-memory only.
|
||||
// Paste the entire file into Tessera Console, then press Enter.
|
||||
// 学生成绩中心为纯内存演示,记录均虚构,不连接学校数据库。
|
||||
// 按钮操作 GradeCenter/Student 原对象;成绩行与摘要用 textOf 绑定,同一学生在全部/复核视图中复用业务身份。
|
||||
// 整个文件作为一次完整源码输入提交;状态只存在本次 VM,会话重置后不保留。
|
||||
// 为 Column 增加期望宽度字段,使手工表格各列对齐,而非让文字自然宽度决定列边界。
|
||||
class GradeCell: Column {
|
||||
width: Number
|
||||
// 先保留 Column 的自然高度,再把新测量结果宽度设为字段值,最终仍由父约束协商。
|
||||
function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
size = Column.measure(self, mount, constraints)
|
||||
size.width = self.width
|
||||
return size
|
||||
}
|
||||
}
|
||||
// 背景绘制由源覆盖,子项仍按普通 Column 排列与绘制。
|
||||
class GradePanel: Column {
|
||||
// 按本次 geometry 生成白色圆角底板;不重绘或复制子项命令,后代由统一遍历处理。
|
||||
function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList {
|
||||
fill = PaintCommand()
|
||||
fill.kind = "roundedRect"
|
||||
@@ -21,6 +26,7 @@ class GradePanel: Column {
|
||||
return DisplayList(ObjectList(fill))
|
||||
}
|
||||
}
|
||||
// 姓名/学号和各科成绩/状态是业务字段;数值修改受 Number 声明检查。
|
||||
class Student {
|
||||
name: String
|
||||
number: String
|
||||
@@ -29,9 +35,11 @@ class Student {
|
||||
english: Number
|
||||
status: String
|
||||
}
|
||||
// 用统一无 padding/gap 的单格容器包装任意 Block,末尾 width 是源字段构造参数。
|
||||
function gradeCell(content: Block, width: Number): Block {
|
||||
return GradeCell(0, 0, content, width)
|
||||
}
|
||||
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与固定列宽保持表格结构。
|
||||
function gradeRow(student: Student): Block {
|
||||
return Column(0, 12,
|
||||
Row(0, 0,
|
||||
@@ -43,6 +51,7 @@ function gradeRow(student: Student): Block {
|
||||
gradeCell(ui.textOf(student, "status", 1, "#526C83"), 160)),
|
||||
RectBlock(730, 1, 0, "#EDF0F3"))
|
||||
}
|
||||
// 保存全部/复核两棵现成子树与可替换 table,同时持有要复核的真实 Student。
|
||||
class GradeCenter {
|
||||
table: Column
|
||||
all: Column
|
||||
@@ -50,20 +59,24 @@ class GradeCenter {
|
||||
student: Student
|
||||
pending: Number
|
||||
message: String
|
||||
// 替换稳定槽0的内容为全部行,复用已有学生,不重新请求/重建数据。
|
||||
function showAll(self: GradeCenter, event: Event): Unit {
|
||||
self.table.replace(0, self.all)
|
||||
self.message = "正在查看全部 6 位学生"
|
||||
}
|
||||
// 切换到预先构造的复核行;这是特定示例子树,不是动态过滤算法。
|
||||
function showAttention(self: GradeCenter, event: Event): Unit {
|
||||
self.table.replace(0, self.attention)
|
||||
self.message = "林沐的数学成绩有一条复核记录"
|
||||
}
|
||||
// 写预设数学成绩68及状态,清待复核数;相关绑定在两个视图都读同一 studentLin。
|
||||
function review(self: GradeCenter, event: Event): Unit {
|
||||
self.student.math = 68
|
||||
self.student.status = "已复核"
|
||||
self.pending = 0
|
||||
self.message = "已录入复核结果:林沐 · 数学 58 → 68"
|
||||
}
|
||||
// 恢复初始学生值/计数并显示全部列表,便于重复观察同一操作。
|
||||
function reset(self: GradeCenter, event: Event): Unit {
|
||||
self.student.math = 58
|
||||
self.student.status = "待复核"
|
||||
@@ -72,6 +85,7 @@ class GradeCenter {
|
||||
self.message = "已恢复示例数据"
|
||||
}
|
||||
}
|
||||
// 下面六条数据均本地构造;allGradeRows 与 attentionGradeRows 对 studentLin 建立不同视觉行但共享其业务值。
|
||||
studentLin = Student("林沐", "2026001", 86, 58, 91, "待复核")
|
||||
studentChen = Student("陈知夏", "2026002", 94, 96, 93, "已确认")
|
||||
studentXu = Student("许星河", "2026003", 82, 88, 85, "已确认")
|
||||
@@ -81,15 +95,18 @@ studentLu = Student("陆景行", "2026006", 79, 84, 87, "已确认")
|
||||
allGradeRows = Column(0, 14, gradeRow(studentLin), gradeRow(studentChen), gradeRow(studentXu), gradeRow(studentZhou), gradeRow(studentSu), gradeRow(studentLu))
|
||||
attentionGradeRows = Column(0, 14, gradeRow(studentLin))
|
||||
gradeTable = Column(0, 0, allGradeRows)
|
||||
// table 最初含一个稳定子槽,后续 replace(0,...) 正是切换这个槽。
|
||||
gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生")
|
||||
showAllGrades = Button("全部学生")
|
||||
showAttentionGrades = Button("查看复核记录")
|
||||
reviewGrade = Button("录入复核成绩")
|
||||
resetGrades = Button("恢复示例数据")
|
||||
// 四个回调均接受 Event,绑定到同一 GradeCenter 接收者。
|
||||
showAllGrades.on("click", gradeCenter.showAll)
|
||||
showAttentionGrades.on("click", gradeCenter.showAttention)
|
||||
reviewGrade.on("click", gradeCenter.review)
|
||||
resetGrades.on("click", gradeCenter.reset)
|
||||
// 表头复用与数据行完全相同的列宽,摘要仅 pending 响应式,6/3是固定示例统计。
|
||||
gradeHeader = Row(0, 0,
|
||||
gradeCell(TextBlock("学生", 1, "#7A8390"), 150),
|
||||
gradeCell(TextBlock("学号", 1, "#7A8390"), 120),
|
||||
@@ -101,6 +118,7 @@ gradeSummary = Row(0, 40,
|
||||
Column(0, 6, TextBlock("学生人数", 1, "#7A8390"), TextBlock("6", 2)),
|
||||
Column(0, 6, TextBlock("考试科目", 1, "#7A8390"), TextBlock("3", 2)),
|
||||
Column(0, 6, TextBlock("待复核", 1, "#7A8390"), ui.textOf(gradeCenter, "pending", 2)))
|
||||
// 最终一次 render 组合摘要、控制区和表格面板;message 绑定显示最近业务操作结果。
|
||||
gradesPage = Page()
|
||||
gradesPage.title = "学生成绩管理中心"
|
||||
gradesPage.render(Column(32, 20,
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
// 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。
|
||||
// 当前侧栏提供选择/关闭,文案中的拖放场景也可经版本化事件 API 测试;不要把它理解成已实现的侧栏拖动手势。
|
||||
// 继承 Page 后观察 moveToWindow/moveTab 的真实方法调用,保留原生迁移/重排行为。
|
||||
class TrackedPage: Page {
|
||||
calls: Object
|
||||
// 先递增 calls,再显式调用基类执行跨窗迁移;Page 身份保留,旧 Mount 上下文会替换。
|
||||
function moveToWindow(self: TrackedPage, window: Window): Page {
|
||||
self.calls.increment()
|
||||
return Page.moveToWindow(self, window)
|
||||
}
|
||||
// position 是所属窗口内最终零基位置,重排不等于创建新的业务 Page。
|
||||
function moveTab(self: TrackedPage, position: Number): Page {
|
||||
self.calls.increment()
|
||||
return Page.moveTab(self, position)
|
||||
}
|
||||
}
|
||||
// 保存容器业务对象,按钮插入只改变其子槽顺序,已有 child 的槽与 Mount 尽量保留。
|
||||
class Actions {
|
||||
list: Column
|
||||
// 在索引 1 前插入新 TextBlock,返回的稳定槽可供 remove 等后续操作使用。
|
||||
function insert(self: Actions, event: Event): Unit {
|
||||
self.list.insert(1, TextBlock("插入的新对象"))
|
||||
}
|
||||
@@ -20,6 +27,7 @@ list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"
|
||||
actions = Actions(list)
|
||||
button = Button("在位置 1 插入 Block")
|
||||
connection = button.on("click", actions.insert)
|
||||
// A/C/目标页共享同一根及 list;各页激活时使用独立展示上下文,业务插入结果共享。
|
||||
shared = Column(24, 16, TextBlock("拖动 Tab 重排、移入另一个窗口,或拖出独立窗口"), TextBlock("Page 方法调用次数"), ui.textOf(calls,"value"), button, list)
|
||||
first = TrackedPage(calls)
|
||||
first.title = "A · 共享内容"
|
||||
@@ -30,6 +38,7 @@ second.render(Column(24, 16, TextBlock("这是 B,Page 身份随 Tab 移动保
|
||||
third = TrackedPage(calls)
|
||||
third.title = "C · 同一对象"
|
||||
third.render(shared)
|
||||
// 建立目标窗口并指定桌面位置,MoveTo 使用物理桌面坐标且可能受窗口管理器调整。
|
||||
other = Window()
|
||||
other.resize(720,520)
|
||||
other.setTitle("Tab Target")
|
||||
@@ -38,6 +47,7 @@ target = TrackedPage(calls)
|
||||
target.title = "目标窗口"
|
||||
target.render(shared)
|
||||
target.open(other)
|
||||
// 恢复源窗口首个页面,最后排出 resize/title/move/focus,便于手动或 API 验证。
|
||||
first.activate()
|
||||
ui.window().resize(800,600)
|
||||
ui.window().setTitle("Tab Source")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Code and tab chrome operate the same Page objects.
|
||||
// 代码与原生侧栏都操作真实 Page 对象;activate/close/open 改变展示关系,业务对象可继续被全局引用保留。
|
||||
// 三个页面共存于同一 VM,最后 first.activate 选择初始展示页。
|
||||
// 当前宿主通过侧栏管理页面;Page 的代码接口保持同一业务身份。
|
||||
// 创建持有计数器与按钮的页面;切到其他页不等于销毁 count。
|
||||
first = Page()
|
||||
first.title = "共享计数"
|
||||
count = ui.state(0)
|
||||
@@ -6,12 +9,15 @@ increment = Button("加一")
|
||||
connection = increment.on("click", count.increment)
|
||||
first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment))
|
||||
|
||||
// 另一页演示 Page 生命周期与快捷键;界面提示是示例文案,实际导航位于当前宿主侧栏。
|
||||
second = Page()
|
||||
second.title = "另一个页面"
|
||||
second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开")))
|
||||
|
||||
// 独立内容页面仍用同一个运行时的原生类型和资源注册表。
|
||||
third = Page()
|
||||
third.title = "窗口与布局"
|
||||
third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45")))
|
||||
// 重新激活第一页面,原生侧栏和源查询可观察相同 active 状态。
|
||||
first.activate()
|
||||
ui.window().setTitle("Endor Pages")
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。
|
||||
// onMount 作为业务阶段收集结果;实际布局/绘制计算仍受只读规则约束。
|
||||
// 把协议探测结果保存到普通业务字段,textOf 将其中一部分显示出来。
|
||||
class VisualResults {
|
||||
width: Number
|
||||
height: Number
|
||||
@@ -5,8 +8,10 @@ class VisualResults {
|
||||
childY: Number
|
||||
color: String
|
||||
}
|
||||
// 继承原生矩形,在自身挂载后用该 Mount 显式调用 measure/paint。
|
||||
class ProbeRect: RectBlock {
|
||||
results: VisualResults
|
||||
// 有限最大宽 120 约束自然宽 200;另用自定义 Bounds 查询绘制颜色,把返回结果写进共享 results。
|
||||
function onMount(self: ProbeRect, mount: Mount): Unit {
|
||||
measured = self.measure(mount, Constraints(0, 0, 120, 80))
|
||||
self.results.width = measured.width
|
||||
@@ -15,14 +20,17 @@ class ProbeRect: RectBlock {
|
||||
self.results.color = drawing.commands.at(0).color
|
||||
}
|
||||
}
|
||||
// 在挂载回调中探测原生排列,复用当前结构的稳定子槽。
|
||||
class ProbeColumn: Column {
|
||||
results: VisualResults
|
||||
// 给定绝对起点(20,30)及容器范围,原生 padding=24 使首子项位于(44,54)。
|
||||
function onMount(self: ProbeColumn, mount: Mount): Unit {
|
||||
positions = self.arrange(mount, Bounds(20, 30, 500, 300))
|
||||
self.results.childX = positions.children.at(0).x
|
||||
self.results.childY = positions.children.at(0).y
|
||||
}
|
||||
}
|
||||
// 先创建结果容器,再将它作为源子类字段共享;root 的最后一个参数是该字段,不是视觉子项。
|
||||
results = VisualResults(0, 0, 0, 0, "等待挂载")
|
||||
rect = ProbeRect(200, 60, 12, "#DDEEFF", results)
|
||||
root = ProbeColumn(24, 16, TextBlock("对象的原生视觉协议", 2), rect, TextBlock("约束测量宽度"), ui.textOf(results, "width"), TextBlock("绘制颜色"), ui.textOf(results, "color"), results)
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
// 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。
|
||||
// 实际可用视口再返回布局约束,原生窗口尺寸不是业务对象上凭空写入的一份几何副本。
|
||||
// 保存目标 Window,两个按钮对同一资源请求不同尺寸。
|
||||
class WindowActions {
|
||||
window: Window
|
||||
// 请求 900x600 逻辑尺寸,最终大小仍由窗口管理器决定。
|
||||
function compact(self: WindowActions, event: Event): Unit {
|
||||
self.window.resize(900, 600)
|
||||
}
|
||||
// 请求 1120x720;同次输入多次 resize 可由命令队列合并到最后值。
|
||||
function wide(self: WindowActions, event: Event): Unit {
|
||||
self.window.resize(1120, 720)
|
||||
}
|
||||
}
|
||||
// 原生矩形的测量覆盖展示父约束如何随窗口变化。
|
||||
class WindowBand: RectBlock {
|
||||
// 取当前出现位置 maxWidth 作为期望宽度,固定高 32;不读取其他窗口的尺寸。
|
||||
function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return MeasureResult(constraints.maxWidth, 32)
|
||||
}
|
||||
}
|
||||
// ui.window 返回当前主窗口托管资源;page.open 指定同一资源,后面的 title/resize 为原生命令。
|
||||
window = ui.window()
|
||||
actions = WindowActions(window)
|
||||
band = WindowBand(80, 32, 0, "#237A45")
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
// Both native windows use one Aster VM and one shared business object.
|
||||
// 两个原生窗口共用一个 Aster VM 和 count 业务对象,各自拥有独立 Page 与 Mount。
|
||||
// 任一窗口点击都修改同一计数,两个 textOf 显示随之更新。关闭窗口解除 Page 归属,持有的业务对象仍可重开。
|
||||
// 跨窗共享的是业务对象,不是某个窗口中的具体 Mount。
|
||||
// 唯一计数来源;两个按钮直接使用同一零参数原生 increment 方法。
|
||||
count = ui.state(0)
|
||||
leftButton = Button("共享计数 +1")
|
||||
leftConnection = leftButton.on("click",count.increment)
|
||||
// 先在主窗口建立页面,render 默认选择该页并挂载它的内容。
|
||||
left = Page()
|
||||
left.title = "主窗口页面"
|
||||
left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton))
|
||||
|
||||
// Window() 分配同 VM 中的独立窗口资源,不创建第二个运行时。
|
||||
other = Window()
|
||||
rightButton = Button("另一个窗口 +1")
|
||||
rightConnection = rightButton.on("click",count.increment)
|
||||
right = Page()
|
||||
right.title = "独立窗口页面"
|
||||
right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton))
|
||||
// 把页面放到 other,上下文更换将重建出现位置,业务 count 仍不变。
|
||||
right.open(other)
|
||||
// 窗口命令针对各自资源编号排队;成功输入后再交 GUI,另一窗口设置自己的尺寸。
|
||||
ui.window().setTitle("Endor Main")
|
||||
other.setTitle("Endor Shared")
|
||||
other.resize(960,640)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Paste the complete file into Console in Script mode, then Run.
|
||||
// All numbers, progress values and activity entries below are demo data.
|
||||
// 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。
|
||||
// 辅助函数返回视觉子树,最后统一 Page.render;它们是普通 Endor 函数,不是宿主特殊组件语法。
|
||||
// 将整个文件作为完整输入提交;这里没有实时指标提供方,全部数据由下面的常量生成。
|
||||
|
||||
// 把标签、大数值、说明和色条纵向组合,参数控制文字与强调颜色。
|
||||
function metric(label: Text, value: Text, note: Text, color: Text): View {
|
||||
return ui.column(0, 6,
|
||||
ui.text(label, 1, "#637082"),
|
||||
@@ -10,11 +12,14 @@ function metric(label: Text, value: Text, note: Text, color: Text): View {
|
||||
)
|
||||
}
|
||||
|
||||
// 生成项目标题和状态,再用两个相邻矩形表示已完成/剩余长度;filled/remaining 是逻辑像素。
|
||||
function project(name: Text, state: Text, detail: Text, filled: Number, remaining: Number, color: Text): View {
|
||||
// 项目名和状态横排,状态颜色与进度前景相同。
|
||||
let caption = ui.row(0, 12,
|
||||
ui.text(name, 1, "#243047"),
|
||||
ui.text(state, 1, color)
|
||||
)
|
||||
// 两块矩形间 gap=0 合成连续条,条宽由调用方传入的演示数值决定。
|
||||
let progress = ui.row(0, 0,
|
||||
ui.rect(filled, 6, 3, color),
|
||||
ui.rect(remaining, 6, 3, "#E5EAF2")
|
||||
@@ -26,6 +31,7 @@ function project(name: Text, state: Text, detail: Text, filled: Number, remainin
|
||||
)
|
||||
}
|
||||
|
||||
// 组合小圆点、时间戳、标题与说明;spacer 仅作视觉对齐,不代表真实事件时间线。
|
||||
function activity(time: Text, title: Text, detail: Text, color: Text): View {
|
||||
let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color))
|
||||
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
|
||||
@@ -36,6 +42,7 @@ function activity(time: Text, title: Text, detail: Text, color: Text): View {
|
||||
)
|
||||
}
|
||||
|
||||
// 头部三个文本使用字号/颜色层级,后续 statistics 复用 metric 返回三组指标。
|
||||
let heading = ui.column(0, 6,
|
||||
ui.text("ENDOR STUDIO / WORKSPACE", 1, "#5263D6"),
|
||||
ui.text("项目工作台", 2, "#243047"),
|
||||
@@ -48,6 +55,7 @@ let statistics = ui.row(0, 20,
|
||||
metric("页面草稿", "08", "等待下一次灵感", "#D9A34A")
|
||||
)
|
||||
|
||||
// 纵向复用 project,示例名称与比例是常量,不做进度计算或持久化。
|
||||
let projects = ui.column(0, 14,
|
||||
ui.text("项目进度", 1, "#243047"),
|
||||
project("Aster", "进行中", "语言解析 / 编译 / 执行", 240, 60, "#6574E8"),
|
||||
@@ -55,6 +63,7 @@ let projects = ui.column(0, 14,
|
||||
project("设计文档", "草稿", "Block / Page / 对象模型", 108, 192, "#D9A34A")
|
||||
)
|
||||
|
||||
// 三条 activity 组成静态活动区,再与项目区横排成 body。
|
||||
let timeline = ui.column(0, 14,
|
||||
ui.text("最近活动", 1, "#243047"),
|
||||
activity("10:24", "更新字体", "中英文混排预览", "#6574E8"),
|
||||
@@ -64,6 +73,7 @@ let timeline = ui.column(0, 14,
|
||||
|
||||
let body = ui.row(0, 20, projects, timeline)
|
||||
|
||||
// 底部提示以细色块和文字组合,明确说明页面还未接业务交互。
|
||||
let note = ui.row(0, 12,
|
||||
ui.rect(4, 46, 2, "#6574E8"),
|
||||
ui.column(0, 6,
|
||||
@@ -72,6 +82,7 @@ let note = ui.row(0, 12,
|
||||
)
|
||||
)
|
||||
|
||||
// 最终根统一设置 24px padding 与20px间距;创建辅助子树本身不会提前发布页面。
|
||||
let page = ui.page()
|
||||
page.render(ui.column(24, 20,
|
||||
heading,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// A normal Endor UI tree rendered on a transformed plane in the 3D world.
|
||||
// 世界空间 UI:普通 Endor 树先在独立 UI 分辨率中布局/绘制,再作为 Panel3D 平面的纹理参与三维深度。
|
||||
// 面板内还有 CanvasViewport,输入需经外层射线、面板 UV 和内层二维相机逐层转换。
|
||||
// 面板内容仍是完整 Endor UI,可共享对象、绑定、源视觉协议与嵌套视口。
|
||||
// 分别持有外层三维视口、面板变换和面板内二维画布,避免一次动作同时驱动多层相机。
|
||||
class WorldActions {
|
||||
view: Viewport3D
|
||||
panel: Panel3D
|
||||
canvas: CanvasViewport
|
||||
// 重置三维相机位置与观察目标,不改变面板或内层画布业务状态。
|
||||
function reset(self: WorldActions): Unit {
|
||||
self.view.camera.x = 0
|
||||
self.view.camera.y = 0.5
|
||||
@@ -11,19 +15,24 @@ class WorldActions {
|
||||
self.view.camera.targetY = 0
|
||||
self.view.camera.targetZ = 0
|
||||
}
|
||||
// 只旋转 Panel3D 平面,面板内 UI 树仍用自己的逻辑宽高。
|
||||
function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) }
|
||||
// 找到画布当前 Mount,按该出现位置的有限尺寸 fitView,而非用外层三维视口大小。
|
||||
function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() }
|
||||
}
|
||||
count = ui.state(0)
|
||||
increment = Button("点击:世界里的真实按钮")
|
||||
increment.onClick(count.increment)
|
||||
// 二维场景卡片使用带符号世界坐标;canvas 再提供有限可见区域和独立 camera。
|
||||
card = Column(10, 6, TextBlock("CanvasViewport", 1), RectBlock(180, 26, 6, "#9CB8A8"))
|
||||
card.boxWidth = 240
|
||||
card.x = 10
|
||||
card.y = 4
|
||||
canvas = CanvasViewport(280, 80, Scene2D(card))
|
||||
// 普通 Column、文字、按钮和二维画布组成 UI 树,按钮仍修改同一个 count。
|
||||
ui_root = Column(8, 8, TextBlock("世界空间 UI", 2), ui.textOf(count, "value", 2), increment, canvas)
|
||||
ui_root.crossAlign = "stretch"
|
||||
// 4x3 是三维世界尺寸,320x240 是 UI 布局/纹理分辨率;两者独立控制显示比例与清晰度。
|
||||
panel = Panel3D(4, 3, ui_root)
|
||||
panel.uiWidth = 320
|
||||
panel.uiHeight = 240
|
||||
@@ -34,8 +43,10 @@ cube.x = 2
|
||||
cube.rotationY = 25
|
||||
floor = Box3D(10, 0.08, 5, "#D8DEE6")
|
||||
floor.y = -1.8
|
||||
// 面板与普通网格/地面共处一个深度场景,实体可遮挡面板,也阻挡被遮住的 UI 命中。
|
||||
scene = Scene3D(panel, cube, floor, DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9))
|
||||
scene.ambient = 0.35
|
||||
// 外层 Viewport3D 作为二维布局里的 flex 子项;相机控制外部观察,而非 UI 内部滚动。
|
||||
view = Viewport3D(960, 520, scene)
|
||||
view.flex = 1
|
||||
view.camera.y = 0.5
|
||||
@@ -47,6 +58,7 @@ turn = Button("转动 UI 面板")
|
||||
turn.onClick(actions.turn)
|
||||
fit = Button("适配面板内的画布")
|
||||
fit.onClick(actions.fit)
|
||||
// 控制按钮保持在二维页面空间,外层三维内容作为一个区域随布局伸展。
|
||||
body = Column(24, 16, TextBlock("UI → 3D 世界 → UI → Canvas", 2), view, Row(0, 12, reset, turn, fit), TextBlock("空白处左键拖动旋转 3D · 中键/空格平移 · 滚轮缩放\n面板里的按钮、文本、布局和画布都由同一份 Endor 对象驱动。"))
|
||||
body.flex = 1
|
||||
body.crossAlign = "stretch"
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
// 多窗口共享长段落:同一文字对象在每个 Mount 按当前宽度独立换行,后继位置跟随实际高度。
|
||||
// 源子类显式委托原生 measure/paint,保留系统字体塑形、组合字符和 emoji 的行为。
|
||||
// 没有独立宽度副本;约束与换行结果属于出现位置,文本内容属于共享对象。
|
||||
class Paragraph: TextBlock {
|
||||
// 将当前 Mount 与 Constraints 交给原生文字测量,宽度变化触发重测高度。
|
||||
function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
return TextBlock.measure(self, mount, constraints)
|
||||
}
|
||||
// 使用本次 geometry 画文字,和原生测量采用相同换行区域。
|
||||
function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList {
|
||||
return TextBlock.paint(self, mount, geometry)
|
||||
}
|
||||
}
|
||||
// 保存主窗口、共享文字和原全文,四个按钮分别改窗口尺寸或改文字内容。
|
||||
class Actions {
|
||||
window: Window
|
||||
paragraph: TextBlock
|
||||
full: String
|
||||
// 请求主窗口变窄;真正的 viewport 由原生尺寸回传后影响换行。
|
||||
function narrow(self: Actions, event: Event): Unit { self.window.resize(800, 650) }
|
||||
// 请求主窗口变宽,文字高度随之减小,不改另一窗口尺寸。
|
||||
function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) }
|
||||
// 用短文本替换同一对象内容,两个窗口的文字都重新测量。
|
||||
function short(self: Actions, event: Event): Unit { self.paragraph.setText("短文本:同一个对象、同一个 Mount。") }
|
||||
// 恢复保存的 full 字符串,不创建新的 Paragraph。
|
||||
function restore(self: Actions, event: Event): Unit { self.paragraph.setText(self.full) }
|
||||
}
|
||||
full = "GUI 和代码操控同一套 Aster 内存对象。这个段落同时出现在两个窗口:对象内容相同,每个 Mount 根据自己的可用宽度换行。窗口变窄,段落高度增加,后面的对象向下移动;窗口变宽,段落高度减小,后面的对象向上移动。对象身份和已有 Mount 身份保持稳定。English words wrap at word boundaries; a_long_unbroken_identifier_also_wraps_when_it_cannot_fit_on_one_line. 中文、组合字符 é 与 emoji 👩💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
|
||||
@@ -30,6 +40,7 @@ restoreConnection = restore.on("click", actions.restore)
|
||||
page = Page()
|
||||
page.title = "约束下的文字换行"
|
||||
page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail))
|
||||
// 新窗口有独立可用宽度,但 second.render 仍用同一 paragraph,所以两个 Mount 可有不同高度。
|
||||
other = Window()
|
||||
other.resize(720, 500)
|
||||
other.setTitle("同一个段落 · 另一组宽度")
|
||||
@@ -38,6 +49,7 @@ second = Page()
|
||||
second.title = "共享 Paragraph"
|
||||
second.render(Column(24, 16, paragraph))
|
||||
second.open(other)
|
||||
// 恢复主页面活动状态并设置原生窗口尺寸/标题/桌面位置,focus 命令在成功输入后交宿主执行。
|
||||
page.activate()
|
||||
ui.window().resize(800, 650)
|
||||
ui.window().setTitle("文字换行与相邻布局")
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Native macOS build and app bundle. No Docker, mirror configuration, or tests.
|
||||
# macOS 应用打包入口:构建 release 可执行文件并组装最小 Tessera.app;脚本本身不运行测试或部署。
|
||||
# 执行会产生构建产物,应在已确认的 macOS 构建环境中调用。
|
||||
# 任一命令失败或使用未定义变量即退出,避免在失败编译后继续打包旧文件。
|
||||
set -eu
|
||||
# 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。
|
||||
if [ "$(uname -s)" != Darwin ]; then
|
||||
echo "Build this app bundle on macOS." >&2
|
||||
exit 1
|
||||
fi
|
||||
# 以脚本所在目录的父目录作为仓库根;清空这一次 cd 的 CDPATH,防止用户 shell 配置改变目录解析。
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
# 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。
|
||||
cargo build --release --locked "$@"
|
||||
# 从 Cargo 包元数据读取首个 version 值,空值由 test 拒绝,不生成缺版本的 bundle。
|
||||
version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml)
|
||||
test -n "$version"
|
||||
# macOS bundle 约定:Contents/MacOS 保存入口二进制,Contents/Info.plist 保存系统识别信息。
|
||||
bundle="$(pwd)/target/release/Tessera.app"
|
||||
mkdir -p "$bundle/Contents/MacOS"
|
||||
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
|
||||
cp target/release/tessera "$bundle/Contents/MacOS/tessera.new"
|
||||
mv -f "$bundle/Contents/MacOS/tessera.new" "$bundle/Contents/MacOS/tessera"
|
||||
# 生成最小 macOS bundle 元数据;应用名、入口与高分辨率标志供系统启动/显示使用。
|
||||
cat > "$bundle/Contents/Info.plist.new" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -27,5 +36,7 @@ cat > "$bundle/Contents/Info.plist.new" <<PLIST
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
</dict></plist>
|
||||
PLIST
|
||||
# 元数据完整写入后再替换正式文件;这只是单文件替换,不表示整个 bundle 的构建具有事务回滚。
|
||||
mv -f "$bundle/Contents/Info.plist.new" "$bundle/Contents/Info.plist"
|
||||
# 标准输出返回最终 app 路径,便于构建调用方定位产物。
|
||||
printf '%s\n' "$bundle"
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// Documentation-only tool. Install pinned packages in a separate temporary directory;
|
||||
// UML_RENDER_TOOLS points there. No app dependencies or remote rendering service.
|
||||
// 文档 UML 批量渲染入口:读取仓库内 .puml,在 rendered 中写入 SVG/PNG。
|
||||
// 固定版本的工具包安装到独立临时目录,由 UML_RENDER_TOOLS 指定;不会修改应用依赖。
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const tools = process.env.UML_RENDER_TOOLS;
|
||||
// 工具依赖放在独立目录,避免给应用运行依赖增加文档渲染包。
|
||||
if (!tools) throw new Error('Set UML_RENDER_TOOLS to the documentation tool directory; see docs/architecture/uml/README.md');
|
||||
// 从工具目录的 package.json 创建专用 require,所有渲染依赖均从该目录解析,不依赖运行时 cwd。
|
||||
const require = createRequire(path.join(path.resolve(tools), 'package.json'));
|
||||
const { createCanvas } = require('@napi-rs/canvas');
|
||||
const { DOMImplementation, XMLSerializer, DOMParser } = require('@xmldom/xmldom');
|
||||
const sharp = require('sharp');
|
||||
|
||||
// PlantUML's official JS engine uses DOM construction and Canvas text metrics.
|
||||
// Supply a real XML DOM and native Canvas metrics, not approximate text widths.
|
||||
// 官方 PlantUML JS 引擎依赖 DOM、Graphviz 和 Canvas 文本度量。
|
||||
// 在导入引擎前补齐全局环境,让 Node 进程可执行原本面向浏览器的布局过程。
|
||||
globalThis.PLANTUML_VIZ = require('@viz-js/viz');
|
||||
globalThis.window = globalThis;
|
||||
globalThis.XMLSerializer = XMLSerializer;
|
||||
@@ -22,14 +24,17 @@ globalThis.DOMParser = DOMParser;
|
||||
const document = new DOMImplementation().createDocument(null, 'root', null);
|
||||
const element = document.createElement.bind(document);
|
||||
const elementNS = document.createElementNS.bind(document);
|
||||
// 创建只用于文本度量的 1x1 Canvas。此对象不承载最终图片像素,字体实际尺寸由原生引擎返回。
|
||||
function measurementCanvas() {
|
||||
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
|
||||
const canvas = createCanvas(1, 1);
|
||||
const context = canvas.getContext('2d');
|
||||
const measure = context.measureText.bind(context);
|
||||
// 包装测量结果,修正 PlantUML 用于行距的 ascent/descent;保留其他真实 metrics 字段。
|
||||
context.measureText = text => {
|
||||
const metrics = measure(text);
|
||||
// PlantUML uses this height for line spacing. Font-wide ascent/descent
|
||||
// keeps adjacent class fields from colliding when glyphs lack descenders.
|
||||
// 优先使用整套字体的上下界,而非当前字符串字形界;
|
||||
// 否则不带下伸部的文字可能使相邻类字段的行距过小而重叠。
|
||||
return {
|
||||
...metrics,
|
||||
width: metrics.width,
|
||||
@@ -39,10 +44,13 @@ function measurementCanvas() {
|
||||
};
|
||||
return canvas;
|
||||
}
|
||||
// 普通标签交给 XML DOM,只有 canvas 替换为可实际 measureText 的原生对象。
|
||||
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
|
||||
document.createElementNS = (namespace, tag) => {
|
||||
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
|
||||
const node = elementNS(namespace, tag);
|
||||
node.style = {};
|
||||
// 无浏览器布局树时只模拟当前文字节点的包围盒:字体来自 SVG 属性,y 从基线向上偏移。
|
||||
node.getBBox = () => {
|
||||
const context = measurementCanvas().getContext('2d');
|
||||
context.font = `${node.getAttribute('font-size') || 14}px ${node.getAttribute('font-family') || 'Arial'}`;
|
||||
@@ -60,26 +68,31 @@ document.body = element('body');
|
||||
document.documentElement.appendChild(document.body);
|
||||
globalThis.document = document;
|
||||
const { renderToString } = await import(pathToFileURL(require.resolve('@plantuml/core')));
|
||||
// 仓库位置由脚本 URL 确定,与调用者工作目录无关;输出目录不存在时创建。
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const source = path.join(root, 'docs/architecture/uml');
|
||||
const output = path.join(source, 'rendered');
|
||||
await mkdir(output, { recursive: true });
|
||||
// 按文件名排序使处理顺序/日志稳定,只处理本目录的 .puml,不递归扫描产物目录。
|
||||
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
|
||||
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
|
||||
const text = await readFile(path.join(source, filename), 'utf8');
|
||||
// 将引擎 resolve/reject 回调适配为 Promise,逐图等待;渲染失败直接终止,不覆盖该图产物。
|
||||
const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject));
|
||||
const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml');
|
||||
const labels = Array.from(xml.getElementsByTagName('text'), node => node.textContent).join('\n');
|
||||
if (xml.documentElement.tagName !== 'svg' || /syntax error|error line|assumed diagram type|an error has occurr/i.test(labels)) {
|
||||
throw new Error(`${filename}: renderer returned an error diagram\n${labels}`);
|
||||
}
|
||||
// Explicit opaque paper keeps arrows/labels legible in dark Markdown viewers.
|
||||
// 在最前方加入不透明白纸背景,保证深色 Markdown 查看器中的箭头与文字仍可辨认。
|
||||
const paper = xml.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
for (const [key, value] of Object.entries({ x: '0', y: '0', width: '100%', height: '100%', fill: '#FFFFFF' })) paper.setAttribute(key, value);
|
||||
xml.documentElement.insertBefore(paper, xml.documentElement.firstChild);
|
||||
const svg = new XMLSerializer().serializeToString(xml);
|
||||
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
|
||||
const stem = filename.slice(0, -5);
|
||||
await writeFile(path.join(output, `${stem}.svg`), svg);
|
||||
// High-resolution PNG is the universally visible Markdown preview; SVG keeps zoomable text.
|
||||
// 以 192 DPI 光栅化为 PNG 供通用预览;SVG 同时保留矢量文字和缩放能力。
|
||||
const png = sharp(Buffer.from(svg), { density: 192 });
|
||||
await png.png().toFile(path.join(output, `${stem}.png`));
|
||||
const metadata = await sharp(path.join(output, `${stem}.png`)).metadata();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Tessera 源码导航
|
||||
|
||||
本体代码已补充中文模块、类型、函数与关键流程注释;建议按[注释阅读索引和逐文件说明](../docs/code-comments.md)从启动、运行时发布和视觉协议依次阅读。
|
||||
|
||||
整体设计和当前进展从 [架构导览](../docs/architecture/README.md) 开始;本轮每个文件的迁移对应见 [逐文件清单](../docs/architecture/file-map.md)。
|
||||
|
||||
`main.rs` 只负责组合启动,并保留 crate 私有模块路径别名。五组目录仍在同一个 binary crate 中,不是严格单向分层。
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
//! General retained graphics resources for source paint: images, fonts, prepared
|
||||
//! text, geometric exclusions and numerical functions. No application behaviour.
|
||||
use crate::{
|
||||
document::{RenderCommand, parse_color},
|
||||
geometry::{Point, Rect},
|
||||
render::{
|
||||
image::Image,
|
||||
text_flow::{Cursor, Prepared},
|
||||
},
|
||||
text::{TextFamily, TextStyle},
|
||||
};
|
||||
use endor_core::{
|
||||
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
|
||||
ObjectStore, Value, Vm, VmError,
|
||||
};
|
||||
use std::{any::Any, sync::Arc};
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
fn object(v: &Value) -> Result<ObjectRef, VmError> {
|
||||
if let Value::Object(o) = v {
|
||||
Ok(*o)
|
||||
} else {
|
||||
Err(invalid("expected Object"))
|
||||
}
|
||||
}
|
||||
fn number(v: &Value) -> Result<f32, VmError> {
|
||||
let Value::Number(v) = v else {
|
||||
return Err(invalid("expected Number"));
|
||||
};
|
||||
let n = v.to_f64()?;
|
||||
if n.abs() > 1_000_000. {
|
||||
return Err(invalid("graphics number exceeds bounds"));
|
||||
}
|
||||
Ok(n as f32)
|
||||
}
|
||||
fn text(v: &Value) -> Result<&str, VmError> {
|
||||
if let Value::Text(s) = v {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(invalid("expected String"))
|
||||
}
|
||||
}
|
||||
fn n(v: f32) -> Value {
|
||||
Value::Number((v as f64).into())
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ImageResource(pub Arc<Image>);
|
||||
#[derive(Debug)]
|
||||
struct FontResource(TextFamily);
|
||||
#[derive(Debug)]
|
||||
struct TextResource {
|
||||
prepared: Arc<Prepared>,
|
||||
line: f32,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Exclusion {
|
||||
circle: Option<(Point, f32)>,
|
||||
polygon: Vec<Point>,
|
||||
padding: f32,
|
||||
}
|
||||
macro_rules! resource {
|
||||
($t:ty,$name:literal) => {
|
||||
impl ObjectPayload for $t {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
$name
|
||||
}
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
resource!(ImageResource, "image-resource");
|
||||
resource!(FontResource, "font-resource");
|
||||
resource!(TextResource, "prepared-text");
|
||||
resource!(Exclusion, "layout-exclusion");
|
||||
fn attach(cx: &mut NativeContext<'_>, o: ObjectRef, desktop: ObjectRef) -> Result<(), VmError> {
|
||||
crate::ui_bridge::attach_desktop_type(cx, o, "Object", desktop)
|
||||
}
|
||||
fn file(path: &str, limit: u64) -> Result<Vec<u8>, VmError> {
|
||||
use std::io::Read;
|
||||
if path.len() > 8192 {
|
||||
return Err(invalid("resource path too long"));
|
||||
}
|
||||
let f = std::fs::File::open(path).map_err(|_| invalid("resource file cannot be opened"))?;
|
||||
let meta = f
|
||||
.metadata()
|
||||
.map_err(|_| invalid("resource metadata unavailable"))?;
|
||||
if !meta.is_file() || meta.len() > limit {
|
||||
return Err(invalid("resource must be a bounded regular file"));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
f.take(limit + 1)
|
||||
.read_to_end(&mut data)
|
||||
.map_err(|_| invalid("resource read failed"))?;
|
||||
if data.len() as u64 > limit {
|
||||
return Err(invalid("resource exceeds byte limit"));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result<f32, VmError> {
|
||||
number(&s.get_attr(o, key)?)
|
||||
}
|
||||
pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result<RenderCommand, VmError> {
|
||||
let resource = object(&s.get_attr(c, "image")?)?;
|
||||
let image = s
|
||||
.payload::<ImageResource>(resource, "ImageResource")?
|
||||
.0
|
||||
.clone();
|
||||
let tint = match s.get_attr(c, "tint") {
|
||||
Ok(Value::Text(v)) => Some(parse_color(&v).map_err(|_| invalid("invalid image tint"))?),
|
||||
Ok(Value::Nil) | Err(VmError::AttributeNotFound(_)) => None,
|
||||
_ => return Err(invalid("invalid image tint")),
|
||||
};
|
||||
let c = RenderCommand::Image {
|
||||
x: field(s, c, "x")?,
|
||||
y: field(s, c, "y")?,
|
||||
width: field(s, c, "width")?,
|
||||
height: field(s, c, "height")?,
|
||||
offset_x: field(s, c, "offsetX")?,
|
||||
offset_y: field(s, c, "offsetY")?,
|
||||
angle: field(s, c, "angle")?,
|
||||
opacity: field(s, c, "opacity")?,
|
||||
tint,
|
||||
image,
|
||||
};
|
||||
c.validate()
|
||||
.map_err(|_| invalid("invalid image geometry"))?;
|
||||
Ok(c)
|
||||
}
|
||||
fn command(
|
||||
cx: &mut NativeContext<'_>,
|
||||
desktop: ObjectRef,
|
||||
image: ObjectRef,
|
||||
origin: Point,
|
||||
rect: Rect,
|
||||
angle: f32,
|
||||
opacity: f32,
|
||||
) -> Result<ObjectRef, VmError> {
|
||||
let c = crate::native_visual::record(
|
||||
cx,
|
||||
desktop,
|
||||
"PaintCommand",
|
||||
vec![
|
||||
("kind", Value::Text("image".into())),
|
||||
("image", Value::Object(image)),
|
||||
("x", n(origin.x)),
|
||||
("y", n(origin.y)),
|
||||
("width", n(rect.width)),
|
||||
("height", n(rect.height)),
|
||||
("offsetX", n(rect.x)),
|
||||
("offsetY", n(rect.y)),
|
||||
("angle", n(angle)),
|
||||
("opacity", n(opacity)),
|
||||
],
|
||||
)?;
|
||||
Ok(c)
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Op {
|
||||
LoadImage,
|
||||
LoadFont,
|
||||
ReadText,
|
||||
Slice,
|
||||
Stringify,
|
||||
Floor,
|
||||
Now,
|
||||
Prepare,
|
||||
Sprite,
|
||||
ImageExclusion,
|
||||
Circle,
|
||||
Rectangle,
|
||||
Flow,
|
||||
Float,
|
||||
Sin,
|
||||
Cos,
|
||||
Sqrt,
|
||||
Atan2,
|
||||
Abs,
|
||||
Min,
|
||||
Max,
|
||||
Clamp,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Function {
|
||||
op: Op,
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Function {
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
use Op::*;
|
||||
Some(endor_core::CallArity::exact(match self.op {
|
||||
Now => 0,
|
||||
LoadImage | ReadText | Float | Sin | Cos | Sqrt | Abs | Stringify | Floor => 1,
|
||||
LoadFont | Atan2 | Min | Max | ImageExclusion => 2,
|
||||
Circle | Clamp | Slice => 3,
|
||||
Rectangle => 4,
|
||||
Prepare => 6,
|
||||
Flow => 7,
|
||||
Sprite => 9,
|
||||
}))
|
||||
}
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
use Op::*;
|
||||
let desktop = self.desktop;
|
||||
let result = match self.op {
|
||||
Now => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid(
|
||||
"sample the clock in an event/task and store it in the model",
|
||||
));
|
||||
}
|
||||
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
Value::Number(
|
||||
START
|
||||
.get_or_init(std::time::Instant::now)
|
||||
.elapsed()
|
||||
.as_secs_f64()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
LoadImage => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid("load resources outside paint/layout"));
|
||||
}
|
||||
let image = Image::png(&file(text(&args[0])?, 4_194_304)?)
|
||||
.map_err(|_| invalid("invalid or oversized PNG"))?;
|
||||
let (w, h) = (image.width, image.height);
|
||||
let resource = cx.allocate(ImageResource(image))?;
|
||||
attach(cx, resource, desktop)?;
|
||||
cx.set_attr(resource, "width", n(w as f32))?;
|
||||
cx.set_attr(resource, "height", n(h as f32))?;
|
||||
Value::Object(resource)
|
||||
}
|
||||
LoadFont => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid("load resources outside paint/layout"));
|
||||
}
|
||||
let family = text(&args[1])?;
|
||||
if family.len() > 128 {
|
||||
return Err(invalid("font family name too long"));
|
||||
}
|
||||
let font = crate::text::register_font(file(text(&args[0])?, 4_194_304)?, family)
|
||||
.map_err(|_| invalid("invalid font family or registry budget"))?;
|
||||
let resource = cx.allocate(FontResource(font))?;
|
||||
attach(cx, resource, desktop)?;
|
||||
Value::Object(resource)
|
||||
}
|
||||
ReadText => {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid("load resources outside paint/layout"));
|
||||
}
|
||||
Value::Text(
|
||||
String::from_utf8(file(text(&args[0])?, 16_384)?)
|
||||
.map_err(|_| invalid("resource is not UTF-8"))?,
|
||||
)
|
||||
}
|
||||
Slice => {
|
||||
let value = text(&args[0])?;
|
||||
let start = number(&args[1])?;
|
||||
let end = number(&args[2])?;
|
||||
if start < 0. || end < start || start.fract() != 0. || end.fract() != 0. {
|
||||
return Err(invalid("invalid text slice"));
|
||||
}
|
||||
let lo = value
|
||||
.char_indices()
|
||||
.nth(start as usize)
|
||||
.map_or(value.len(), |(i, _)| i);
|
||||
let hi = value
|
||||
.char_indices()
|
||||
.nth(end as usize)
|
||||
.map_or(value.len(), |(i, _)| i);
|
||||
Value::Text(value[lo..hi].into())
|
||||
}
|
||||
Stringify => Value::Text(match &args[0] {
|
||||
Value::Text(s) => s.clone(),
|
||||
Value::Number(v) => v.to_string(),
|
||||
Value::Bool(v) => v.to_string(),
|
||||
Value::Nil => "nil".into(),
|
||||
_ => return Err(invalid("string expects scalar value")),
|
||||
}),
|
||||
Prepare => {
|
||||
let value = text(&args[0])?;
|
||||
if value.len() > 16_384 {
|
||||
return Err(invalid("prepared text exceeds 16 KiB"));
|
||||
}
|
||||
let family = cx
|
||||
.store()
|
||||
.payload::<FontResource>(object(&args[1])?, "FontResource")?
|
||||
.0;
|
||||
let size = number(&args[2])?;
|
||||
let line = number(&args[3])?;
|
||||
let scale = number(&args[5])?;
|
||||
if !(8. ..=96.).contains(&size)
|
||||
|| !(size..=192.).contains(&line)
|
||||
|| !(0.5..=4.).contains(&scale)
|
||||
{
|
||||
return Err(invalid("invalid text metrics"));
|
||||
}
|
||||
let color =
|
||||
parse_color(text(&args[4])?).map_err(|_| invalid("invalid text color"))?;
|
||||
let prepared = Prepared::new(
|
||||
value,
|
||||
TextStyle {
|
||||
font_size: size,
|
||||
line_height: line,
|
||||
family,
|
||||
},
|
||||
scale,
|
||||
color,
|
||||
);
|
||||
let prepared = prepared.map_err(invalid)?;
|
||||
let resource = cx.allocate(TextResource {
|
||||
prepared: Arc::new(prepared),
|
||||
line,
|
||||
})?;
|
||||
attach(cx, resource, desktop)?;
|
||||
Value::Object(resource)
|
||||
}
|
||||
Sprite => {
|
||||
let image = object(&args[0])?;
|
||||
cx.store()
|
||||
.payload::<ImageResource>(image, "ImageResource")?;
|
||||
let x = number(&args[1])?;
|
||||
let y = number(&args[2])?;
|
||||
let w = number(&args[3])?;
|
||||
let h = number(&args[4])?;
|
||||
let a = number(&args[5])?;
|
||||
let ax = number(&args[6])?;
|
||||
let ay = number(&args[7])?;
|
||||
let opacity = number(&args[8])?;
|
||||
let c = command(
|
||||
cx,
|
||||
desktop,
|
||||
image,
|
||||
Point::new(x, y),
|
||||
Rect::new(-w * ax, -h * ay, w, h),
|
||||
a,
|
||||
opacity,
|
||||
)?;
|
||||
decode_image(cx.store(), c)?;
|
||||
Value::Object(c)
|
||||
}
|
||||
ImageExclusion => {
|
||||
let RenderCommand::Image {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
offset_x,
|
||||
offset_y,
|
||||
angle,
|
||||
image,
|
||||
..
|
||||
} = decode_image(cx.store(), object(&args[0])?)?
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let padding = number(&args[1])?;
|
||||
if !(0. ..=1000.).contains(&padding) {
|
||||
return Err(invalid("invalid exclusion padding"));
|
||||
}
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
let polygon = image
|
||||
.outline
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let px = offset_x + p.x / image.width as f32 * width;
|
||||
let py = offset_y + p.y / image.height as f32 * height;
|
||||
Point::new(x + cos * px - sin * py, y + sin * px + cos * py)
|
||||
})
|
||||
.collect();
|
||||
let e = cx.allocate(Exclusion {
|
||||
circle: None,
|
||||
polygon,
|
||||
padding,
|
||||
})?;
|
||||
attach(cx, e, desktop)?;
|
||||
Value::Object(e)
|
||||
}
|
||||
Circle | Rectangle => {
|
||||
let x = number(&args[0])?;
|
||||
let y = number(&args[1])?;
|
||||
let size = number(&args[2])?;
|
||||
if size < 0. {
|
||||
return Err(invalid("negative exclusion extent"));
|
||||
}
|
||||
let e = if matches!(self.op, Circle) {
|
||||
Exclusion {
|
||||
circle: Some((Point::new(x, y), size)),
|
||||
polygon: vec![],
|
||||
padding: 0.,
|
||||
}
|
||||
} else {
|
||||
let h = number(&args[3])?;
|
||||
if h < 0. {
|
||||
return Err(invalid("negative exclusion height"));
|
||||
}
|
||||
Exclusion {
|
||||
circle: None,
|
||||
polygon: vec![
|
||||
Point::new(x, y),
|
||||
Point::new(x + size, y),
|
||||
Point::new(x + size, y + h),
|
||||
Point::new(x, y + h),
|
||||
],
|
||||
padding: 0.,
|
||||
}
|
||||
};
|
||||
let e = cx.allocate(e)?;
|
||||
attach(cx, e, desktop)?;
|
||||
Value::Object(e)
|
||||
}
|
||||
Flow => flow(cx, desktop, args)?,
|
||||
_ => {
|
||||
let real = |v: &Value| -> Result<f64, VmError> {
|
||||
if let Value::Number(n) = v {
|
||||
n.to_f64()
|
||||
} else {
|
||||
Err(invalid("math expects Number"))
|
||||
}
|
||||
};
|
||||
let x = real(&args[0])?;
|
||||
if matches!(self.op, Floor) {
|
||||
if x.abs() > 1_000_000_000. {
|
||||
return Err(invalid("floor result exceeds integer conversion budget"));
|
||||
}
|
||||
return Ok(NativeResult::Return(Value::Number(
|
||||
(x.floor() as i64).into(),
|
||||
)));
|
||||
}
|
||||
let v = match self.op {
|
||||
Float => x,
|
||||
Sin => x.sin(),
|
||||
Cos => x.cos(),
|
||||
Sqrt => x.sqrt(),
|
||||
Atan2 => x.atan2(real(&args[1])?),
|
||||
Abs => x.abs(),
|
||||
Min => x.min(real(&args[1])?),
|
||||
Max => x.max(real(&args[1])?),
|
||||
Clamp => {
|
||||
let lo = real(&args[1])?;
|
||||
let hi = real(&args[2])?;
|
||||
if lo > hi {
|
||||
return Err(invalid("clamp minimum exceeds maximum"));
|
||||
}
|
||||
x.clamp(lo, hi)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !v.is_finite() {
|
||||
return Err(invalid("non-finite math result"));
|
||||
}
|
||||
Value::Number(v.into())
|
||||
}
|
||||
};
|
||||
Ok(NativeResult::Return(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn ranges(left: f32, right: f32, y: f32, line: f32, exclusions: &[&Exclusion]) -> Vec<(f32, f32)> {
|
||||
let mut blocked = Vec::new();
|
||||
for e in exclusions {
|
||||
if let Some((p, r)) = e.circle {
|
||||
let gap = (y - p.y).max(p.y - y - line).max(0.);
|
||||
if gap < r {
|
||||
let dx = (r * r - gap * gap).sqrt();
|
||||
blocked.push((p.x - dx, p.x + dx));
|
||||
}
|
||||
}
|
||||
let (top, bottom) = (y - e.padding, y + line + e.padding);
|
||||
let mut xs = Vec::new();
|
||||
for i in 0..e.polygon.len() {
|
||||
let a = e.polygon[i];
|
||||
let b = e.polygon[(i + 1) % e.polygon.len()];
|
||||
if a.y >= top && a.y <= bottom {
|
||||
xs.push(a.x);
|
||||
}
|
||||
for edge in [top, bottom] {
|
||||
if (a.y < edge && b.y > edge) || (b.y < edge && a.y > edge) {
|
||||
xs.push(a.x + (b.x - a.x) * (edge - a.y) / (b.y - a.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !xs.is_empty() {
|
||||
blocked.push((
|
||||
xs.iter().copied().fold(f32::INFINITY, f32::min) - e.padding,
|
||||
xs.iter().copied().fold(f32::NEG_INFINITY, f32::max) + e.padding,
|
||||
));
|
||||
}
|
||||
}
|
||||
blocked.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut x = left;
|
||||
let mut ranges = Vec::new();
|
||||
for (lo, hi) in blocked {
|
||||
if hi <= left || lo >= right {
|
||||
continue;
|
||||
}
|
||||
if lo > x {
|
||||
ranges.push((x, lo.min(right)));
|
||||
}
|
||||
x = x.max(hi).min(right);
|
||||
}
|
||||
if x < right {
|
||||
ranges.push((x, right));
|
||||
}
|
||||
ranges.into_iter().filter(|(l, r)| r - l >= 8.).collect()
|
||||
}
|
||||
fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Result<Value, VmError> {
|
||||
let resource = object(&args[0])?;
|
||||
let text = cx
|
||||
.store()
|
||||
.payload::<TextResource>(resource, "PreparedText")?;
|
||||
let prepared = text.prepared.clone();
|
||||
let line = text.line;
|
||||
let x = number(&args[1])?;
|
||||
let y = number(&args[2])?;
|
||||
let width = number(&args[3])?;
|
||||
let height = number(&args[4])?;
|
||||
let letters = number(&args[6])? != 0.;
|
||||
if width < 0. || height < 0. || height / line > 1024. {
|
||||
return Err(invalid("text flow bounds exceed budget"));
|
||||
}
|
||||
let items = crate::object_list::items(cx.store(), object(&args[5])?)?;
|
||||
if items.len() > 512 {
|
||||
return Err(invalid("text flow exceeds 512 exclusions"));
|
||||
}
|
||||
let exclusions = items
|
||||
.iter()
|
||||
.map(|&o| cx.store().payload::<Exclusion>(o, "Exclusion"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut placements = Vec::new();
|
||||
let mut cursor = Cursor::default();
|
||||
let mut top = y;
|
||||
while top + line <= y + height && cursor.word < prepared.words.len() {
|
||||
let intervals = ranges(x, x + width, top, line, &exclusions);
|
||||
prepared.row(&mut cursor, &intervals, top, &mut placements);
|
||||
top += line;
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
for p in placements {
|
||||
let word = &prepared.words[p.word];
|
||||
let inks = if let Some(i) = p.letter {
|
||||
std::slice::from_ref(&word.letters[i])
|
||||
} else if letters {
|
||||
&word.letters[..]
|
||||
} else {
|
||||
std::slice::from_ref(&word.ink)
|
||||
};
|
||||
let mut px = p.x;
|
||||
for ink in inks {
|
||||
let image = cx.allocate(ImageResource(ink.image.clone()))?;
|
||||
attach(cx, image, desktop)?;
|
||||
commands.push(command(
|
||||
cx,
|
||||
desktop,
|
||||
image,
|
||||
Point::new(px, p.y),
|
||||
ink.rect,
|
||||
0.,
|
||||
1.,
|
||||
)?);
|
||||
px += ink.width;
|
||||
if commands.len() > 10_000 {
|
||||
return Err(invalid("text flow command budget exceeded"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let commands = crate::object_list::collection(cx, commands, desktop)?;
|
||||
Ok(Value::Object(crate::native_visual::record(
|
||||
cx,
|
||||
desktop,
|
||||
"DisplayList",
|
||||
vec![("commands", Value::Object(commands))],
|
||||
)?))
|
||||
}
|
||||
pub(crate) fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
ui: ObjectRef,
|
||||
desktop: ObjectRef,
|
||||
) -> Result<(), VmError> {
|
||||
let graphics = vm.new_object()?;
|
||||
roots.push(graphics);
|
||||
attach_vm(vm, graphics, "Object", desktop)?;
|
||||
let math = vm.new_object()?;
|
||||
roots.push(math);
|
||||
attach_vm(vm, math, "Object", desktop)?;
|
||||
for (owner, name, op) in [
|
||||
(graphics, "loadImage", Op::LoadImage),
|
||||
(graphics, "loadFont", Op::LoadFont),
|
||||
(graphics, "readText", Op::ReadText),
|
||||
(graphics, "textSlice", Op::Slice),
|
||||
(graphics, "string", Op::Stringify),
|
||||
(math, "floor", Op::Floor),
|
||||
(math, "now", Op::Now),
|
||||
(graphics, "prepareText", Op::Prepare),
|
||||
(graphics, "sprite", Op::Sprite),
|
||||
(graphics, "imageExclusion", Op::ImageExclusion),
|
||||
(graphics, "circleExclusion", Op::Circle),
|
||||
(graphics, "rectExclusion", Op::Rectangle),
|
||||
(graphics, "flowText", Op::Flow),
|
||||
(math, "float", Op::Float),
|
||||
(math, "sin", Op::Sin),
|
||||
(math, "cos", Op::Cos),
|
||||
(math, "sqrt", Op::Sqrt),
|
||||
(math, "atan2", Op::Atan2),
|
||||
(math, "abs", Op::Abs),
|
||||
(math, "min", Op::Min),
|
||||
(math, "max", Op::Max),
|
||||
(math, "clamp", Op::Clamp),
|
||||
] {
|
||||
let f = vm.allocate(ContextNativeFunction::new(
|
||||
name,
|
||||
Function { op, desktop },
|
||||
vec![desktop],
|
||||
))?;
|
||||
roots.push(f);
|
||||
attach_vm(vm, f, "Function", desktop)?;
|
||||
vm.set_attr(owner, name, Value::Object(f))?;
|
||||
}
|
||||
vm.set_attr(math, "pi", Value::Number(std::f64::consts::PI.into()))?;
|
||||
vm.set_attr(graphics, "math", Value::Object(math))?;
|
||||
vm.set_attr(ui, "graphics", Value::Object(graphics))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attach_vm(vm: &mut Vm, o: ObjectRef, name: &str, desktop: ObjectRef) -> Result<(), VmError> {
|
||||
let t = crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)?;
|
||||
vm.attach_type(o, t)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PointerDispatch {
|
||||
pub owner: ObjectRef,
|
||||
pub mount: ObjectRef,
|
||||
pub desktop: ObjectRef,
|
||||
pub kind: String,
|
||||
pub point: Point,
|
||||
}
|
||||
impl NativeCallable for PointerDispatch {
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let data = cx
|
||||
.store()
|
||||
.payload::<crate::mount::Mount>(self.mount, "Mount")?;
|
||||
let g = &data.geometry;
|
||||
if !data.mounted || !g.pointer_events {
|
||||
return Err(invalid(
|
||||
"pointer target is detached or has no pointer subscriptions",
|
||||
));
|
||||
}
|
||||
let local = g
|
||||
.view_point(self.point, false)
|
||||
.map(|p| g.view.unwrap_or_default().inverse(p))
|
||||
.ok_or(invalid("pointer cannot be projected"))?;
|
||||
let (x, y) = (local.x - g.x, local.y - g.y);
|
||||
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
|
||||
let event = crate::event_dispatch::new_event(cx, definition, &self.kind)?;
|
||||
cx.set_attr(event, "x", n(x))?;
|
||||
cx.set_attr(event, "y", n(y))?;
|
||||
crate::event_dispatch::dispatch(cx, self.owner, event, Some(self.mount), self.desktop)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,7 @@
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
|
||||
//!
|
||||
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
|
||||
/// 注册托管类型/宿主方法,验证参数并连接业务对象、Mount、视觉协议与窗口/任务资源。
|
||||
pub(crate) mod ui_bridge;
|
||||
|
||||
pub(crate) mod graphics;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
|
||||
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
|
||||
//!
|
||||
//! Allocate and connect the managed desktop, type tables and host globals.
|
||||
use super::*;
|
||||
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
// 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。
|
||||
let desktop = vm.new_object()?;
|
||||
roots.push(desktop);
|
||||
vm.set_attr(desktop, "viewport", Value::Nil)?;
|
||||
@@ -10,6 +15,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
|
||||
let ui = vm.new_object()?;
|
||||
roots.push(ui);
|
||||
// 原生 Session 借用托管身份,主窗口先拥有独立 Presentation;业务对象仍全部位于传入的单一 VM。
|
||||
let session = Arc::new(Mutex::new(Session {
|
||||
nodes: Vec::new(),
|
||||
presentations: std::collections::BTreeMap::from([(
|
||||
@@ -29,6 +35,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
binding_evaluations: 0,
|
||||
exiting: false,
|
||||
}));
|
||||
// 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。
|
||||
let mut public_functions = Vec::new();
|
||||
let mut factories = Vec::new();
|
||||
for (name, operation) in [
|
||||
@@ -71,6 +78,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
vm.set_attr(ui, name, Value::Object(function))?;
|
||||
public_functions.push((name, function, operation));
|
||||
}
|
||||
// Object/Type 来自 VM 类型引导,不能用普通类代替;之后所有宿主名义类型从此根体系建立。
|
||||
let root_types = vm.bootstrap_types()?;
|
||||
roots.extend([root_types.object, root_types.type_type]);
|
||||
vm.set_attr(ui, "Object", Value::Object(root_types.object))?;
|
||||
@@ -79,6 +87,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
("Object", root_types.object),
|
||||
("Type", root_types.type_type),
|
||||
];
|
||||
// 类型按基类先于子类的顺序创建。None 表示抽象能力/宿主结果类型,不开放普通源构造器。
|
||||
for (name, base_name, constructor_name) in [
|
||||
("Trait", "Object", None),
|
||||
("Function", "Object", None),
|
||||
@@ -120,6 +129,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
] {
|
||||
let base = object(Some(&vm.get_attr(ui, base_name)?))?;
|
||||
let constructor = match constructor_name {
|
||||
// ObjectList 与 Event 有特殊的“类型首参+子类字段”构造规则,单独原生适配;其他类型共用 NativeFactory。
|
||||
Some("object-list") => {
|
||||
let factory = vm.allocate(ContextNativeFunction::new(
|
||||
"object-list",
|
||||
@@ -146,6 +156,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
TypeConstructor::NativeTyped(factory)
|
||||
}
|
||||
Some(constructor_name) => {
|
||||
// 把构造标签映射到宿主 Operation;3D 族委托其元数据,未知标签立即失败,不静默创建错误类型。
|
||||
let operation = match constructor_name {
|
||||
"text" => Operation::Text,
|
||||
"rect" => Operation::Rect,
|
||||
@@ -182,6 +193,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
}
|
||||
None => TypeConstructor::Abstract,
|
||||
};
|
||||
// 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。
|
||||
if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor)
|
||||
{
|
||||
factories.push((tag, factory));
|
||||
@@ -191,6 +203,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
vm.set_attr(ui, name, Value::Object(definition))?;
|
||||
definitions.push((name, definition));
|
||||
}
|
||||
// 协议值采用普通托管类:Constraints/Bounds/Point 等字段有类型注解,PaintCommand/CameraGesture 后续再初始化属性。
|
||||
for (name, fields) in [
|
||||
(
|
||||
"Constraints",
|
||||
@@ -208,6 +221,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
.into_iter()
|
||||
.map(|name| endor_core::ClassField {
|
||||
name: name.into(),
|
||||
// children/commands 先声明 Object 接口,后续各协议解码器再要求受控列表;几何字段统一为 Number。
|
||||
type_name: if matches!(name, "children" | "commands") {
|
||||
"Object"
|
||||
} else {
|
||||
@@ -228,6 +242,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
}
|
||||
// The desktop owns the registry through ordinary traced attributes. The
|
||||
// Rust session only borrows handles and adds no hidden lifetime roots.
|
||||
// 桌面通过普通属性拥有类型字典;后续 nominal 查询必须使用同一 desktop 中注册的精确类型身份。
|
||||
let types = vm.new_dict()?;
|
||||
roots.push(types);
|
||||
for (name, definition) in definitions {
|
||||
@@ -237,8 +252,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
vm.attach_type(desktop, root_types.object)?;
|
||||
vm.attach_type(ui, root_types.object)?;
|
||||
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
||||
// 让后续 VM 创建的函数使用宿主 Function 名义类型,并打开源类型检查,再安装完整本机签名。
|
||||
vm.set_function_type(function_type)?;
|
||||
vm.set_source_type_checks(true)?;
|
||||
// 以同一函数身份补完 operation_signature,不改变先前 ui 属性引用的函数对象。
|
||||
for (name, function, operation) in public_functions {
|
||||
vm.replace_payload(
|
||||
function,
|
||||
@@ -280,6 +297,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
};
|
||||
if let Some(operation) = operation {
|
||||
let options = operation_signature(vm.object_store(), desktop, operation)?;
|
||||
// 外层 NativeTyped 工厂接收 Type 首参和任意候选尾参,允许子类字段;NativeFactory 用 options 再核对基构造参数。
|
||||
let envelope = CallSignature {
|
||||
arity: endor_core::CallArity::range(options.arity.required + 1, usize::MAX),
|
||||
parameters: vec![AnnotationType::Type.into()],
|
||||
@@ -288,6 +306,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
effect: options.effect,
|
||||
};
|
||||
let mut captures = vec![desktop];
|
||||
// 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。
|
||||
captures.extend(options.references());
|
||||
vm.replace_payload(
|
||||
function,
|
||||
@@ -303,6 +322,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
),
|
||||
)?;
|
||||
} else {
|
||||
// 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。
|
||||
let kind = if name == "event" {
|
||||
"Event"
|
||||
} else {
|
||||
@@ -373,6 +393,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
let function = object(Some(&vm.get_attr(ui, name)?))?;
|
||||
vm.attach_type(function, function_type)?;
|
||||
}
|
||||
// 实例方法安装到对应名义类型;继承复用基类方法,TypeMethod 会验证实际接收者及参数契约。
|
||||
for (type_name, name, operation) in [
|
||||
("Block", "bind", Operation::Bind),
|
||||
("OrbitController", "update", Operation::CameraControl),
|
||||
@@ -425,12 +446,14 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
expected,
|
||||
session: session.clone(),
|
||||
},
|
||||
// 方法 wrapper 捕获所属类型与桌面,保证分发和签名引用在方法仍存活时有效。
|
||||
vec![expected, desktop],
|
||||
))?;
|
||||
roots.push(method);
|
||||
vm.attach_type(method, function_type)?;
|
||||
vm.define_method(expected, name, method)?;
|
||||
}
|
||||
// 类型与基础方法就绪后再装窗口、默认视觉、事件方法和能力契约,顺序满足各模块查类型/方法的前提。
|
||||
crate::window::install(vm, roots, desktop, tabs)?;
|
||||
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
|
||||
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
|
||||
@@ -438,7 +461,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?;
|
||||
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
|
||||
crate::protocols::install(vm, roots, desktop, ui)?;
|
||||
// 每个桥接域配套自己的任务 Scheduler,再接工具入口及托管起始页工厂,最后返回可使用的 UiBridge。
|
||||
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
|
||||
crate::bridge::graphics::install(vm, roots, ui, desktop)?;
|
||||
developer_pages::install(vm, ui, &session)?;
|
||||
let bridge = UiBridge { ui, session, tasks };
|
||||
developer_pages::install_start_page(vm, &bridge)?;
|
||||
|
||||