7 Commits

73 changed files with 3164 additions and 78 deletions
Generated
+132
View File
@@ -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"
+2
View File
@@ -10,6 +10,8 @@ license = "MIT OR Apache-2.0"
anyhow = "1.0"
compiler = { path = "vendor/aster/crates/compiler" }
cosmic-text = "0.19"
png = "0.18"
unicode-linebreak = "0.1.5"
endor_core = { package = "core", path = "vendor/aster/crates/core" }
frontend = { path = "vendor/aster/crates/frontend" }
serde = { version = "1.0", features = ["derive", "rc"] }
+8
View File
@@ -22,6 +22,14 @@ experimental `namespace` / `export scene` / `render` parser has been removed.
Host UI integration lives in Tessera; Aster needs no Tessera-specific changes.
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
+111
View File
@@ -0,0 +1,111 @@
# Endor 脚本:龙与文字实时绕排
完整程序:[examples/illustrated-manuscript.endor](../examples/illustrated-manuscript.endor)。它通过普通 Page、源 measure/paint、托管事件及 owner-scoped Task 工作。Tessera 没有 Dragon / Flame / Manuscript 专用 Rust 类型、专用启动参数或动画循环;Rust 只补齐可被其他脚本复用的图形与文字能力。
## 运行
在仓库根目录启动普通应用:
```sh
cargo run --release --locked
```
另开终端,在同一个仓库根目录提交原始 Endor 文件:
```sh
curl --request PUT --header 'Content-Type: text/plain; charset=utf-8' \
--data-binary @examples/illustrated-manuscript.endor \
http://127.0.0.1:4737/v1/namespaces/app.main/code
```
这是普通源码提交,没有预编译场景或原生替身。资源路径目前相对于应用工作目录;通过以上根目录启动方式使用 `examples/assets/manuscript/`。从其他目录启动时,修改脚本的 `assets` 路径为绝对路径。图片、字体、正文都在示例目录,不编入宿主。素材与字体许可见 [NOTICE](../examples/assets/manuscript/NOTICE.md)。
移动鼠标让龙跟随,按住左键喷火,松开停止。离开窗口、取消指针或失焦也会停止喷火。空闲两秒回到卷曲姿势。原生窗口缩放触发普通布局和 paint;窄窗口切换到脚本预备的小字号。关闭 Page 会取消其定时任务,不留下应用级动画循环。
## 脚本对象分工
| 对象 | 状态与责任 |
| --- | --- |
| `Segment` | 一节身体的位置、角度及共享图片引用 |
| `FireArt` | 一张火焰图及其原始轴角;这些校准值是示例素材数据 |
| `Flame` | 一个粒子的位置、速度、尺寸、年龄及 FireArt 引用 |
| `Dragon` | 身体链、火焰集合、目标点、输入状态、时钟样本和随机种子;实现追随、恢复、生成/更新/删除粒子及绘图描述 |
| `Manuscript : RectBlock` | 普通视觉子类;组合预处理文字、首字母图片、龙和 Page,覆盖 measure/paint,通过 onMount/onUnmount 管理 Task |
| `Page` / `Task` / `Event` / `Mount` | 沿用 Tessera 已有托管体系:Page 是任务 owner,Task 调用脚本 tick,Event 带局部指针位置,Mount 提供当前出现的几何 |
这些是同一 VM 中的普通对象与引用。身体并不是多个重量远程 actor;图片被多个出现/粒子共享,临时 PaintCommand 只描述本帧绘制。
## 从输入到一帧
1. `view.on("pointermove", dragon.pointer)` 显式订阅连续输入。宿主把原生指针转换成 Event,再交给普通传播链;脚本读取 `event.mount.bounds()`,将目标点换算到自己的 700 单位画布。按下/松开只改变脚本 `pressed`。
2. `onMount` 用 `ui.after(page,"animation",80,self.tick)` 创建 Page 所有的任务。`tick` 调用脚本 `Dragon.step()`,再安排下一个任务。80 ms 是脚本参数;宿主不知道这是龙的动画。
3. `step` 先采样通用单调时钟。头向目标移动,其余 19 节依次跟随上一节,保持间距 30,并把相邻转角差限制在 ±0.25 弧度。空闲时由脚本计算卷曲目标并插值回去。
4. 粒子在脚本中更新位置、速度与年龄,倒序删除超过 12 步的粒子。每步最多产生 1 个、总数最多 12 个,最长边仅 12–20 个画布单位。随机变化由脚本自己的确定性种子和数学函数产生。
5. 普通属性变更使源 paint 的已观察依赖失效。`paint` 从当前几何生成图像绘制命令,再由脚本选择哪些图片形成避让区,交给通用文字流布局。
6. 正常文字使用缓存整词形状;喷火时脚本请求字素粒度,并在 `heat` 中按粒子距离修改临时文字命令的位置、角度、透明度和颜色。没有把热力规则藏进宿主。
7. 返回的 DisplayList 经过现有只读 paint 校验、Frame 发布和 CPU 栅格化,在普通窗口中呈现。VM 对象不进入窗口线程。
paint 内创建/修改的是临时返回对象,不能写已有龙状态或读取文件/时钟。资源加载、时钟采样和状态更新发生在初始化、事件或任务中。
## 绕排与预处理
脚本初始化时用 `prepareText` 准备两套正文和提示文字。字体/字号/行高/颜色/设备分辨率属于准备参数;每帧只传新几何与避让区,复用字形形状和透明图像。
`Dragon.artwork` 返回普通图像命令。脚本循环调用通用 `imageExclusion(command,padding)`,由图片 alpha 轮廓产生保守凸包,并给首字母添加矩形避让。`flowText` 对整个行带求交、合并遮挡区间,再依次填充剩余的左右片段;同一个文字 cursor 跨片段继续,不复制或跳过词。单词装不下所有可用片段时才退回字素边界。
当前提供面向本示例的从左到右文字流,不声称完整支持所有文字系统的双向段落。字素绘制会改变部分字偶距;这是喷火效果的取舍。正文超出当前页的部分留在准备数据中,当前示例保持单页,未增加滚动文档业务。
## 火焰角度与尺寸
火焰 PNG 自身方向各不相同;把它们一律当作朝右图片会错位。脚本在 `FireArt.axis` 中记录每张图片原始轴角,绘制时使用:
```endor
image = p.art.image
imageScale = p.size * scale / math.max(image.width, image.height)
angle = math.atan2(p.vy, p.vx) - p.art.axis
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
```
角度以弧度表示,在屏幕坐标中正方向为顺时针;原始素材校准值与 PNG 一起属于应用数据。尺寸按最长边归一化,窄高的 PNG 不会因宽度较小而放大。舌头同样通过局部 anchor 定位,使其偏移随头部一起旋转。
## 可复用宿主接口
入口是 `g = ui.graphics`,数学入口是 `g.math`。宿主实现位于 `src/bridge/graphics.rs`、`src/render/image.rs` 和 `src/render/text_flow.rs`。
| 方法 | 行为 |
| --- | --- |
| `loadImage(path)` | 读取有界普通 PNG 文件,返回共享不可变资源及 width/height |
| `loadFont(path,family)` | 注册开放字体,验证指定 family;相同资源复用,冲突不替换既有字体 |
| `readText(path)` / `textSlice(text,start,end)` / `string(value)` | 初始化用有界 UTF-8 资源读取、按字符切片及标量文本转换 |
| `prepareText(text,font,size,lineHeight,color,rasterScale)` | 生成与障碍无关的缓存文字资源 |
| `sprite(image,x,y,width,height,angle,anchorX,anchorY,opacity)` | 返回普通 PaintCommand;旋转围绕原点,anchor 决定图片局部偏移 |
| `imageExclusion(command,padding)` | 图像实际 alpha 轮廓的保守凸包,应用该命令的平移、缩放与旋转 |
| `circleExclusion(x,y,r)` / `rectExclusion(x,y,w,h)` | 通用几何避让区 |
| `flowText(prepared,x,y,w,h,exclusions,letterMode)` | 生成可检查/修改的普通文字图像命令列表 |
| `math.float/sin/cos/sqrt/atan2/abs/min/max/clamp/floor/now` | 通用实数与图形数学;float 是显式二进制浮点入口,now 仅在事件/任务中采样 |
| `ObjectList.append/remove/extend/added` | 通用托管集合编辑/批量追加/持久添加;paint 只能编辑本次创建的临时集合 |
PNG 单张最多 4M 像素,文件最多 4 MiB;准备文字最多 16 KiB、缓存最多 8M 像素;最多 512 个避让区、1024 行和 10000 条绘图命令。资源读取不允许在 paint/layout 中执行。图形边界显式窄化数字,源语言的精确数值表示不会被改写。
原生连续指针输入需显式订阅。生产端在有界队列中只合并相同窗口/session/目标的相邻绝对移动采样,按下/松开/取消及目标变化都是顺序边界。后台 relay 直接把内部 Input envelope 送进同一 VM worker 队列并等待完成,窗口线程不等待;不绕行 HTTP。worker 等既有刷新结束后才重验 Mount/session 并更新连续事件版本,避免动画发布新帧时丢掉移动或松开。外部 HTTP 无法创建内部 envelope,继续严格校验帧版本。
## Aster 语言扩展
为使上述逻辑留在源码中,独立 Aster 分支实现了真实四则运算、非字面量负号、六种比较、if/else 与 while,并同步 ASDL、AST 生成产物、名称分析、bytecode 验证与 VM。整数/十进制/有理数保持精确;有 BinaryFloat 操作数才选择浮点算术,混合数值比较避免大整数丢精度。循环沿用同一累计指令限制与协作暂停机制。不是在 Tessera 中解释另一个小语言。
## 验证与范围
```sh
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo fmt --all -- --check
TESSERA_MANUSCRIPT_SNAPSHOTS=/absolute/output/path \
cargo test --release --locked manuscript_script_snapshots -- --nocapture
```
快照测试加载实际 Endor 文件,经 VM、事件、源 paint 和软件渲染器输出 endor-rest/crossing/fire/narrow 等 PNG。覆盖持续喷火达到上限、角度校准、最长边尺寸、字形呈现、宽窄窗口,以及任务确实调用脚本和关闭 Page 后停止。另一项集成测试验证指针驱动头部移动、取消与粒子回收。不是用 Rust 动画或手工绘图替代源码执行。
当前采用既有 CPU 后端;无 GPU 实现,也未达到稳定 60 FPS。参考本机 release 样本,16 次持续喷火的 VM 更新与完整 Frame 刷新约 1.4–1.5 秒(不含 OS 呈现),不能把 80 ms 定时参数当作实际帧率。性能结果属于脚本通路而非之前已移除的原生原型。
本次还补上了指针类型的 HTTP 白名单。最初只测 VM 分发遗漏了真实 worker 入口,随后通过真实 HTTP 回归复现并修复 `unsupported event kind`;动画期间的过期事件竞争则通过内部原生 envelope 解决。测试分别覆盖 HTTP 的坐标/类型/严格版本、原生 envelope 换帧后仍投递且旧会话不穿透,以及生产端合并不跨越按下/松开。最终运行窗口已由用户确认“持续正常,松开也正常”。系统自动事件投递/截图权限未开启,因此最终展示图来自实际脚本的 VM→Frame→软件渲染快照,原生交互验证来自用户操作。
+63
View File
@@ -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。
+4
View File
@@ -37,3 +37,7 @@ and the running start page's four working actions.
The file is client-side sample material; Tessera's API stores source by namespace.
Return to the [project guide](../README.md).
## 龙与文字绕排
`illustrated-manuscript.endor` 通过通用图形资源、源 paint、指针事件与 Page 任务实现。龙、火焰和文字受热逻辑均在 Endor 中。资源在 `assets/manuscript/`;从仓库根目录启动应用并提交源码,详见 [运行和脚本解释](../docs/illustrated-manuscript.md)。
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# Illustrated Manuscript assets and attribution
Dragon sprites (head, tongue, front/back wings, body-1 through body-19), fire
sprites (Layer 2 through Layer 11), dropcap.png and story.txt are from
https://github.com/dengshu2/illustrated-manuscript (main, retrieved 2026-11-12).
The upstream package.json declares `ISC`; upstream has no separate LICENSE file.
Its complete README is retained as UPSTREAM.md. It credits the original demo to
River Marchand (@Riyvir), Neither/Nor: https://neithernor.studio/.
Reference live demo: https://illustrated-manuscript.vercel.app/.
The native chain animation, exclusion and fire algorithms are adapted from that
project's src/dragon.js and src/main.js; this port contains no JavaScript runtime.
IMFellEnglish-Regular.ttf is the unmodified IMFeENrm28P.ttf from
https://github.com/google/fonts/tree/main/ofl/imfellenglish.
Copyright (c) 2010, Igino Marini. SIL Open Font License 1.1, retained in OFL.txt.
The face is embedded for offline, reproducible manuscript typography.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+67
View File
@@ -0,0 +1,67 @@
# Illustrated Manuscript
An interactive illuminated manuscript page featuring an animated dragon that follows your cursor, breathes fire on click, and causes text to flow around it in real-time.
**[→ Live Demo](https://illustrated-manuscript.vercel.app/)**
## Overview
This project renders a medieval manuscript page on Canvas, featuring a hand-painted dragon made of sprite segments. The dragon tracks the mouse cursor with chain-physics (20 body segments with angle constraints), and clicking makes it breathe fire. Text reflows around the dragon body and fire particles each frame using [Pretext](https://github.com/chenglou/pretext)'s `layoutNextLine` API — no DOM measurements, no CSS reflow.
### Features
- **Real-time text exclusion** — text wraps around arbitrary moving shapes (dragon body + fire particles), re-laid out every frame via Pretext
- **Chain-physics dragon** — 20-segment inverse-kinematics body, hand-painted sprites, wing flapping, idle rest pose
- **Fire-breathing** — click to spawn particle bursts from the dragon's mouth; particles push and distort nearby text characters
- **Illuminated drop cap** — hand-drawn initial letter in manuscript tradition
- **Medieval aesthetic** — IM Fell English font, parchment tones, hand-painted sprites throughout
## Tech Stack
- **[Pretext](https://github.com/chenglou/pretext)** — Pure JS/TS text measurement & layout engine by [Cheng Lou](https://x.com/_chenglou). Enables laying out text without DOM reflow, line by line with variable widths
- **WebGL2 + 2D Canvas** — offscreen 2D Canvas compositing, uploaded to WebGL2 texture for display
- **Vite** — build tool
## Project Structure
```
src/
config.js — constants, responsive layout calculations
dragon.js — dragon physics, sprite rendering, fire particle system
story.js — manuscript story text
main.js — entry point: WebGL pipeline, input, text layout, render loop
public/
dragon-sprites/ — 23 hand-painted dragon body part PNGs
fire-sprites/ — 10 fire particle PNGs
img/dropcap.png — illuminated drop cap
(IM Fell English loaded via Google Fonts)
```
## Getting Started
```bash
npm install
npm run dev
```
Open in browser. Desktop recommended — mobile support is limited.
## How It Works
1. **Text preparation**: story text is segmented and measured once via `prepareWithSegments()`
2. **Each frame**: dragon segments and fire particles generate exclusion rectangles
3. **Text layout**: for each line, available width ranges are computed by subtracting exclusion zones, then `layoutNextLine()` fills each range
4. **Fire text effect**: characters near fire particles are displaced, rotated, and color-shifted based on proximity
5. **Rendering**: everything composites on an offscreen 2D Canvas, then uploads to a WebGL2 fullscreen quad
## Credits
Created by [River Marchand (@Riyvir)](https://x.com/Riyvir), Creative Director at [Neither/Nor](https://neithernor.studio/).
Built with [Pretext](https://github.com/chenglou/pretext) by [Cheng Lou](https://x.com/_chenglou) — a pure JavaScript/TypeScript library for multiline text measurement & layout, enabling web UIs to bypass DOM measurements and reflow entirely.
> *"This little illuminated dragon is very happy about Pretext."* — [@Riyvir](https://x.com/Riyvir/status/2038093450139279426)
## License
ISC
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+47
View File
@@ -0,0 +1,47 @@
It was the third candle the abbess had confiscated, and Sable had nodded gravely each time and stolen another from the chapel stores. She kept reading by its unsteady light, hunched beneath her wool blanket in the scriptorium where the novices slept on straw pallets between the copying desks.
It was the bestiary that held her. Not the psalter, not the gospels, not the lives of saints with their wooden sufferings. The bestiary. Some brother at Lindisfarne had painted it two centuries ago, and his creatures had a quality she could not name, a weight to them, as if they had been observed from life rather than copied from pattern books. His lions looked hungry. His basilisks looked bored.
And his dragon, coiled in the lower margin of the forty-seventh leaf, looked like it was breathing.
She had watched it for six nights before she was certain. The movement was slight, a swelling of the ribs, a settling, barely more than the flicker the candlelight threw across the page. On the seventh night she held her breath and placed a finger beside its painted flank, and the parchment was warm.
\u201CYou could simply ask,\u201D the dragon said.
Sable jerked her hand back. The dragon opened one eye, yellow as egg tempera, and regarded her with an expression she recognized from the abbey cats: mild contempt for a creature too slow to be interesting but too close to ignore.
\u201CI am called Voss,\u201D it said. \u201CThough the brother who painted me called me several other things, mostly under his breath, mostly because I would not hold still.\u201D
\u201CYou are paint,\u201D Sable said. It came out less firmly than she intended.
\u201CI am gall ink and oak soot and gum arabic,\u201D Voss replied. \u201CAlso rabbit skin glue, which I find personally offensive. But I am not paint. Paint does not get stiff from two centuries of holding the same pose.\u201D He uncurled one leg and stretched it across the margin rule, his claws dimpling the parchment. \u201CYou, on the other hand, are trespassing. This page is not assigned reading for novices.\u201D
\u201CI am not a novice. I clean the scriptorium.\u201D
\u201CEven worse. A servant reading. The brothers would be scandalized.\u201D
\u201CThe brothers are asleep.\u201D
\u201CThe brothers,\u201D Voss said, \u201Care always asleep. That is the principal advantage of monasteries.\u201D He yawned, and his breath smelled of lamp-black and something older, something mineral, like a cave after rain. A thin red tongue uncurled and tasted the air above the page.
Sable leaned closer. In the candlelight she could see the crosshatching of his scales, each one laid down with a single stroke of the brush. The brother who painted him had not scrimped. Most bestiary dragons were outlines stuffed with vermillion, flat and heraldic. This one had shadow under its jaw. This one had a nick in its left ear.
\u201CWhat do you eat?\u201D she asked.
\u201CSilverfish, mostly. They get into the binding paste. Occasionally a bookworm, if I am feeling athletic.\u201D He cocked his head. \u201CWhy are you not frightened?\u201D
\u201CShould I be?\u201D
\u201CConvention demands it. A maiden, a dragon. There are expectations.\u201D
\u201CI scrub floors and empty chamber pots. I am not anyone\u2019s idea of a maiden.\u201D
Voss made a sound that might have been a laugh. It was dry and papery, like a sheet of vellum being folded. \u201CGood,\u201D he said. \u201CMaidens are tedious. They always scream at exactly the wrong moment. You, at least, have the sense to ask questions first.\u201D
He settled back into the margin, tucking his tail around the descender of a large Q. \u201CCome back tomorrow night,\u201D he said. \u201CBring a candle that does not gutter so badly. And if you can manage it, a bit of cheese. I have not tasted cheese in two hundred years, but I believe I would still recognize it.\u201D
\u201CCan you eat cheese? You are made of ink.\u201D
\u201CI am made of ink that remembers being alive,\u201D Voss said. \u201CThere is a considerable difference.\u201D He closed his eyes. The warmth under Sable\u2019s fingertips faded, and the dragon on the page was still again, just pigment and binder on stretched calfskin, exactly as lifeless as it should have been.
Sable sat in the dark for a long time after the candle burned out. Then she went to the larder and stole a piece of cheese.\u2026
+297
View File
@@ -0,0 +1,297 @@
// All application state, motion, fire, hit reactions and scheduling live here.
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
g = ui.graphics
math = g.math
assets = "examples/assets/manuscript/"
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
story = g.textSlice(g.readText(assets + "story.txt"), 1, 1000000)
bodyText = g.prepareText(story, font, 21, 34, "#2A1A0A", 2)
smallText = g.prepareText(story, font, 16, 26, "#2A1A0A", 2)
hint = g.prepareText("Move the pointer. Hold to breathe fire.", font, 14, 22, "#705B44", 2)
function sprite(image: Object, x: Number, y: Number, scale: Number, angle: Number, ax: Number, ay: Number): Object {
return g.sprite(image, x, y, image.width * scale, image.height * scale, angle, ax, ay, 1)
}
function wrappedAngle(angle: Number): Number {
result = angle
while result > math.pi { result = result - math.pi * 2 }
while result < -math.pi { result = result + math.pi * 2 }
return result
}
class Segment {
x: Number
y: Number
angle: Number
image: Object
}
// Asset orientation is application data: angles are radians, clockwise in
// screen coordinates. Each PNG is calibrated against its own long axis.
class FireArt {
image: Object
axis: Number
}
class Direction { angle: Number }
class Flame {
x: Number
y: Number
vx: Number
vy: Number
size: Number
age: Number
art: FireArt
}
class Dragon {
segments: Object
flames: Object
fireImages: Object
tongue: Object
frontWing: Object
backWing: Object
targetX: Number
targetY: Number
pressed: Number
time: Number
lastMove: Number
seed: Number
function random(self: Dragon): Number {
self.seed = self.seed + 1
value = math.sin(self.seed * 12.9898) * 43758.5453
return value - math.floor(value)
}
function pointer(self: Dragon, event: Event): Unit {
bounds = event.mount.bounds()
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
self.targetX = (event.x - (bounds.width - 700 * scale) / 2) / scale
self.targetY = (event.y - 30) / scale
self.lastMove = math.now()
}
function down(self: Dragon, event: Event): Unit {
self.pointer(event)
self.pressed = 1
}
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
function leave(self: Dragon, event: Event): Unit {
self.pressed = 0
self.lastMove = -10
}
function step(self: Dragon): Unit {
self.time = math.now()
if self.time - self.lastMove > 2 {
x = math.float(150)
y = math.float(110)
i = 0
while i < self.segments.count {
s = self.segments.at(i)
angle = -i / 19 * math.pi * 0.7
if i > 0 {
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
}
s.x = s.x + (x - s.x) * 0.12
s.y = s.y + (y - s.y) * 0.12
s.angle = s.angle + wrappedAngle(angle - s.angle) * 0.12
i = i + 1
}
} else {
head = self.segments.at(0)
dx = self.targetX - head.x
dy = self.targetY - head.y
distance = math.sqrt(dx * dx + dy * dy)
if distance > 4 {
speed = math.min(distance, math.max(12, distance * 0.15))
head.x = head.x + dx / distance * speed
head.y = head.y + dy / distance * speed
head.angle = math.atan2(dy, dx)
}
i = 1
while i < self.segments.count {
previous = self.segments.at(i - 1)
s = self.segments.at(i)
angle = math.atan2(previous.y - s.y, previous.x - s.x)
s.angle = previous.angle + math.clamp(wrappedAngle(angle - previous.angle), -0.25, 0.25)
s.x = previous.x - math.cos(s.angle) * 30
s.y = previous.y - math.sin(s.angle) * 30
i = i + 1
}
}
i = self.flames.count
while i > 0 {
i = i - 1
p = self.flames.at(i)
p.age = p.age + 1
p.x = p.x + p.vx
p.y = p.y + p.vy
p.vx = p.vx * 0.95
p.vy = p.vy * 0.95 - 0.6
if p.age >= 12 { self.flames.remove(i) }
}
if self.pressed > 0 {
head = self.segments.at(0)
i = 0
while i < 1 {
if self.flames.count < 12 {
angle = head.angle + (self.random() - 0.5) * 0.25
speed = 20 + self.random() * 12
size = 12 + self.random() * 8
art = self.fireImages.at(math.floor(self.random() * self.fireImages.count))
self.flames.append(Flame(head.x + math.cos(head.angle) * 30, head.y + math.sin(head.angle) * 30, math.cos(angle) * speed, math.sin(angle) * speed, size, 0, art))
}
i = i + 1
}
}
}
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
commands = ObjectList()
wing = self.segments.at(5)
commands.append(sprite(self.backWing, ox + wing.x * scale, oy + wing.y * scale, scale * 0.24, wing.angle + math.sin(self.time * 3) * 0.4, 1, 1))
i = self.segments.count
while i > 0 {
i = i - 1
s = self.segments.at(i)
ax = math.float(0.5)
if i == 0 {
ax = 0.45
commands.append(sprite(self.tongue, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, -s.image.width * 0.3 / self.tongue.width, 0.5))
}
commands.append(sprite(s.image, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, ax, 0.5))
if i == 5 { commands.append(sprite(self.frontWing, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle - math.sin(self.time * 3 + 0.5) * 0.4, 1, 1)) }
}
i = 0
while i < self.flames.count {
p = self.flames.at(i)
image = p.art.image
imageScale = p.size * scale / math.max(image.width, image.height)
angle = math.atan2(p.vy, p.vx) - p.art.axis
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
drawing.opacity = 1 - p.age / 12
if p.age > 4 { drawing.tint = "#E08A30" }
if p.age > 8 { drawing.tint = "#F0C030" }
commands.append(drawing)
i = i + 1
}
return commands
}
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
i = 0
while i < commands.count {
c = commands.at(i)
j = 0
while j < self.flames.count {
p = self.flames.at(j)
dx = c.x - ox - p.x * scale
if math.abs(dx) < 60 * scale {
dy = c.y - oy - p.y * scale
if math.abs(dy) < 60 * scale {
distance = math.sqrt(dx * dx + dy * dy)
if distance > 0.1 {
if distance < 60 * scale {
weight = (1 - distance / (60 * scale)) * (1 - p.age / 12)
c.x = c.x + dx / distance * weight * 8 * scale
c.y = c.y + dy / distance * weight * 8 * scale
c.angle = c.angle + weight * 0.12
c.opacity = math.max(0.3, c.opacity - weight * 0.2)
c.tint = "#C4402A"
}
}
}
}
j = j + 1
}
i = i + 1
}
}
}
class Manuscript: RectBlock {
dragon: Dragon
page: Object
prepared: Object
small: Object
initial: Object
timer: Object
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
}
function onMount(self: Manuscript, mount: Mount): Unit {
self.timer = ui.after(self.page, "animation", 80, self.tick)
}
function onUnmount(self: Manuscript, mount: Mount): Unit { self.timer.cancel() }
function tick(self: Manuscript): Unit {
self.dragon.step()
self.timer = ui.after(self.page, "animation", 80, self.tick)
}
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
ox = bounds.x + (bounds.width - 700 * scale) / 2
oy = bounds.y + 30
margin = 45 * scale
text = self.prepared
line = 34
if bounds.width < 540 {
text = self.small
line = 26
}
initialHeight = line * 7
initialWidth = self.initial.width / self.initial.height * initialHeight
art = self.dragon.artwork(ox, oy, scale)
exclusions = ObjectList(g.rectExclusion(ox + margin, oy + margin, initialWidth + 12, initialHeight))
i = 0
while i < art.count {
exclusions.append(g.imageExclusion(art.at(i), 6 * scale))
i = i + 1
}
letters = 0
if self.dragon.flames.count > 0 { letters = 1 }
drawing = g.flowText(text, ox + margin, oy + margin, 700 * scale - margin * 2, math.max(0, bounds.height - 90 - margin), exclusions, letters)
if self.dragon.flames.count > 0 { self.dragon.heat(drawing.commands, ox, oy, scale) }
commands = RectBlock.paint(self, mount, bounds).commands
commands.append(g.sprite(self.initial, ox + margin, oy + margin, initialWidth, initialHeight, 0, 0, 0, 1))
commands.extend(drawing.commands)
commands.extend(art)
footer = g.flowText(hint, bounds.x + 20, bounds.y + bounds.height - 28, math.max(0, bounds.width - 40), 24, ObjectList(), 0)
commands.extend(footer.commands)
return DisplayList(commands)
}
}
segments = ObjectList()
headImage = g.loadImage(assets + "dragon-sprites/head.png")
segments.append(Segment(math.float(150), math.float(110), math.float(0), headImage))
function makeBody(): Unit {
i = 1
x = math.float(150)
y = math.float(110)
while i < 20 {
angle = -i / 19 * math.pi * 0.7
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
image = g.loadImage(assets + "dragon-sprites/body-" + g.string(i) + ".png")
segments.append(Segment(x, y, angle, image))
i = i + 1
}
}
makeBody()
fireImages = ObjectList()
// Layer 2..11: baseline directions differ; these values belong to this artwork.
fireDirections = ObjectList(Direction(-0.50), Direction(-0.30), Direction(-0.55), Direction(0.40), Direction(-1.35), Direction(-1.57), Direction(-1.57), Direction(0.05), Direction(-1.40), Direction(1.12))
function loadFire(): Unit {
i = 2
while i <= 11 {
image = g.loadImage(assets + "fire-sprites/Layer " + g.string(i) + ".png")
fireImages.append(FireArt(image, fireDirections.at(i - 2).angle))
i = i + 1
}
}
loadFire()
dragon = Dragon(segments, ObjectList(), fireImages, g.loadImage(assets + "dragon-sprites/tongue.png"), g.loadImage(assets + "dragon-sprites/wing-front.png"), g.loadImage(assets + "dragon-sprites/wing-back.png"), math.float(150), math.float(110), 0, math.now(), -10, math.float(42))
page = Page()
page.title = "Illustrated Manuscript · Endor"
view = Manuscript(700, 800, 0, "#F4EEE0", dragon, page, bodyText, smallText, g.loadImage(assets + "dropcap.png"), ObjectList())
view.flex = 1
view.boxWidth = "fill"
page.crossAlign = "stretch"
moveConnection = view.on("pointermove", dragon.pointer)
downConnection = view.on("pointerdown", dragon.down)
upConnection = view.on("pointerup", dragon.up)
leaveConnection = view.on("pointerleave", dragon.leave)
cancelConnection = view.on("pointercancel", dragon.leave)
page.render(view)
+673
View File
@@ -0,0 +1,673 @@
//! General retained graphics resources for source paint: images, fonts, prepared
//! text, geometric exclusions and numerical functions. No application behaviour.
use crate::{
document::{RenderCommand, parse_color},
geometry::{Point, Rect},
render::{
image::Image,
text_flow::{Cursor, Prepared},
},
text::{TextFamily, TextStyle},
};
use endor_core::{
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
ObjectStore, Value, Vm, VmError,
};
use std::{any::Any, sync::Arc};
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
fn object(v: &Value) -> Result<ObjectRef, VmError> {
if let Value::Object(o) = v {
Ok(*o)
} else {
Err(invalid("expected Object"))
}
}
fn number(v: &Value) -> Result<f32, VmError> {
let Value::Number(v) = v else {
return Err(invalid("expected Number"));
};
let n = v.to_f64()?;
if n.abs() > 1_000_000. {
return Err(invalid("graphics number exceeds bounds"));
}
Ok(n as f32)
}
fn text(v: &Value) -> Result<&str, VmError> {
if let Value::Text(s) = v {
Ok(s)
} else {
Err(invalid("expected String"))
}
}
fn n(v: f32) -> Value {
Value::Number((v as f64).into())
}
#[derive(Debug)]
pub(crate) struct ImageResource(pub Arc<Image>);
#[derive(Debug)]
struct FontResource(TextFamily);
#[derive(Debug)]
struct TextResource {
prepared: Arc<Prepared>,
line: f32,
}
#[derive(Debug)]
struct Exclusion {
circle: Option<(Point, f32)>,
polygon: Vec<Point>,
padding: f32,
}
macro_rules! resource {
($t:ty,$name:literal) => {
impl ObjectPayload for $t {
fn as_any(&self) -> &dyn Any {
self
}
fn kind_name(&self) -> &'static str {
$name
}
fn allows_attributes(&self) -> bool {
true
}
fn reference_assignment_only(&self) -> bool {
true
}
}
};
}
resource!(ImageResource, "image-resource");
resource!(FontResource, "font-resource");
resource!(TextResource, "prepared-text");
resource!(Exclusion, "layout-exclusion");
fn attach(cx: &mut NativeContext<'_>, o: ObjectRef, desktop: ObjectRef) -> Result<(), VmError> {
crate::ui_bridge::attach_desktop_type(cx, o, "Object", desktop)
}
fn file(path: &str, limit: u64) -> Result<Vec<u8>, VmError> {
use std::io::Read;
if path.len() > 8192 {
return Err(invalid("resource path too long"));
}
let f = std::fs::File::open(path).map_err(|_| invalid("resource file cannot be opened"))?;
let meta = f
.metadata()
.map_err(|_| invalid("resource metadata unavailable"))?;
if !meta.is_file() || meta.len() > limit {
return Err(invalid("resource must be a bounded regular file"));
}
let mut data = Vec::new();
f.take(limit + 1)
.read_to_end(&mut data)
.map_err(|_| invalid("resource read failed"))?;
if data.len() as u64 > limit {
return Err(invalid("resource exceeds byte limit"));
}
Ok(data)
}
fn field(s: &ObjectStore, o: ObjectRef, key: &str) -> Result<f32, VmError> {
number(&s.get_attr(o, key)?)
}
pub(crate) fn decode_image(s: &ObjectStore, c: ObjectRef) -> Result<RenderCommand, VmError> {
let resource = object(&s.get_attr(c, "image")?)?;
let image = s
.payload::<ImageResource>(resource, "ImageResource")?
.0
.clone();
let tint = match s.get_attr(c, "tint") {
Ok(Value::Text(v)) => Some(parse_color(&v).map_err(|_| invalid("invalid image tint"))?),
Ok(Value::Nil) | Err(VmError::AttributeNotFound(_)) => None,
_ => return Err(invalid("invalid image tint")),
};
let c = RenderCommand::Image {
x: field(s, c, "x")?,
y: field(s, c, "y")?,
width: field(s, c, "width")?,
height: field(s, c, "height")?,
offset_x: field(s, c, "offsetX")?,
offset_y: field(s, c, "offsetY")?,
angle: field(s, c, "angle")?,
opacity: field(s, c, "opacity")?,
tint,
image,
};
c.validate()
.map_err(|_| invalid("invalid image geometry"))?;
Ok(c)
}
fn command(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
image: ObjectRef,
origin: Point,
rect: Rect,
angle: f32,
opacity: f32,
) -> Result<ObjectRef, VmError> {
let c = crate::native_visual::record(
cx,
desktop,
"PaintCommand",
vec![
("kind", Value::Text("image".into())),
("image", Value::Object(image)),
("x", n(origin.x)),
("y", n(origin.y)),
("width", n(rect.width)),
("height", n(rect.height)),
("offsetX", n(rect.x)),
("offsetY", n(rect.y)),
("angle", n(angle)),
("opacity", n(opacity)),
],
)?;
Ok(c)
}
#[derive(Debug, Clone, Copy)]
enum Op {
LoadImage,
LoadFont,
ReadText,
Slice,
Stringify,
Floor,
Now,
Prepare,
Sprite,
ImageExclusion,
Circle,
Rectangle,
Flow,
Float,
Sin,
Cos,
Sqrt,
Atan2,
Abs,
Min,
Max,
Clamp,
}
#[derive(Debug)]
struct Function {
op: Op,
desktop: ObjectRef,
}
impl NativeCallable for Function {
fn arity(&self) -> Option<endor_core::CallArity> {
use Op::*;
Some(endor_core::CallArity::exact(match self.op {
Now => 0,
LoadImage | ReadText | Float | Sin | Cos | Sqrt | Abs | Stringify | Floor => 1,
LoadFont | Atan2 | Min | Max | ImageExclusion => 2,
Circle | Clamp | Slice => 3,
Rectangle => 4,
Prepare => 6,
Flow => 7,
Sprite => 9,
}))
}
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Op::*;
let desktop = self.desktop;
let result = match self.op {
Now => {
if cx.store().is_read_only() {
return Err(invalid(
"sample the clock in an event/task and store it in the model",
));
}
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
Value::Number(
START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs_f64()
.into(),
)
}
LoadImage => {
if cx.store().is_read_only() {
return Err(invalid("load resources outside paint/layout"));
}
let image = Image::png(&file(text(&args[0])?, 4_194_304)?)
.map_err(|_| invalid("invalid or oversized PNG"))?;
let (w, h) = (image.width, image.height);
let resource = cx.allocate(ImageResource(image))?;
attach(cx, resource, desktop)?;
cx.set_attr(resource, "width", n(w as f32))?;
cx.set_attr(resource, "height", n(h as f32))?;
Value::Object(resource)
}
LoadFont => {
if cx.store().is_read_only() {
return Err(invalid("load resources outside paint/layout"));
}
let family = text(&args[1])?;
if family.len() > 128 {
return Err(invalid("font family name too long"));
}
let font = crate::text::register_font(file(text(&args[0])?, 4_194_304)?, family)
.map_err(|_| invalid("invalid font family or registry budget"))?;
let resource = cx.allocate(FontResource(font))?;
attach(cx, resource, desktop)?;
Value::Object(resource)
}
ReadText => {
if cx.store().is_read_only() {
return Err(invalid("load resources outside paint/layout"));
}
Value::Text(
String::from_utf8(file(text(&args[0])?, 16_384)?)
.map_err(|_| invalid("resource is not UTF-8"))?,
)
}
Slice => {
let value = text(&args[0])?;
let start = number(&args[1])?;
let end = number(&args[2])?;
if start < 0. || end < start || start.fract() != 0. || end.fract() != 0. {
return Err(invalid("invalid text slice"));
}
let lo = value
.char_indices()
.nth(start as usize)
.map_or(value.len(), |(i, _)| i);
let hi = value
.char_indices()
.nth(end as usize)
.map_or(value.len(), |(i, _)| i);
Value::Text(value[lo..hi].into())
}
Stringify => Value::Text(match &args[0] {
Value::Text(s) => s.clone(),
Value::Number(v) => v.to_string(),
Value::Bool(v) => v.to_string(),
Value::Nil => "nil".into(),
_ => return Err(invalid("string expects scalar value")),
}),
Prepare => {
let value = text(&args[0])?;
if value.len() > 16_384 {
return Err(invalid("prepared text exceeds 16 KiB"));
}
let family = cx
.store()
.payload::<FontResource>(object(&args[1])?, "FontResource")?
.0;
let size = number(&args[2])?;
let line = number(&args[3])?;
let scale = number(&args[5])?;
if !(8. ..=96.).contains(&size)
|| !(size..=192.).contains(&line)
|| !(0.5..=4.).contains(&scale)
{
return Err(invalid("invalid text metrics"));
}
let color =
parse_color(text(&args[4])?).map_err(|_| invalid("invalid text color"))?;
let prepared = Prepared::new(
value,
TextStyle {
font_size: size,
line_height: line,
family,
},
scale,
color,
);
let prepared = prepared.map_err(invalid)?;
let resource = cx.allocate(TextResource {
prepared: Arc::new(prepared),
line,
})?;
attach(cx, resource, desktop)?;
Value::Object(resource)
}
Sprite => {
let image = object(&args[0])?;
cx.store()
.payload::<ImageResource>(image, "ImageResource")?;
let x = number(&args[1])?;
let y = number(&args[2])?;
let w = number(&args[3])?;
let h = number(&args[4])?;
let a = number(&args[5])?;
let ax = number(&args[6])?;
let ay = number(&args[7])?;
let opacity = number(&args[8])?;
let c = command(
cx,
desktop,
image,
Point::new(x, y),
Rect::new(-w * ax, -h * ay, w, h),
a,
opacity,
)?;
decode_image(cx.store(), c)?;
Value::Object(c)
}
ImageExclusion => {
let RenderCommand::Image {
x,
y,
width,
height,
offset_x,
offset_y,
angle,
image,
..
} = decode_image(cx.store(), object(&args[0])?)?
else {
unreachable!()
};
let padding = number(&args[1])?;
if !(0. ..=1000.).contains(&padding) {
return Err(invalid("invalid exclusion padding"));
}
let (sin, cos) = angle.sin_cos();
let polygon = image
.outline
.iter()
.map(|p| {
let px = offset_x + p.x / image.width as f32 * width;
let py = offset_y + p.y / image.height as f32 * height;
Point::new(x + cos * px - sin * py, y + sin * px + cos * py)
})
.collect();
let e = cx.allocate(Exclusion {
circle: None,
polygon,
padding,
})?;
attach(cx, e, desktop)?;
Value::Object(e)
}
Circle | Rectangle => {
let x = number(&args[0])?;
let y = number(&args[1])?;
let size = number(&args[2])?;
if size < 0. {
return Err(invalid("negative exclusion extent"));
}
let e = if matches!(self.op, Circle) {
Exclusion {
circle: Some((Point::new(x, y), size)),
polygon: vec![],
padding: 0.,
}
} else {
let h = number(&args[3])?;
if h < 0. {
return Err(invalid("negative exclusion height"));
}
Exclusion {
circle: None,
polygon: vec![
Point::new(x, y),
Point::new(x + size, y),
Point::new(x + size, y + h),
Point::new(x, y + h),
],
padding: 0.,
}
};
let e = cx.allocate(e)?;
attach(cx, e, desktop)?;
Value::Object(e)
}
Flow => flow(cx, desktop, args)?,
_ => {
let real = |v: &Value| -> Result<f64, VmError> {
if let Value::Number(n) = v {
n.to_f64()
} else {
Err(invalid("math expects Number"))
}
};
let x = real(&args[0])?;
if matches!(self.op, Floor) {
if x.abs() > 1_000_000_000. {
return Err(invalid("floor result exceeds integer conversion budget"));
}
return Ok(NativeResult::Return(Value::Number(
(x.floor() as i64).into(),
)));
}
let v = match self.op {
Float => x,
Sin => x.sin(),
Cos => x.cos(),
Sqrt => x.sqrt(),
Atan2 => x.atan2(real(&args[1])?),
Abs => x.abs(),
Min => x.min(real(&args[1])?),
Max => x.max(real(&args[1])?),
Clamp => {
let lo = real(&args[1])?;
let hi = real(&args[2])?;
if lo > hi {
return Err(invalid("clamp minimum exceeds maximum"));
}
x.clamp(lo, hi)
}
_ => unreachable!(),
};
if !v.is_finite() {
return Err(invalid("non-finite math result"));
}
Value::Number(v.into())
}
};
Ok(NativeResult::Return(result))
}
}
fn ranges(left: f32, right: f32, y: f32, line: f32, exclusions: &[&Exclusion]) -> Vec<(f32, f32)> {
let mut blocked = Vec::new();
for e in exclusions {
if let Some((p, r)) = e.circle {
let gap = (y - p.y).max(p.y - y - line).max(0.);
if gap < r {
let dx = (r * r - gap * gap).sqrt();
blocked.push((p.x - dx, p.x + dx));
}
}
let (top, bottom) = (y - e.padding, y + line + e.padding);
let mut xs = Vec::new();
for i in 0..e.polygon.len() {
let a = e.polygon[i];
let b = e.polygon[(i + 1) % e.polygon.len()];
if a.y >= top && a.y <= bottom {
xs.push(a.x);
}
for edge in [top, bottom] {
if (a.y < edge && b.y > edge) || (b.y < edge && a.y > edge) {
xs.push(a.x + (b.x - a.x) * (edge - a.y) / (b.y - a.y));
}
}
}
if !xs.is_empty() {
blocked.push((
xs.iter().copied().fold(f32::INFINITY, f32::min) - e.padding,
xs.iter().copied().fold(f32::NEG_INFINITY, f32::max) + e.padding,
));
}
}
blocked.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut x = left;
let mut ranges = Vec::new();
for (lo, hi) in blocked {
if hi <= left || lo >= right {
continue;
}
if lo > x {
ranges.push((x, lo.min(right)));
}
x = x.max(hi).min(right);
}
if x < right {
ranges.push((x, right));
}
ranges.into_iter().filter(|(l, r)| r - l >= 8.).collect()
}
fn flow(cx: &mut NativeContext<'_>, desktop: ObjectRef, args: &[Value]) -> Result<Value, VmError> {
let resource = object(&args[0])?;
let text = cx
.store()
.payload::<TextResource>(resource, "PreparedText")?;
let prepared = text.prepared.clone();
let line = text.line;
let x = number(&args[1])?;
let y = number(&args[2])?;
let width = number(&args[3])?;
let height = number(&args[4])?;
let letters = number(&args[6])? != 0.;
if width < 0. || height < 0. || height / line > 1024. {
return Err(invalid("text flow bounds exceed budget"));
}
let items = crate::object_list::items(cx.store(), object(&args[5])?)?;
if items.len() > 512 {
return Err(invalid("text flow exceeds 512 exclusions"));
}
let exclusions = items
.iter()
.map(|&o| cx.store().payload::<Exclusion>(o, "Exclusion"))
.collect::<Result<Vec<_>, _>>()?;
let mut placements = Vec::new();
let mut cursor = Cursor::default();
let mut top = y;
while top + line <= y + height && cursor.word < prepared.words.len() {
let intervals = ranges(x, x + width, top, line, &exclusions);
prepared.row(&mut cursor, &intervals, top, &mut placements);
top += line;
}
let mut commands = Vec::new();
for p in placements {
let word = &prepared.words[p.word];
let inks = if let Some(i) = p.letter {
std::slice::from_ref(&word.letters[i])
} else if letters {
&word.letters[..]
} else {
std::slice::from_ref(&word.ink)
};
let mut px = p.x;
for ink in inks {
let image = cx.allocate(ImageResource(ink.image.clone()))?;
attach(cx, image, desktop)?;
commands.push(command(
cx,
desktop,
image,
Point::new(px, p.y),
ink.rect,
0.,
1.,
)?);
px += ink.width;
if commands.len() > 10_000 {
return Err(invalid("text flow command budget exceeded"));
}
}
}
let commands = crate::object_list::collection(cx, commands, desktop)?;
Ok(Value::Object(crate::native_visual::record(
cx,
desktop,
"DisplayList",
vec![("commands", Value::Object(commands))],
)?))
}
pub(crate) fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
ui: ObjectRef,
desktop: ObjectRef,
) -> Result<(), VmError> {
let graphics = vm.new_object()?;
roots.push(graphics);
attach_vm(vm, graphics, "Object", desktop)?;
let math = vm.new_object()?;
roots.push(math);
attach_vm(vm, math, "Object", desktop)?;
for (owner, name, op) in [
(graphics, "loadImage", Op::LoadImage),
(graphics, "loadFont", Op::LoadFont),
(graphics, "readText", Op::ReadText),
(graphics, "textSlice", Op::Slice),
(graphics, "string", Op::Stringify),
(math, "floor", Op::Floor),
(math, "now", Op::Now),
(graphics, "prepareText", Op::Prepare),
(graphics, "sprite", Op::Sprite),
(graphics, "imageExclusion", Op::ImageExclusion),
(graphics, "circleExclusion", Op::Circle),
(graphics, "rectExclusion", Op::Rectangle),
(graphics, "flowText", Op::Flow),
(math, "float", Op::Float),
(math, "sin", Op::Sin),
(math, "cos", Op::Cos),
(math, "sqrt", Op::Sqrt),
(math, "atan2", Op::Atan2),
(math, "abs", Op::Abs),
(math, "min", Op::Min),
(math, "max", Op::Max),
(math, "clamp", Op::Clamp),
] {
let f = vm.allocate(ContextNativeFunction::new(
name,
Function { op, desktop },
vec![desktop],
))?;
roots.push(f);
attach_vm(vm, f, "Function", desktop)?;
vm.set_attr(owner, name, Value::Object(f))?;
}
vm.set_attr(math, "pi", Value::Number(std::f64::consts::PI.into()))?;
vm.set_attr(graphics, "math", Value::Object(math))?;
vm.set_attr(ui, "graphics", Value::Object(graphics))?;
Ok(())
}
fn attach_vm(vm: &mut Vm, o: ObjectRef, name: &str, desktop: ObjectRef) -> Result<(), VmError> {
let t = crate::ui_bridge::desktop_type(vm.object_store(), name, desktop)?;
vm.attach_type(o, t)
}
#[derive(Debug)]
pub(crate) struct PointerDispatch {
pub owner: ObjectRef,
pub mount: ObjectRef,
pub desktop: ObjectRef,
pub kind: String,
pub point: Point,
}
impl NativeCallable for PointerDispatch {
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
let data = cx
.store()
.payload::<crate::mount::Mount>(self.mount, "Mount")?;
let g = &data.geometry;
if !data.mounted || !g.pointer_events {
return Err(invalid(
"pointer target is detached or has no pointer subscriptions",
));
}
let local = g
.view_point(self.point, false)
.map(|p| g.view.unwrap_or_default().inverse(p))
.ok_or(invalid("pointer cannot be projected"))?;
let (x, y) = (local.x - g.x, local.y - g.y);
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
let event = crate::event_dispatch::new_event(cx, definition, &self.kind)?;
cx.set_attr(event, "x", n(x))?;
cx.set_attr(event, "y", n(y))?;
crate::event_dispatch::dispatch(cx, self.owner, event, Some(self.mount), self.desktop)
}
}
+2
View File
@@ -2,3 +2,5 @@
//!
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
pub(crate) mod ui_bridge;
pub(crate) mod graphics;
+27
View File
@@ -1451,6 +1451,33 @@ impl UiBridge {
if !data.geometry.interactive {
bail!("Mount does not accept input");
}
if input.kind.starts_with("pointer") {
let p = input
.point
.ok_or_else(|| anyhow::anyhow!("pointer needs point"))?;
let f = vm.allocate(ContextNativeFunction::new(
"native pointer event",
crate::bridge::graphics::PointerDispatch {
owner: data.block,
mount,
desktop,
kind: input.kind.clone(),
point: crate::geometry::Point::new(p[0], p[1]),
},
vec![data.block, mount, desktop],
))?;
let result = vm.call_with_limits(
f,
&[],
endor_core::ExecutionLimits {
max_frames: 128,
max_instructions: 1_000_000,
},
);
vm.release(f)?;
vm.release_value(result?)?;
return Ok(());
}
vm.set_attr(
desktop,
"focus",
+1
View File
@@ -443,6 +443,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
crate::protocols::install(vm, roots, desktop, ui)?;
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)?;
+2
View File
@@ -26,6 +26,7 @@ pub struct UiNode {
// slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。
pub slots: Vec<u64>,
pub interactive: bool,
pub pointer_events: bool,
pub kind: UiKind,
}
@@ -81,6 +82,7 @@ impl UiNode {
|| self.protocol_key != other.protocol_key
|| self.slots != other.slots
|| self.interactive != other.interactive
|| self.pointer_events != other.pointer_events
{
return false;
}
+57
View File
@@ -474,6 +474,8 @@ pub struct MountGeometry {
pub width: f32,
pub height: f32,
pub interactive: bool,
#[serde(default)]
pub pointer_events: bool,
}
impl MountGeometry {
@@ -560,6 +562,19 @@ fn default_background() -> String {
#[serde(tag = "type", rename_all = "snake_case")]
/// 可校验、可序列化的逻辑绘制命令;转换到设备像素在 display_list 阶段完成。
pub enum RenderCommand {
#[serde(skip_deserializing)]
Image {
x: f32,
y: f32,
width: f32,
height: f32,
offset_x: f32,
offset_y: f32,
angle: f32,
opacity: f32,
tint: Option<Color>,
image: Arc<crate::render::image::Image>,
},
Scene3D {
x: f32,
y: f32,
@@ -603,6 +618,29 @@ impl RenderCommand {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub(crate) fn validate(&self) -> Result<()> {
match self {
Self::Image {
x,
y,
width,
height,
offset_x,
offset_y,
angle,
opacity,
image,
..
} => {
validate_rect(*x, *y, *width, *height, 0.)?;
if ![offset_x, offset_y, angle, opacity]
.iter()
.all(|v| v.is_finite())
|| !(0. ..=1.).contains(opacity)
|| image.width == 0
|| image.height == 0
{
bail!("invalid image command");
}
}
Self::Scene3D {
x,
y,
@@ -676,6 +714,25 @@ impl RenderCommand {
/// 把一条已验证的发布命令变成原生绘制原语,缩放几何和文字样式。
fn to_draw_command(&self, scale: f32) -> Result<DrawCommand> {
Ok(match self {
Self::Image {
x,
y,
width,
height,
offset_x,
offset_y,
angle,
opacity,
tint,
image,
} => DrawCommand::Image {
image: image.clone(),
origin: Point::new(x * scale, y * scale),
rect: scaled_rect(*offset_x, *offset_y, *width, *height, scale),
angle: *angle,
opacity: *opacity,
tint: *tint,
},
Self::Scene3D {
x,
y,
+92 -4
View File
@@ -70,6 +70,72 @@ impl NativeCallable for At {
}
}
#[derive(Debug)]
struct Edit {
owner: ObjectRef,
desktop: ObjectRef,
remove: bool,
persistent: bool,
extend: bool,
}
impl NativeCallable for Edit {
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let mut members = items(cx.store(), self.owner)?.to_vec();
if self.extend {
let [Value::Object(other)] = args else {
return Err(VmError::InvalidArgument("extend expects ObjectList"));
};
let incoming = items(cx.store(), *other)?;
if members.len() + incoming.len() > 10_000 {
return Err(VmError::InvalidArgument(
"object list exceeds 10000 members",
));
}
members.extend_from_slice(incoming);
} else if self.remove {
let [Value::Number(index)] = args else {
return Err(VmError::InvalidArgument("remove expects index"));
};
let index = index
.as_i64()
.and_then(|v| usize::try_from(v).ok())
.filter(|&i| i < members.len())
.ok_or(VmError::InvalidArgument("list index out of range"))?;
members.remove(index);
} else {
let [Value::Object(item)] = args else {
return Err(VmError::InvalidArgument("append expects object"));
};
if members.len() >= 10_000 {
return Err(VmError::InvalidArgument(
"object list exceeds 10000 members",
));
}
members.push(*item);
}
if self.persistent {
return Ok(NativeResult::Return(Value::Object(collection(
cx,
members,
self.desktop,
)?)));
}
let count = members.len();
let first = members
.first()
.copied()
.map(Value::Object)
.unwrap_or(Value::Nil);
cx.replace_payload(self.owner, ObjectList(members))?;
cx.set_attr(self.owner, "count", Value::Number((count as i64).into()))?;
cx.set_attr(self.owner, "first", first)?;
Ok(NativeResult::Return(Value::Nil))
}
}
/// 将已有对象引用包装为新的 ObjectList,并安装默认名义类型及下标查询方法。
pub fn collection(
cx: &mut NativeContext<'_>,
@@ -108,6 +174,26 @@ fn build(
let at = cx.allocate(ContextNativeFunction::new("at", At(set), vec![set]))?;
crate::ui_bridge::attach_desktop_type(cx, at, "Function", desktop)?;
cx.set_attr(set, "at", Value::Object(at))?;
for (name, remove, persistent, extend) in [
("append", false, false, false),
("remove", true, false, false),
("added", false, true, false),
("extend", false, false, true),
] {
let method = cx.allocate(ContextNativeFunction::new(
name,
Edit {
owner: set,
desktop,
remove,
persistent,
extend,
},
vec![set, desktop],
))?;
crate::ui_bridge::attach_desktop_type(cx, method, "Function", desktop)?;
cx.set_attr(set, name, Value::Object(method))?;
}
Ok(set)
}
@@ -142,10 +228,12 @@ impl NativeCallable for Constructor {
.payload::<endor_core::EndorTypeObject>(*definition, "Type")?
.fields()
.to_vec();
if fields
.iter()
.any(|field| matches!(field.name.as_str(), "count" | "first" | "at"))
{
if fields.iter().any(|field| {
matches!(
field.name.as_str(),
"count" | "first" | "at" | "append" | "remove" | "added" | "extend"
)
}) {
return Err(VmError::InvalidArgument("reserved ObjectList field"));
}
let count = values
+1
View File
@@ -203,6 +203,7 @@ mod tests {
protocol_key: "rect".into(),
slots: Vec::new(),
interactive: false,
pointer_events: false,
kind: UiKind::Rect {
width: 500.0,
height: 500.0,
+8 -1
View File
@@ -223,6 +223,11 @@ impl RenderCache {
continue;
}
path.remove(&data_ref);
let pointer_connection = data.subscriptions.iter().any(|&o| {
store
.payload::<crate::event_dispatch::Connection>(o, "Subscription")
.is_ok_and(|s| s.handler.is_some() && s.kind.starts_with("pointer"))
});
let mut protocol_key = String::new();
let mut custom_event = false;
if let Some(definition) = store.type_of(block)? {
@@ -402,7 +407,9 @@ impl RenderCache {
block_id: block.to_string(),
protocol_key,
slots: data.children.iter().map(|c| c.slot).collect(),
interactive: matches!(&data.kind, Kind::Button | Kind::Viewport3D { .. })
pointer_events: pointer_connection,
interactive: pointer_connection
|| matches!(&data.kind, Kind::Button | Kind::Viewport3D { .. })
|| data.click.is_some()
|| click_connection
|| custom_event,
+21 -3
View File
@@ -4,7 +4,7 @@
use crate::geometry::{Point, Rect};
use crate::text::TextStyle;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
/// 设备绘制使用的 RGBA 字节通道,区别于源代码的颜色文本。
pub struct Color {
pub red: u8,
@@ -38,6 +38,15 @@ impl Color {
#[derive(Clone, Debug)]
/// 原生画布最终顺序执行的绘制原语,已经使用对应设备比例。
pub enum DrawCommand {
/// Immutable RGBA image mapped from its local rectangle by rotation.
Image {
image: std::sync::Arc<crate::render::image::Image>,
origin: Point,
rect: Rect,
angle: f32,
opacity: f32,
tint: Option<Color>,
},
Scene3D {
rect: Rect,
view: std::sync::Arc<crate::scene3d::View>,
@@ -82,7 +91,7 @@ impl DrawCommand {
rect.x += delta.x;
rect.y += delta.y;
}
Self::Text { origin, .. } => {
Self::Text { origin, .. } | Self::Image { origin, .. } => {
origin.x += delta.x;
origin.y += delta.y;
}
@@ -123,6 +132,15 @@ impl DisplayList {
*width *= scale;
Some(rect)
}
DrawCommand::Image { origin, rect, .. } => {
origin.x *= scale;
origin.y *= scale;
rect.x *= scale;
rect.y *= scale;
rect.width *= scale;
rect.height *= scale;
None
}
DrawCommand::Text { origin, style, .. } => {
origin.x *= scale;
origin.y *= scale;
@@ -156,7 +174,7 @@ impl DisplayList {
rect.x += delta.x;
rect.y += delta.y;
}
DrawCommand::Text { origin, .. } => {
DrawCommand::Text { origin, .. } | DrawCommand::Image { origin, .. } => {
origin.x += delta.x;
origin.y += delta.y;
}
+193
View File
@@ -0,0 +1,193 @@
//! Immutable, bounded RGBA images shared by display lists. Decoding and text
//! preparation happen outside the frame loop; the CPU backend samples them.
use crate::{
display_list::Color,
geometry::Rect,
text::{self, TextStyle},
};
use std::{io::Cursor, sync::Arc};
#[derive(Debug, serde::Serialize)]
pub struct Image {
pub width: u32,
pub height: u32,
pub id: u64,
#[serde(skip)]
pixels: Vec<u8>,
#[serde(skip)]
pub outline: Vec<crate::geometry::Point>,
}
impl Image {
pub fn png(bytes: &[u8]) -> anyhow::Result<Arc<Self>> {
let mut decoder = png::Decoder::new(Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let info = reader.info();
anyhow::ensure!(
info.width > 0
&& info.height > 0
&& u64::from(info.width) * u64::from(info.height) <= 4_194_304,
"image exceeds pixel budget"
);
let mut buffer = vec![
0;
reader
.output_buffer_size()
.ok_or_else(|| anyhow::anyhow!("image size overflow"))?
];
let info = reader.next_frame(&mut buffer)?;
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for chunk in buffer[..info.buffer_size()].chunks_exact(info.color_type.samples()) {
let rgba = match info.color_type {
png::ColorType::Rgba => [chunk[0], chunk[1], chunk[2], chunk[3]],
png::ColorType::Rgb => [chunk[0], chunk[1], chunk[2], 255],
png::ColorType::Grayscale => [chunk[0], chunk[0], chunk[0], 255],
png::ColorType::GrayscaleAlpha => [chunk[0], chunk[0], chunk[0], chunk[1]],
png::ColorType::Indexed => anyhow::bail!("unexpanded indexed PNG"),
};
pixels.extend_from_slice(&rgba);
}
let outline = alpha_hull(&pixels, info.width, info.height);
Ok(Arc::new(Self {
width: info.width,
height: info.height,
pixels,
id: next_id(),
outline,
}))
}
/// Cache a shaped word at the actual device resolution. Padding preserves
/// italic/serif overhangs. Returns its logical image rectangle and advance.
pub fn text(
value: &str,
style: TextStyle,
scale: f32,
color: Color,
) -> Result<(Arc<Self>, Rect, f32), &'static str> {
let size = text::measure(value, style);
let pad = (style.font_size * 0.3).ceil();
let width = ((size.width + pad * 2.) * scale).ceil().max(1.) as u32;
let height = ((size.height + pad * 2.) * scale).ceil().max(1.) as u32;
if u64::from(width) * u64::from(height) > 1_048_576 {
return Err("prepared text image exceeds pixel budget");
}
let mut pixels = vec![0; width as usize * height as usize * 4];
text::draw_clipped(
value,
style.scaled(scale),
color,
None,
Rect::new(-pad * scale, -pad * scale, width as f32, height as f32),
|x, y, c| {
let x = x + (pad * scale).round() as i32;
let y = y + (pad * scale).round() as i32;
if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 {
return;
}
let i = (y as usize * width as usize + x as usize) * 4;
// All glyphs have the same ink color; alpha-over preserves overlaps.
pixels[i..i + 3].copy_from_slice(&[c.red, c.green, c.blue]);
pixels[i + 3] = (u16::from(c.alpha)
+ u16::from(pixels[i + 3]) * (255 - u16::from(c.alpha)) / 255)
as u8;
},
);
Ok((
Arc::new(Self {
width,
height,
pixels,
id: next_id(),
outline: vec![],
}),
Rect::new(-pad, -pad, width as f32 / scale, height as f32 / scale),
size.width,
))
}
pub fn sample(&self, u: f32, v: f32, opacity: f32) -> Color {
let x = u * self.width as f32 - 0.5;
let y = v * self.height as f32 - 0.5;
let (ix, iy) = (x.floor() as i32, y.floor() as i32);
let (fx, fy) = (x - x.floor(), y - y.floor());
let mut sum = [0.; 4];
for (dx, dy, w) in [
(0, 0, (1. - fx) * (1. - fy)),
(1, 0, fx * (1. - fy)),
(0, 1, (1. - fx) * fy),
(1, 1, fx * fy),
] {
let px = (ix + dx).clamp(0, self.width as i32 - 1) as usize;
let py = (iy + dy).clamp(0, self.height as i32 - 1) as usize;
let i = (py * self.width as usize + px) * 4;
let a = f32::from(self.pixels[i + 3]) * w;
for (j, s) in sum.iter_mut().enumerate().take(3) {
*s += f32::from(self.pixels[i + j]) * a;
}
sum[3] += a;
}
if sum[3] <= 0. {
return Color::rgba(0, 0, 0, 0);
}
Color::rgba(
(sum[0] / sum[3]).round() as u8,
(sum[1] / sum[3]).round() as u8,
(sum[2] / sum[3]).round() as u8,
(sum[3] * opacity).round() as u8,
)
}
}
/// Conservative convex silhouette from opaque row endpoints. Unlike an image's
/// rectangle, this excludes empty transparent margins. Generated once per PNG.
fn alpha_hull(pixels: &[u8], width: u32, height: u32) -> Vec<crate::geometry::Point> {
use crate::geometry::Point;
let mut points = Vec::new();
for y in 0..height {
let xs: Vec<_> = (0..width)
.filter(|&x| pixels[((y * width + x) * 4 + 3) as usize] >= 16)
.collect();
if let (Some(&left), Some(&right)) = (xs.first(), xs.last()) {
for x in [left as f32, right as f32 + 1.] {
for yy in [y as f32, y as f32 + 1.] {
points.push(Point::new(x, yy));
}
}
}
}
points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
points.dedup();
if points.len() < 3 {
return points;
}
let cross =
|a: Point, b: Point, c: Point| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
let mut hull: Vec<Point> = Vec::new();
for &p in &points {
while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
hull.pop();
}
hull.push(p);
}
let lower = hull.len();
for &p in points.iter().rev().skip(1) {
while hull.len() > lower && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0. {
hull.pop();
}
hull.push(p);
}
hull.pop();
hull
}
fn next_id() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl PartialEq for Image {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
+1
View File
@@ -1104,6 +1104,7 @@ impl LayoutRuntime {
width: size.width,
height: size.height,
interactive: item.node.interactive,
pointer_events: item.node.pointer_events,
});
if !structural {
commands.extend(paint_commands(item.node, x, y, size));
+3
View File
@@ -16,3 +16,6 @@ pub(crate) mod source_layout;
pub(crate) mod source_paint;
pub(crate) mod text;
pub(crate) mod viewport;
pub mod image;
pub mod text_flow;
+62
View File
@@ -69,6 +69,16 @@ impl SoftwareRenderer {
for command in list.commands() {
match command {
DrawCommand::Image {
image,
origin,
rect,
angle,
opacity,
tint,
} => {
canvas.draw_image(image, *origin, *rect, *angle, *opacity, *tint);
}
DrawCommand::Scene3D { rect, view } => {
let left = rect.x.max(canvas.clip.x).max(0.).floor() as u32;
let top = rect.y.max(canvas.clip.y).max(0.).floor() as u32;
@@ -163,6 +173,58 @@ struct Canvas<'a> {
}
impl Canvas<'_> {
/// Inverse-map only clipped destination pixels. Images use straight RGBA;
/// bilinear interpolation happens in premultiplied alpha to avoid dark halos.
fn draw_image(
&mut self,
image: &crate::render::image::Image,
origin: Point,
rect: Rect,
angle: f32,
opacity: f32,
tint: Option<Color>,
) {
if rect.width <= 0. || rect.height <= 0. || !angle.is_finite() || !opacity.is_finite() {
return;
}
let (sin, cos) = angle.sin_cos();
let corners = [
(rect.x, rect.y),
(rect.x + rect.width, rect.y),
(rect.x, rect.y + rect.height),
(rect.x + rect.width, rect.y + rect.height),
];
let mut lo = Point::new(f32::INFINITY, f32::INFINITY);
let mut hi = Point::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
for (x, y) in corners {
let px = origin.x + cos * x - sin * y;
let py = origin.y + sin * x + cos * y;
lo.x = lo.x.min(px);
lo.y = lo.y.min(py);
hi.x = hi.x.max(px);
hi.y = hi.y.max(py);
}
let (left, top, right, bottom) =
self.clamped_bounds(Rect::new(lo.x, lo.y, hi.x - lo.x, hi.y - lo.y));
for y in top..bottom {
for x in left..right {
let dx = x as f32 + 0.5 - origin.x;
let dy = y as f32 + 0.5 - origin.y;
let u = (cos * dx + sin * dy - rect.x) / rect.width;
let v = (-sin * dx + cos * dy - rect.y) / rect.height;
if (0. ..1.).contains(&u) && (0. ..1.).contains(&v) {
let mut sample = image.sample(u, v, opacity.clamp(0., 1.));
if let Some(tint) = tint {
sample.red = tint.red;
sample.green = tint.green;
sample.blue = tint.blue;
}
self.blend_pixel(x, y, sample);
}
}
}
}
/// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。
fn clear(&mut self, color: Color) {
self.pixels.fill(pack(color));
+2 -1
View File
@@ -79,12 +79,13 @@ fn decode(
let x = number(store, command, "x")?;
let y = number(store, command, "y")?;
let kind = text(store, command, "kind")?;
let color = if kind == "scene3d" {
let color = if matches!(kind.as_str(), "scene3d" | "image") {
String::new()
} else {
text(store, command, "color")?
};
let result = match kind.as_str() {
"image" => crate::bridge::graphics::decode_image(store, command)?,
"scene3d" => {
let snapshot = object(store.get_attr(command, "scene")?)?;
RenderCommand::Scene3D {
+36
View File
@@ -17,6 +17,8 @@ use crate::display_list::Color;
pub enum TextFamily {
Interface,
Code,
/// A registered family loaded through the generic font-resource API.
Custom(u32),
}
#[derive(Clone, Copy, Debug)]
@@ -82,6 +84,7 @@ struct PreparedText {
/// 共享字体系统、字形缓存和整形缓存;调用通过全局锁串行访问。
struct TextEngine {
fonts: FontSystem,
custom: Vec<(String, u64)>,
code_family: Option<String>,
swash: SwashCache,
buffers: HashMap<TextKey, PreparedText>,
@@ -121,6 +124,7 @@ impl TextEngine {
}
Self {
fonts,
custom: Vec::new(),
code_family: mono,
swash: SwashCache::new(),
buffers: HashMap::new(),
@@ -152,6 +156,7 @@ impl TextEngine {
}
let family = match style.family {
TextFamily::Interface => Family::SansSerif,
TextFamily::Custom(id) => Family::Name(&self.custom[id as usize].0),
// Keep Latin source on the selected code face, and allow ordinary
// script fallback for CJK instead of searching all monospace faces.
TextFamily::Code => self
@@ -365,6 +370,37 @@ pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
format!("{}{ellipsis}", &value[..end])
}
/// Register an explicit face for graphics resources. Repeated identical loads
/// reuse the family; conflicting content and unbounded global growth fail.
pub fn register_font(bytes: Vec<u8>, family: &str) -> anyhow::Result<TextFamily> {
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hash);
let hash = hash.finish();
let mut e = engine().lock().expect("text engine lock");
if let Some((id, (_, old))) = e.custom.iter().enumerate().find(|(_, v)| v.0 == family) {
anyhow::ensure!(*old == hash, "font family already has different content");
return Ok(TextFamily::Custom(id as u32));
}
anyhow::ensure!(e.custom.len() < 32, "font registry exceeds 32 families");
let previous = e.fonts.db().clone();
e.fonts.db_mut().load_font_data(bytes);
if !e
.fonts
.db()
.faces()
.any(|f| f.families.iter().any(|(n, _)| n == family))
{
*e.fonts.db_mut() = previous;
anyhow::bail!("font data does not contain requested family");
}
let id = e.custom.len() as u32;
e.custom.push((family.into(), hash));
e.buffers.clear();
e.source_bytes = 0;
Ok(TextFamily::Custom(id))
}
#[cfg(test)]
mod tests {
use super::*;
+142
View File
@@ -0,0 +1,142 @@
//! Width-independent word preparation and variable-width line filling. The
//! exclusion algorithm owns geometry; this module only consumes text in order.
use crate::{display_list::Color, geometry::Rect, render::image::Image, text::TextStyle};
use std::{collections::HashMap, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct Ink {
pub image: Arc<Image>,
pub rect: Rect,
pub width: f32,
}
#[derive(Debug)]
pub struct Word {
pub ink: Arc<Ink>,
pub letters: Vec<Arc<Ink>>,
pub space: f32,
pub newline: bool,
}
#[derive(Debug)]
pub struct Prepared {
pub words: Vec<Word>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Cursor {
pub word: usize,
pub letter: usize,
}
pub struct Placement {
pub word: usize,
pub letter: Option<usize>,
pub x: f32,
pub y: f32,
}
impl Prepared {
pub fn new(
value: &str,
style: TextStyle,
scale: f32,
color: Color,
) -> Result<Self, &'static str> {
let mut cache: HashMap<String, Arc<Ink>> = HashMap::new();
let mut pixels = 0_u64;
let mut ink = |text: &str| -> Result<Arc<Ink>, &'static str> {
if let Some(ink) = cache.get(text) {
return Ok(ink.clone());
}
let (image, rect, width) = Image::text(text, style, scale, color)?;
pixels += u64::from(image.width) * u64::from(image.height);
if pixels > 8_388_608 {
return Err("prepared text exceeds 8M pixel cache budget");
}
let ink = Arc::new(Ink { image, rect, width });
cache.insert(text.to_owned(), ink.clone());
Ok(ink)
};
let mut words = Vec::new();
for para in value.split_inclusive('\n') {
let mut start = 0;
for (end, _) in unicode_linebreak::linebreaks(para) {
let part = &para[start..end];
let body = part.trim_end_matches(char::is_whitespace);
let space =
crate::text::measure(part[body.len()..].trim_matches(['\n', '\r']), style)
.width;
words.push(Word {
ink: ink(body)?,
letters: body
.graphemes(true)
.map(&mut ink)
.collect::<Result<Vec<_>, _>>()?,
space,
newline: part.ends_with('\n'),
});
start = end;
}
}
Ok(Self { words })
}
/// The same cursor crosses left/right fragments. Narrow gaps never discard
/// a word; a word wider than every remaining fragment falls back to graphemes.
pub fn row(
&self,
cursor: &mut Cursor,
ranges: &[(f32, f32)],
y: f32,
out: &mut Vec<Placement>,
) {
for (ri, &(left, right)) in ranges.iter().enumerate() {
let mut x = left;
while let Some(word) = self.words.get(cursor.word) {
if word.letters.is_empty() {
cursor.word += 1;
cursor.letter = 0;
if word.newline {
return;
}
continue;
}
let remaining_max = ranges[ri..].iter().map(|(l, r)| r - l).fold(0., f32::max);
if cursor.letter == 0 && word.ink.width <= right - x + 0.001 {
out.push(Placement {
word: cursor.word,
letter: None,
x,
y,
});
x += word.ink.width + word.space;
cursor.word += 1;
if word.newline {
return;
}
} else if cursor.letter == 0 && word.ink.width <= remaining_max + 0.001 {
break;
} else {
let letter = &word.letters[cursor.letter];
if letter.width > right - x + 0.001 {
break;
}
out.push(Placement {
word: cursor.word,
letter: Some(cursor.letter),
x,
y,
});
x += letter.width;
cursor.letter += 1;
if cursor.letter == word.letters.len() {
cursor.word += 1;
cursor.letter = 0;
x += word.space;
if word.newline {
return;
}
}
}
}
}
}
}
+77 -4
View File
@@ -13,7 +13,7 @@ use std::{
ops::{Deref, DerefMut},
panic::{AssertUnwindSafe, catch_unwind},
sync::{
Arc, RwLock,
Arc, OnceLock, RwLock,
mpsc::{Receiver, SyncSender, TrySendError, sync_channel},
},
sync::{
@@ -51,8 +51,40 @@ pub struct RuntimeControl {
sender: SyncSender<()>,
requested: Arc<AtomicBool>,
completion: ShutdownCompletion,
native_sender: Arc<OnceLock<SyncSender<BuildRequest>>>,
namespace: Arc<RwLock<NamespaceState>>,
}
impl RuntimeControl {
/// Only the native input relay calls this blocking acknowledgement path.
/// Window threads still use a bounded, nonblocking Input sender.
pub(crate) fn dispatch_native(&self, input: crate::events::Input) -> Result<()> {
anyhow::ensure!(!self.is_exiting(), "application is exiting");
let (done, completed) = sync_channel(1);
let job = {
let state = self
.namespace
.read()
.map_err(|_| anyhow::anyhow!("namespace lock is poisoned"))?;
BuildRequest {
request: RequestReply::Native(done),
kind: RequestKind::Event,
source: String::new(),
namespace: state.name.clone(),
submission: state.submission,
native: Some(input),
}
};
self.native_sender
.get()
.ok_or_else(|| anyhow::anyhow!("native input queue is unavailable"))?
.send(job)
.map_err(|_| anyhow::anyhow!("native input worker stopped"))?;
completed
.recv()
.map_err(|_| anyhow::anyhow!("native input acknowledgement stopped"))?
.map_err(anyhow::Error::msg)
}
/// 只发出一次退出请求,唤醒 worker 执行清理,并通过完成句柄通知原生循环。
pub fn request_shutdown(&self) -> bool {
if self.requested.swap(true, Ordering::AcqRel) {
@@ -98,7 +130,8 @@ enum RequestKind {
/// 保留原 HTTP 请求与提交身份的执行工作,完成或失败后由 worker 回应。
struct BuildRequest {
request: ObservedRequest,
request: RequestReply,
native: Option<crate::events::Input>,
kind: RequestKind,
source: String,
namespace: String,
@@ -115,6 +148,8 @@ pub fn start(
sender,
requested: Arc::new(AtomicBool::new(false)),
completion: Arc::new((Mutex::new(None), Condvar::new())),
native_sender: Arc::new(OnceLock::new()),
namespace: namespace.clone(),
};
let shutdown = Shutdown {
receiver,
@@ -135,6 +170,7 @@ pub fn start(
activity.clone(),
notify,
Some(shutdown),
Some(control.native_sender.clone()),
)?;
Ok((activity, control))
}
@@ -153,6 +189,7 @@ fn start_server(
Arc::new(Mutex::new(ActivityLog::default())),
Arc::new(|| {}),
None,
None,
)
}
@@ -164,8 +201,14 @@ fn start_server_observed(
activity: Activity,
observer_notify: Notify,
shutdown: Option<Shutdown>,
native_port: Option<Arc<OnceLock<SyncSender<BuildRequest>>>>,
) -> Result<thread::JoinHandle<()>> {
let (sender, receiver) = sync_channel::<BuildRequest>(8);
if let Some(native_port) = native_port {
native_port
.set(sender.clone())
.map_err(|_| anyhow::anyhow!("native input queue already attached"))?;
}
let worker_namespace = namespace.clone();
let worker_notify = notify.clone();
let exiting = shutdown
@@ -240,7 +283,9 @@ fn start_server_observed(
Ok(job) => {
match worker.begin_request(&job, &worker_namespace, &worker_notify) {
RequestProgress::Pending(input) => {
if matches!(&input, PendingRequest::BeforeInput) {
if job.native.is_none()
&& matches!(&input, PendingRequest::BeforeInput)
{
eprintln!(
"Tessera worker: submission {} waits for active refresh",
job.submission
@@ -299,6 +344,33 @@ fn start_server_observed(
// Capture only allowlisted headers: credentials and cookies never enter the inspector.
/// HTTP 请求的观察包装,在读取正文和响应时维护 Network 工具记录。
/// HTTP and in-process native jobs share one serial execution/refresh path.
/// Native envelopes cannot be created by an HTTP header or request body.
enum RequestReply {
Http(ObservedRequest),
Native(SyncSender<std::result::Result<(), String>>),
}
impl RequestReply {
fn respond(self, response: Response<std::io::Cursor<Vec<u8>>>) -> std::io::Result<()> {
match self {
Self::Http(request) => request.respond(response),
Self::Native(done) => {
let status = response.status_code().0;
let result = if status == 200 || status == 409 {
Ok(())
} else {
Err(String::from_utf8_lossy(response.into_reader().get_ref())
.chars()
.take(2048)
.collect())
};
let _ = done.send(result);
Ok(())
}
}
}
}
struct ObservedRequest {
inner: Request,
activity: Activity,
@@ -572,7 +644,8 @@ fn submit(
}
};
let job = BuildRequest {
request,
request: RequestReply::Http(request),
native: None,
kind,
source,
namespace: addressed.into(),
+255
View File
@@ -845,3 +845,258 @@ fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
assert_eq!(value("canvas.camera.zoom"), 2.);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
#[test]
fn native_pointer_kinds_cross_real_http_route_with_local_coordinates_and_strict_versions() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 500.,
height: 400.,
}),
..Default::default()
}));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"
class Tracker {
x:Number
y:Number
held:Number
function move(self:Tracker,e:Event):Unit {self.x=e.x self.y=e.y}
function down(self:Tracker,e:Event):Unit {self.held=1}
function up(self:Tracker,e:Event):Unit {self.held=0}
}
tracker=Tracker(0,0,0)
target=RectBlock(200,100,0)
a=target.on("pointermove",tracker.move)
b=target.on("pointerdown",tracker.down)
c=target.on("pointerup",tracker.up)
d=target.on("pointerleave",tracker.up)
e=target.on("pointercancel",tracker.up)
page=Page()
page.render(Column(20,0,target))
"#;
let put = client.request("PUT", "/v1/namespaces/app.main/code", source);
assert_eq!(put.0, 200, "{}", put.2);
let input = |kind: &str| {
let current = namespace.read().unwrap();
let frame = &current.document.frame;
let mount = frame.mounts.iter().find(|m| m.pointer_events).unwrap();
crate::events::Input {
window: 1,
session: frame.session,
layout_version: frame.layout_version,
mount: mount.id,
kind: kind.into(),
point: Some([mount.x + 35., mount.y + 27.]),
press_point: None,
delta: None,
tab_drop: None,
screen_position: None,
}
};
let body = serde_json::to_string(&input("pointermove")).unwrap();
let response = client.request("POST", "/v1/namespaces/app.main/events", &body);
assert_eq!(response.0, 200, "{}", response.2);
// Tracker state alone need not publish a new picture. Expire the frame
// through a real visual mutation before asserting strict stale rejection.
assert_eq!(
client
.request("POST", "/v1/namespaces/app.main/repl", "target.width=201")
.0,
200
);
assert_eq!(
client
.request("POST", "/v1/namespaces/app.main/events", &body)
.0,
409
);
for (expr, expected) in [("tracker.x", "35"), ("tracker.y", "27")] {
let r = client.request("POST", "/v1/namespaces/app.main/repl", expr);
assert_eq!(r.0, 200);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&r.2).unwrap()["value"],
expected
);
}
for (kind, expected) in [
("pointerdown", "1"),
("pointerup", "0"),
("pointerdown", "1"),
("pointerleave", "0"),
("pointerdown", "1"),
("pointercancel", "0"),
] {
let response = client.request(
"POST",
"/v1/namespaces/app.main/events",
&serde_json::to_string(&input(kind)).unwrap(),
);
assert_eq!(response.0, 200, "{}", response.2);
let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&r.2).unwrap()["value"],
expected
);
}
let mut invalid = input("pointermove");
invalid.point = None;
assert_eq!(
client
.request(
"POST",
"/v1/namespaces/app.main/events",
&serde_json::to_string(&invalid).unwrap()
)
.0,
400
);
assert!(namespace.read().unwrap().document.frame.fault.is_none());
}
#[test]
fn native_envelopes_rebase_after_animation_but_reject_replaced_sessions() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState {
viewport: Some(crate::layout::Size {
width: 500.,
height: 400.,
}),
..Default::default()
}));
let native = Arc::new(OnceLock::new());
let api = start_server_observed(
server.clone(),
namespace.clone(),
Arc::new(|| {}),
Arc::new(Mutex::new(ActivityLog::default())),
Arc::new(|| {}),
None,
Some(native.clone()),
)
.unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let source = r#"
class Tracker {
x:Number
held:Number
function move(self:Tracker,e:Event):Unit {self.x=e.x}
function down(self:Tracker,e:Event):Unit {self.held=1}
function up(self:Tracker,e:Event):Unit {self.held=0}
}
tracker=Tracker(0,0)
target=RectBlock(200,100,0)
a=target.on("pointermove",tracker.move)
b=target.on("pointerdown",tracker.down)
c=target.on("pointerup",tracker.up)
page=Page()
page.render(Column(20,0,target))
"#;
assert_eq!(
client
.request("PUT", "/v1/namespaces/app.main/code", source)
.0,
200
);
let old = {
let state = namespace.read().unwrap();
let f = &state.document.frame;
let m = f.mounts.iter().find(|m| m.pointer_events).unwrap();
crate::events::Input {
window: 1,
session: f.session,
layout_version: f.layout_version,
mount: m.id,
kind: "pointermove".into(),
point: Some([m.x + 37., m.y + 20.]),
press_point: None,
delta: None,
tab_drop: None,
screen_position: None,
}
};
let send_native = |input: crate::events::Input| {
let state = namespace.read().unwrap();
let epoch = state.submission;
let name = state.name.clone();
drop(state);
let (done, complete) = sync_channel(1);
native
.get()
.unwrap()
.send(BuildRequest {
request: RequestReply::Native(done),
native: Some(input),
kind: RequestKind::Event,
source: String::new(),
namespace: name,
submission: epoch,
})
.unwrap_or_else(|_| panic!("native worker stopped"));
complete
.recv_timeout(Duration::from_secs(5))
.unwrap()
.unwrap();
};
// Every native gesture deliberately carries the old picture version; each
// intervening visual mutation simulates an animation publishing a frame.
for (i, kind) in ["pointermove", "pointerdown", "pointerup"]
.into_iter()
.enumerate()
{
assert_eq!(
client
.request(
"POST",
"/v1/namespaces/app.main/repl",
&format!("target.width={}", 201 + i)
)
.0,
200
);
let mut sample = old.clone();
sample.kind = kind.into();
send_native(sample);
}
for (code, value) in [("tracker.x", "37"), ("tracker.held", "0")] {
let r = client.request("POST", "/v1/namespaces/app.main/repl", code);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&r.2).unwrap()["value"],
value
);
}
assert_eq!(
client
.request(
"POST",
"/v1/namespaces/app.main/events",
&serde_json::to_string(&old).unwrap()
)
.0,
409
);
assert_eq!(
client
.request("PUT", "/v1/namespaces/app.main/code", source)
.0,
200
);
let mut stale = old;
stale.kind = "pointerdown".into();
send_native(stale);
let r = client.request("POST", "/v1/namespaces/app.main/repl", "tracker.held");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&r.2).unwrap()["value"],
"0"
);
}
+26 -7
View File
@@ -337,19 +337,28 @@ impl Worker {
return RequestProgress::Pending(PendingRequest::BeforeInput);
}
let event = if job.kind == RequestKind::Event {
let input: crate::events::Input = match serde_json::from_str(&job.source) {
Ok(input) => input,
Err(error) => {
return RequestProgress::Complete(error_response(
StatusCode(400),
error.to_string(),
));
let mut input: crate::events::Input = if let Some(input) = &job.native {
input.clone()
} else {
match serde_json::from_str(&job.source) {
Ok(input) => input,
Err(error) => {
return RequestProgress::Complete(error_response(
StatusCode(400),
error.to_string(),
));
}
}
};
if !input.kind.starts_with("developer-open:")
&& !matches!(
input.kind.as_str(),
"click"
| "pointermove"
| "pointerdown"
| "pointerup"
| "pointerleave"
| "pointercancel"
| "focus"
| "scroll"
| "page-pan"
@@ -383,6 +392,16 @@ impl Worker {
));
}
};
// Rebase trusted captured native gestures only after BeforeInput
// has drained the prior refresh. External HTTP remains strict.
if job.native.is_some()
&& !crate::events::refresh_continuous_input(&mut input, &current)
{
return RequestProgress::Complete(error_response(
StatusCode(409),
"native target was replaced or detached",
));
}
let frame = if input.window == crate::window::MAIN_WINDOW {
&current.document.frame
} else {
+43 -5
View File
@@ -185,6 +185,32 @@ fn compile_plan_with_globals(
}
}
let mut compiled = Vec::with_capacity(plan.roots().len());
fn assigned(statement: &Stmt, globals: &mut BTreeSet<String>) {
match &statement.kind {
StmtKind::Binding(b) => {
globals.insert(b.name.text.clone());
}
StmtKind::Assignment(a) => {
globals.insert(a.name.text.clone());
}
StmtKind::If(b) => {
for s in &b.body.statements {
assigned(s, globals);
}
if let Some(other) = &b.otherwise {
for s in &other.statements {
assigned(s, globals);
}
}
}
StmtKind::While(b) => {
for s in &b.body.statements {
assigned(s, globals);
}
}
_ => {}
}
}
for root in plan.roots() {
let options = compiler::CompileOptions {
globals: globals.iter().cloned().collect(),
@@ -195,11 +221,7 @@ fn compile_plan_with_globals(
);
if let AstRoot::Interactive(input) = root {
for statement in &input.statements {
if let StmtKind::Binding(binding) = &statement.kind {
globals.insert(binding.name.text.clone());
} else if let StmtKind::Assignment(assignment) = &statement.kind {
globals.insert(assignment.name.text.clone());
}
assigned(statement, &mut globals);
}
}
}
@@ -1387,6 +1409,19 @@ impl Architecture {
/// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。
fn statement(&mut self, statement: &Stmt) -> CodeNode {
match &statement.kind {
StmtKind::If(branch) => {
let mut children = vec![self.expression(&branch.condition)];
children.extend(branch.body.statements.iter().map(|s| self.statement(s)));
if let Some(body) = &branch.otherwise {
children.extend(body.statements.iter().map(|s| self.statement(s)));
}
self.node("if".into(), "if", statement.span, children)
}
StmtKind::While(loop_) => {
let mut children = vec![self.expression(&loop_.condition)];
children.extend(loop_.body.statements.iter().map(|s| self.statement(s)));
self.node("while".into(), "while", statement.span, children)
}
StmtKind::MemberAssignment(assignment) => {
let object = self.expression(&assignment.object);
let value = self.expression(&assignment.value);
@@ -1458,3 +1493,6 @@ impl Architecture {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod manuscript_tests;
+251
View File
@@ -0,0 +1,251 @@
//! Integration acceptance through the actual Endor VM and published frames.
use super::*;
use crate::document::RenderCommand;
#[test]
fn illustrated_manuscript_is_driven_by_real_endor_source_and_pointer_events() {
let source = include_str!("../../../examples/illustrated-manuscript.endor");
let (mut session, mut document) = ReplSession::load(source, "app.main", 1, 735).unwrap();
fn evaluate(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String {
let result = session.execute(source, "app.main").unwrap();
if let Some(next) = result.document {
*document = next;
}
result.value
}
evaluate(&mut session, &mut document, "view.timer.cancel()");
document.validate().unwrap();
assert!(
document
.frame
.commands
.iter()
.any(|c| matches!(c, RenderCommand::Image { .. }))
);
assert!(document.frame.mounts.iter().any(|m| m.pointer_events));
let before = evaluate(&mut session, &mut document, "dragon.segments.at(0).x");
let target = document
.frame
.mounts
.iter()
.find(|m| m.pointer_events)
.unwrap();
let mut input = crate::events::Input {
window: 1,
session: 735,
layout_version: document.frame.layout_version,
mount: target.id,
kind: "pointermove".into(),
point: Some([
target.x + target.width * 0.65,
target.y + target.height * 0.55,
]),
press_point: None,
delta: None,
tab_drop: None,
screen_position: None,
};
document = session
.dispatch(&input)
.unwrap()
.document
.unwrap_or(document);
for _ in 0..8 {
document = session
.execute("dragon.step()", "app.main")
.unwrap()
.document
.unwrap_or(document);
}
let after = evaluate(&mut session, &mut document, "dragon.segments.at(0).x");
assert_ne!(before, after);
input.layout_version = document.frame.layout_version;
input.kind = "pointerdown".into();
document = session
.dispatch(&input)
.unwrap()
.document
.unwrap_or(document);
for _ in 0..3 {
document = session
.execute("dragon.step()", "app.main")
.unwrap()
.document
.unwrap_or(document);
}
assert_ne!(
evaluate(&mut session, &mut document, "dragon.flames.count"),
"0"
);
let max_size = evaluate(&mut session, &mut document, "dragon.flames.at(0).size < 20");
assert_eq!(max_size, "true");
input.layout_version = document.frame.layout_version;
input.kind = "pointercancel".into();
session.dispatch(&input).unwrap();
assert_eq!(evaluate(&mut session, &mut document, "dragon.pressed"), "0");
for _ in 0..16 {
document = session
.execute("dragon.step()", "app.main")
.unwrap()
.document
.unwrap_or(document);
}
assert_eq!(
evaluate(&mut session, &mut document, "dragon.flames.count"),
"0"
);
let list = document.display_list(1.).unwrap();
let mut pixels = vec![0; 1024 * 800];
crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, 1024, 800).unwrap();
assert!(pixels.contains(&0xf4eee0));
}
fn execute(session: &mut ReplSession, document: &mut RenderDocument, source: &str) -> String {
let result = session.execute(source, "app.main").unwrap();
if let Some(next) = result.document {
*document = next;
}
result.value
}
fn write_png(document: &RenderDocument, w: u32, h: u32, scale: f32, name: &str) {
let list = document.display_list(scale).unwrap();
let mut pixels = vec![0; w as usize * h as usize];
crate::software_renderer::SoftwareRenderer::render(&list, &mut pixels, w, h).unwrap();
assert!(pixels.iter().filter(|&&p| p == 0xf4eee0).count() > 1000);
if let Ok(dir) = std::env::var("TESSERA_MANUSCRIPT_SNAPSHOTS") {
std::fs::create_dir_all(&dir).unwrap();
let file =
std::fs::File::create(std::path::Path::new(&dir).join(format!("endor-{name}.png")))
.unwrap();
let mut encoder = png::Encoder::new(file, w, h);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let rgb: Vec<_> = pixels
.iter()
.flat_map(|p| [(p >> 16) as u8, (p >> 8) as u8, *p as u8])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&rgb)
.unwrap();
}
}
#[test]
fn manuscript_script_snapshots_and_owned_animation_lifecycle() {
let source = format!(
"{}\nfunction advanceTest():Unit {{dragon.lastMove=math.now() dragon.step()}}",
include_str!("../../../examples/illustrated-manuscript.endor")
);
for (width, height, scale) in [(1000., 900., 1.), (420., 760., 2.)] {
let (mut session, mut document) = ReplSession::load_with_viewport(
&source,
"app.main",
1,
736,
Some(crate::layout::Size { width, height }),
)
.unwrap();
// The actual owner-scoped Task calls script tick, not a native demo loop.
let before = execute(&mut session, &mut document, "dragon.time");
std::thread::sleep(std::time::Duration::from_millis(85));
if let Some(next) = session.poll_tasks().unwrap() {
document = next;
}
let after = execute(&mut session, &mut document, "dragon.time");
assert_ne!(before, after);
execute(&mut session, &mut document, "view.timer.cancel()");
let (w, h) = ((width * scale) as u32, (height * scale) as u32);
write_png(
&document,
w,
h,
scale,
if width > 500. { "rest" } else { "narrow-rest" },
);
let mount = document
.frame
.mounts
.iter()
.find(|m| m.pointer_events)
.unwrap()
.clone();
let point = [mount.x + mount.width * 0.58, mount.y + mount.height * 0.50];
let mut input = crate::events::Input {
window: 1,
session: 736,
layout_version: document.frame.layout_version,
mount: mount.id,
kind: "pointermove".into(),
point: Some(point),
press_point: None,
delta: None,
tab_drop: None,
screen_position: None,
};
document = session
.dispatch(&input)
.unwrap()
.document
.unwrap_or(document);
for _ in 0..12 {
execute(&mut session, &mut document, "advanceTest()");
}
write_png(
&document,
w,
h,
scale,
if width > 500. { "crossing" } else { "narrow" },
);
input.layout_version = document.frame.layout_version;
input.kind = "pointerdown".into();
document = session
.dispatch(&input)
.unwrap()
.document
.unwrap_or(document);
// Hold long enough to reach the steady-state particle cap and exercise
// the worst paint workload, then release/cancel through the event path.
let start = std::time::Instant::now();
for _ in 0..16 {
execute(&mut session, &mut document, "advanceTest()");
}
eprintln!(
"Endor manuscript width={width}: 16 steady-fire VM refreshes {:?}",
start.elapsed()
);
assert_eq!(
execute(&mut session, &mut document, "dragon.flames.count <= 12"),
"true"
);
execute(
&mut session,
&mut document,
"function verifyFire(): Bool { art=dragon.artwork(0,0,1) c=art.at(art.count-1) p=dragon.flames.at(dragon.flames.count-1) return math.abs(c.angle+p.art.axis-math.atan2(p.vy,p.vx))<0.0001 }",
);
assert_eq!(execute(&mut session, &mut document, "verifyFire()"), "true");
execute(
&mut session,
&mut document,
"function verifyFireSize(): Bool { art=dragon.artwork(0,0,1) c=art.at(art.count-1) return math.max(c.width,c.height)<=20 }",
);
assert_eq!(
execute(&mut session, &mut document, "verifyFireSize()"),
"true"
);
write_png(
&document,
w,
h,
scale,
if width > 500. { "fire" } else { "narrow-fire" },
);
execute(&mut session, &mut document, "page.close()");
let closed = execute(&mut session, &mut document, "dragon.time");
std::thread::sleep(std::time::Duration::from_millis(85));
session.poll_tasks().unwrap();
assert_eq!(execute(&mut session, &mut document, "dragon.time"), closed);
}
}
+101
View File
@@ -8,6 +8,107 @@ use crate::document::{NamespaceState, RenderCommand};
const EXAMPLE: &str = include_str!("../../../examples/hello.endor");
const PAGE: &str = include_str!("../../../examples/page.endor");
/// Opt-in release benchmark; run alone to avoid test-thread interference.
/// cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
#[test]
#[ignore]
fn scene_interaction_performance() {
use std::time::Instant;
fn summary(mut values: Vec<f64>) -> String {
values.sort_by(f64::total_cmp);
format!(
"mean={:.3} p50={:.3} p95={:.3}ms",
values.iter().sum::<f64>() / values.len() as f64,
values[values.len() / 2],
values[(values.len() * 95).div_ceil(100) - 1]
)
}
for (name, source) in [
("world-ui", include_str!("../../../examples/world-ui.endor")),
("scene3d", include_str!("../../../examples/scene3d.endor")),
] {
for dpi in [1., 2.] {
let (mut session, mut document) = ReplSession::load_with_viewport(
source,
"app.main",
1,
988,
Some(crate::layout::Size {
width: 1024.,
height: 800.,
}),
)
.unwrap();
let (width, height) = ((1024. * dpi) as u32, (800. * dpi) as u32);
let mut pixels = vec![0; (width * height) as usize];
let mut updates = Vec::new();
let mut draws = Vec::new();
let mut cached = Vec::new();
for sample in 0..33 {
let target = document
.frame
.mounts
.iter()
.find(|mount| mount.scene3d_viewport)
.unwrap();
let input = crate::events::Input {
window: 1,
session: document.frame.session,
layout_version: document.frame.layout_version,
mount: target.id,
kind: "scene-orbit".into(),
point: Some([target.x + target.width / 2., target.y + target.height / 2.]),
delta: Some([1., 0.]),
press_point: None,
tab_drop: None,
screen_position: None,
};
let start = Instant::now();
document = session.dispatch(&input).unwrap().document.unwrap();
let update = start.elapsed().as_secs_f64() * 1000.;
let list = document.display_list(dpi).unwrap();
let start = Instant::now();
crate::software_renderer::SoftwareRenderer::render(
&list,
&mut pixels,
width,
height,
)
.unwrap();
let draw = start.elapsed().as_secs_f64() * 1000.;
let start = Instant::now();
crate::software_renderer::SoftwareRenderer::render(
&list,
&mut pixels,
width,
height,
)
.unwrap();
if sample >= 3 {
updates.push(update);
draws.push(draw);
cached.push(start.elapsed().as_secs_f64() * 1000.);
}
}
println!(
"PERF {name} dpi={dpi} update {} changed_draw {} cached_draw {}",
summary(updates),
summary(draws),
summary(cached)
);
for stat in ["measured", "arranged", "snapshotVisited"] {
println!(
"PERFSTAT {name} {stat}={}",
session
.execute(&format!("ui.stats().{stat}"), "app.main")
.unwrap()
.value
);
}
}
}
}
#[test]
fn actual_mount_version_reads_still_bypass_measurement_cache() {
let (mut session, initial) = ReplSession::load(
+22
View File
@@ -102,6 +102,28 @@ fn shift_input(input: &mut InteractiveInput, offset: usize) {
fn shift_statement(statement: &mut Stmt, offset: usize) {
shift_span(&mut statement.span, offset);
match &mut statement.kind {
StmtKind::If(branch) => {
shift_span(&mut branch.span, offset);
shift_expression(&mut branch.condition, offset);
shift_span(&mut branch.body.span, offset);
for s in &mut branch.body.statements {
shift_statement(s, offset);
}
if let Some(body) = &mut branch.otherwise {
shift_span(&mut body.span, offset);
for s in &mut body.statements {
shift_statement(s, offset);
}
}
}
StmtKind::While(loop_) => {
shift_span(&mut loop_.span, offset);
shift_expression(&mut loop_.condition, offset);
shift_span(&mut loop_.body.span, offset);
for s in &mut loop_.body.statements {
shift_statement(s, offset);
}
}
StmtKind::Binding(binding) => {
shift_span(&mut binding.span, offset);
shift_span(&mut binding.name.span, offset);
+158 -51
View File
@@ -1,18 +1,13 @@
//! 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。
//! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。
//! 队列有界且当前逐项经本地 HTTP 发送;这不意味着输入已经按显示帧合并,也不是无损无限队列。
//! 生产端只合并相同目标的相邻绝对指针采样;有序动作是边界。relay 直接进入串行 worker,后台等待确认。
//!
//! Native input transport. Frame/mount identities and optional logical pointer positions cross to the VM worker.
use serde::{Deserialize, Serialize};
use std::{
io::{Read, Write},
net::TcpStream,
sync::{
Arc, RwLock,
mpsc::{SyncSender, sync_channel},
},
collections::VecDeque,
sync::{Arc, Condvar, Mutex, RwLock},
thread,
time::Duration,
};
use winit::event_loop::EventLoopProxy;
@@ -142,6 +137,23 @@ impl Input {
_ => Err("scroll requires point and finite delta, with no press_point"),
};
}
if matches!(
self.kind.as_str(),
"pointermove" | "pointerdown" | "pointerup" | "pointerleave" | "pointercancel"
) {
return if self.mount != 0
&& self.press_point.is_none()
&& self.delta.is_none()
&& self.screen_position.is_none()
&& self
.point
.is_some_and(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1_000_000.))
{
Ok(())
} else {
Err("pointer event requires finite point and target")
};
}
if self.delta.is_some() {
return Err("delta is only accepted for scroll input");
}
@@ -161,20 +173,80 @@ impl Input {
}
}
#[derive(Clone)]
/// 原生输入的有界发送端,后台线程串行发往本地 API;所有窗口共享权威发布状态。
pub struct Client(SyncSender<Input>);
/// Bounded producer-side coalescing preserves action barriers while the relay
/// waits for a VM acknowledgement. Motion cannot fill the queue by itself.
struct Queue {
items: VecDeque<Input>,
senders: usize,
closed: bool,
}
struct Inbox {
queue: Mutex<Queue>,
ready: Condvar,
}
pub struct Client(Arc<Inbox>);
impl Clone for Client {
fn clone(&self) -> Self {
self.0.queue.lock().unwrap().senders += 1;
Self(self.0.clone())
}
}
impl Drop for Client {
fn drop(&mut self) {
if let Ok(mut q) = self.0.queue.lock() {
q.senders -= 1;
if q.senders == 0 {
q.closed = true;
q.items.clear();
self.0.ready.notify_all();
}
}
}
}
fn enqueue(queue: &mut VecDeque<Input>, input: Input) -> bool {
if input.kind == "pointermove"
&& let Some(last) = queue.back_mut()
&& last.kind == "pointermove"
&& (last.window, last.session, last.mount) == (input.window, input.session, input.mount)
{
*last = input;
return true;
}
if queue.len() >= 64 {
return false;
}
queue.push_back(input);
true
}
impl Inbox {
fn receive(&self) -> Option<Input> {
let mut q = self.queue.lock().ok()?;
while q.items.is_empty() && !q.closed {
q = self.ready.wait(q).ok()?;
}
if q.closed { None } else { q.items.pop_front() }
}
}
impl Client {
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
pub fn new(
proxy: EventLoopProxy<crate::app::AppEvent>,
namespace: Arc<RwLock<crate::document::NamespaceState>>,
control: crate::api::RuntimeControl,
) -> anyhow::Result<Self> {
let (sender, receiver) = sync_channel::<Input>(64);
let inbox = Arc::new(Inbox {
queue: Mutex::new(Queue {
items: VecDeque::new(),
senders: 1,
closed: false,
}),
ready: Condvar::new(),
});
let receiver = inbox.clone();
thread::Builder::new()
.name("tessera-native-events".into())
.spawn(move || {
for mut input in receiver {
while let Some(mut input) = receiver.receive() {
if let Ok(current) = namespace.read() {
if !refresh_continuous_input(&mut input, &current) {
continue;
@@ -182,18 +254,28 @@ impl Client {
} else {
continue;
}
if let Err(error) = send(&input) {
if let Err(error) = control.dispatch_native(input) {
let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed {
message: error.to_string(),
});
}
}
})?;
Ok(Self(sender))
Ok(Self(inbox))
}
/// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。
pub fn send(&self, input: Input) -> bool {
self.0.try_send(input).is_ok()
let Ok(mut q) = self.0.queue.lock() else {
return false;
};
if q.closed {
return false;
}
let queued = enqueue(&mut q.items, input);
if queued {
self.0.ready.notify_one();
}
queued
}
}
@@ -201,10 +283,23 @@ impl Client {
// the version at dequeue, never session, window or target. HTTP callers and
// discrete actions still receive strict stale-frame rejection.
/// 连续手势出队时只更新有效目标的帧版本;保留原 session、窗口和 Mount,离散事件不自动重试。
fn refresh_continuous_input(input: &mut Input, current: &crate::document::NamespaceState) -> bool {
pub(crate) fn refresh_continuous_input(
input: &mut Input,
current: &crate::document::NamespaceState,
) -> bool {
if !matches!(
input.kind.as_str(),
"viewport-pan" | "viewport-zoom" | "scene-pan" | "scene-orbit" | "scene-zoom" | "scroll"
"viewport-pan"
| "viewport-zoom"
| "scene-pan"
| "scene-orbit"
| "scene-zoom"
| "scroll"
| "pointermove"
| "pointerdown"
| "pointerup"
| "pointerleave"
| "pointercancel"
) {
return true;
}
@@ -229,6 +324,8 @@ fn refresh_continuous_input(input: &mut Input, current: &crate::document::Namesp
mount.canvas_viewport
} else if input.kind.starts_with("scene-") {
mount.scene3d_viewport && mount.camera_controls
} else if input.kind.starts_with("pointer") {
mount.pointer_events
} else {
mount.scroll.is_some()
}
@@ -240,39 +337,6 @@ fn refresh_continuous_input(input: &mut Input, current: &crate::document::Namesp
true
}
/// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。
fn send(input: &Input) -> anyhow::Result<()> {
let body = serde_json::to_string(input)?;
let mut stream =
TcpStream::connect_timeout(&crate::api::API_ADDRESS.parse()?, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
write!(
stream,
"POST /v1/namespaces/app.main/events HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)?;
let mut response = String::new();
stream.take(32768).read_to_string(&mut response)?;
let status = response.split_whitespace().nth(1).unwrap_or("");
// An old frame's event is deliberately discarded, with no automatic retry.
if status == "409" {
return Ok(());
}
if status != "200" {
let body = response
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or(&response);
anyhow::bail!(
"Native event failed: {}",
body.chars().take(1024).collect::<String>()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -353,3 +417,46 @@ mod tests {
assert!(input.validate().is_err());
}
}
#[cfg(test)]
mod coalescing_tests {
use super::*;
#[test]
fn producer_side_motion_coalescing_keeps_action_and_target_boundaries() {
let template: Input = serde_json::from_str(
r#"{"session":1,"layout_version":1,"mount":2,"kind":"pointermove","point":[0,0]}"#,
)
.unwrap();
let mut q = VecDeque::new();
for i in 0..1000 {
let mut sample = template.clone();
sample.point = Some([i as f32, 0.]);
assert!(enqueue(&mut q, sample));
}
assert_eq!(q.len(), 1);
assert_eq!(q[0].point.unwrap()[0], 999.);
let mut down = template.clone();
down.kind = "pointerdown".into();
enqueue(&mut q, down);
let mut moved = template.clone();
moved.point = Some([1200., 0.]);
enqueue(&mut q, moved);
let mut up = template.clone();
up.kind = "pointerup".into();
enqueue(&mut q, up);
let mut other = template;
other.window = 2;
enqueue(&mut q, other);
assert_eq!(
q.iter().map(|i| i.kind.as_str()).collect::<Vec<_>>(),
[
"pointermove",
"pointerdown",
"pointermove",
"pointerup",
"pointermove"
]
);
assert_eq!(q.back().unwrap().window, 2);
}
}
+67 -1
View File
@@ -63,6 +63,8 @@ pub struct TesseraApp {
activity: Activity,
event_client: crate::events::Client,
guest_pressed: Option<crate::events::Input>,
pointer_hover: Option<crate::events::Input>,
pointer_capture: Option<crate::events::Input>,
guest_focus: Option<crate::events::Input>,
guest_key: Option<(NamedKey, crate::events::Input)>,
guest_keyboard: bool,
@@ -76,7 +78,8 @@ impl TesseraApp {
proxy: EventLoopProxy<AppEvent>,
control: crate::api::RuntimeControl,
) -> anyhow::Result<Self> {
let event_client = crate::events::Client::new(proxy.clone(), namespace.clone())?;
let event_client =
crate::events::Client::new(proxy.clone(), namespace.clone(), control.clone())?;
Ok(Self::with_event_client(
namespace,
activity,
@@ -120,6 +123,8 @@ impl TesseraApp {
activity,
event_client,
guest_pressed: None,
pointer_hover: None,
pointer_capture: None,
guest_focus: None,
guest_key: None,
guest_keyboard: true,
@@ -558,6 +563,54 @@ impl TesseraApp {
})
}
/// Pointer subscriptions explicitly opt a Mount into continuous input.
/// Capture retains window/session/Mount; the worker validates it again.
fn pointer_target(&self) -> Option<crate::events::Input> {
let mut input = self.guest_hit()?;
let current = self.namespace.read().ok()?;
let frame = &current.document.frame;
let mut mount = frame.mounts.iter().find(|m| m.id == input.mount)?;
while !mount.pointer_events {
mount = frame.mounts.iter().find(|m| Some(m.id) == mount.parent)?;
}
input.mount = mount.id;
input.press_point = None;
Some(input)
}
fn emit_pointer(&self, mut input: crate::events::Input, kind: &str) {
input.kind = kind.into();
input.press_point = None;
if let Some(p) = self.guest_point() {
input.point = Some([p.x, p.y]);
}
self.event_client.send(input);
}
fn cancel_pointer(&mut self) {
if let Some(input) = self.pointer_capture.take() {
self.emit_pointer(input, "pointercancel");
}
if let Some(input) = self.pointer_hover.take() {
self.emit_pointer(input, "pointerleave");
}
}
fn move_pointer(&mut self) {
if let Some(input) = self.pointer_capture.clone() {
self.emit_pointer(input, "pointermove");
return;
}
let next = self.pointer_target();
if self.pointer_hover.as_ref().map(|i| (i.session, i.mount))
!= next.as_ref().map(|i| (i.session, i.mount))
&& let Some(old) = self.pointer_hover.take()
{
self.emit_pointer(old, "pointerleave");
}
if let Some(input) = next.clone() {
self.emit_pointer(input, "pointermove");
}
self.pointer_hover = next;
}
/// 将标签快捷键转换为当前窗口/帧中的明确页面动作。
fn send_tab_shortcut(&mut self, close: bool) -> bool {
let input = self.namespace.read().ok().and_then(|namespace| {
@@ -1152,6 +1205,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
window.request_redraw();
return;
}
self.move_pointer();
self.developer_tools.move_pointer(
next,
delta,
@@ -1175,6 +1229,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
window.request_redraw();
}
WindowEvent::CursorLeft { .. } => {
self.cancel_pointer();
self.guest_pressed = None;
self.panning = false;
self.sidebar.interaction.pointer_left();
@@ -1197,6 +1252,16 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
button: MouseButton::Left,
..
} => {
if state == ElementState::Pressed {
if let Some(input) = self.pointer_target() {
self.pointer_capture = Some(input.clone());
self.emit_pointer(input, "pointerdown");
return;
}
} else if let Some(input) = self.pointer_capture.take() {
self.emit_pointer(input, "pointerup");
return;
}
if state == ElementState::Pressed {
if self.sidebar.covers(self.pointer) {
self.sidebar_keyboard = true;
@@ -1354,6 +1419,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
window.request_redraw();
}
WindowEvent::Focused(false) => {
self.cancel_pointer();
self.guest_pressed = None;
self.guest_key = None;
self.developer_tools.blur();
+1 -1