Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ceb83320 | |||
| f2f19ef87b | |||
| 09c1090339 | |||
| 2715c1ce2c |
@@ -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"
|
||||
|
||||
@@ -16,6 +16,10 @@ anyhow = "1.0"
|
||||
compiler = { path = "vendor/aster/crates/compiler" }
|
||||
# 系统字体发现、塑形与换行,用于测量和软件文字绘制。
|
||||
cosmic-text = "0.19"
|
||||
# 解码脚本加载的 PNG 图像,供通用精灵绘制与透明轮廓计算。
|
||||
png = "0.18"
|
||||
# 按 Unicode 断行机会准备可复用文字片段,供排除区域绕排。
|
||||
unicode-linebreak = "0.1.5"
|
||||
# Aster 托管对象、类型系统、VM 和可恢复调用;依赖包名 core 在 Rust 中重命名为 endor_core。
|
||||
endor_core = { package = "core", path = "vendor/aster/crates/core" }
|
||||
# Endor 源语法解析与原始位置诊断。
|
||||
|
||||
@@ -22,6 +22,14 @@ experimental `namespace` / `export scene` / `render` parser has been removed.
|
||||
Host UI integration lives in Tessera; Aster needs no Tessera-specific changes.
|
||||
Lyra CLI and Hestia Server are independent hosts and are not application dependencies.
|
||||
|
||||
## Endor manuscript example
|
||||
|
||||
[illustrated-manuscript.endor](examples/illustrated-manuscript.endor) implements
|
||||
cursor-following dragon animation, small directional fire and text exclusion in
|
||||
ordinary Endor. It uses generic graphics resources, source paint, pointer events
|
||||
and Page-owned Tasks; no native demo mode is required. See [run instructions,
|
||||
script walkthrough and validation](docs/illustrated-manuscript.md).
|
||||
|
||||
## Native build and start
|
||||
|
||||
Use Rust 1.91 or newer. Initialize the source submodule following the dedicated
|
||||
|
||||
@@ -0,0 +1,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→软件渲染快照,原生交互验证来自用户操作。
|
||||
@@ -37,3 +37,7 @@ and the running start page's four working actions.
|
||||
The file is client-side sample material; Tessera's API stores source by namespace.
|
||||
|
||||
Return to the [project guide](../README.md).
|
||||
|
||||
## 龙与文字绕排
|
||||
|
||||
`illustrated-manuscript.endor` 通过通用图形资源、源 paint、指针事件与 Page 任务实现。龙、火焰和文字受热逻辑均在 Endor 中。资源在 `assets/manuscript/`;从仓库根目录启动应用并提交源码,详见 [运行和脚本解释](../docs/illustrated-manuscript.md)。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Illustrated Manuscript assets and attribution
|
||||
|
||||
Dragon sprites (head, tongue, front/back wings, body-1 through body-19), fire
|
||||
sprites (Layer 2 through Layer 11), dropcap.png and story.txt are from
|
||||
https://github.com/dengshu2/illustrated-manuscript (main, retrieved 2026-11-12).
|
||||
The upstream package.json declares `ISC`; upstream has no separate LICENSE file.
|
||||
Its complete README is retained as UPSTREAM.md. It credits the original demo to
|
||||
River Marchand (@Riyvir), Neither/Nor: https://neithernor.studio/.
|
||||
Reference live demo: https://illustrated-manuscript.vercel.app/.
|
||||
The native chain animation, exclusion and fire algorithms are adapted from that
|
||||
project's src/dragon.js and src/main.js; this port contains no JavaScript runtime.
|
||||
|
||||
IMFellEnglish-Regular.ttf is the unmodified IMFeENrm28P.ttf from
|
||||
https://github.com/google/fonts/tree/main/ofl/imfellenglish.
|
||||
Copyright (c) 2010, Igino Marini. SIL Open Font License 1.1, retained in OFL.txt.
|
||||
The face is embedded for offline, reproducible manuscript typography.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Illustrated Manuscript
|
||||
|
||||
An interactive illuminated manuscript page featuring an animated dragon that follows your cursor, breathes fire on click, and causes text to flow around it in real-time.
|
||||
|
||||
**[→ Live Demo](https://illustrated-manuscript.vercel.app/)**
|
||||
|
||||
## Overview
|
||||
|
||||
This project renders a medieval manuscript page on Canvas, featuring a hand-painted dragon made of sprite segments. The dragon tracks the mouse cursor with chain-physics (20 body segments with angle constraints), and clicking makes it breathe fire. Text reflows around the dragon body and fire particles each frame using [Pretext](https://github.com/chenglou/pretext)'s `layoutNextLine` API — no DOM measurements, no CSS reflow.
|
||||
|
||||
### Features
|
||||
|
||||
- **Real-time text exclusion** — text wraps around arbitrary moving shapes (dragon body + fire particles), re-laid out every frame via Pretext
|
||||
- **Chain-physics dragon** — 20-segment inverse-kinematics body, hand-painted sprites, wing flapping, idle rest pose
|
||||
- **Fire-breathing** — click to spawn particle bursts from the dragon's mouth; particles push and distort nearby text characters
|
||||
- **Illuminated drop cap** — hand-drawn initial letter in manuscript tradition
|
||||
- **Medieval aesthetic** — IM Fell English font, parchment tones, hand-painted sprites throughout
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **[Pretext](https://github.com/chenglou/pretext)** — Pure JS/TS text measurement & layout engine by [Cheng Lou](https://x.com/_chenglou). Enables laying out text without DOM reflow, line by line with variable widths
|
||||
- **WebGL2 + 2D Canvas** — offscreen 2D Canvas compositing, uploaded to WebGL2 texture for display
|
||||
- **Vite** — build tool
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
config.js — constants, responsive layout calculations
|
||||
dragon.js — dragon physics, sprite rendering, fire particle system
|
||||
story.js — manuscript story text
|
||||
main.js — entry point: WebGL pipeline, input, text layout, render loop
|
||||
public/
|
||||
dragon-sprites/ — 23 hand-painted dragon body part PNGs
|
||||
fire-sprites/ — 10 fire particle PNGs
|
||||
img/dropcap.png — illuminated drop cap
|
||||
(IM Fell English loaded via Google Fonts)
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open in browser. Desktop recommended — mobile support is limited.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Text preparation**: story text is segmented and measured once via `prepareWithSegments()`
|
||||
2. **Each frame**: dragon segments and fire particles generate exclusion rectangles
|
||||
3. **Text layout**: for each line, available width ranges are computed by subtracting exclusion zones, then `layoutNextLine()` fills each range
|
||||
4. **Fire text effect**: characters near fire particles are displaced, rotated, and color-shifted based on proximity
|
||||
5. **Rendering**: everything composites on an offscreen 2D Canvas, then uploads to a WebGL2 fullscreen quad
|
||||
|
||||
## Credits
|
||||
|
||||
Created by [River Marchand (@Riyvir)](https://x.com/Riyvir), Creative Director at [Neither/Nor](https://neithernor.studio/).
|
||||
|
||||
Built with [Pretext](https://github.com/chenglou/pretext) by [Cheng Lou](https://x.com/_chenglou) — a pure JavaScript/TypeScript library for multiline text measurement & layout, enabling web UIs to bypass DOM measurements and reflow entirely.
|
||||
|
||||
> *"This little illuminated dragon is very happy about Pretext."* — [@Riyvir](https://x.com/Riyvir/status/2038093450139279426)
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,47 @@
|
||||
It was the third candle the abbess had confiscated, and Sable had nodded gravely each time and stolen another from the chapel stores. She kept reading by its unsteady light, hunched beneath her wool blanket in the scriptorium where the novices slept on straw pallets between the copying desks.
|
||||
|
||||
It was the bestiary that held her. Not the psalter, not the gospels, not the lives of saints with their wooden sufferings. The bestiary. Some brother at Lindisfarne had painted it two centuries ago, and his creatures had a quality she could not name, a weight to them, as if they had been observed from life rather than copied from pattern books. His lions looked hungry. His basilisks looked bored.
|
||||
|
||||
And his dragon, coiled in the lower margin of the forty-seventh leaf, looked like it was breathing.
|
||||
|
||||
She had watched it for six nights before she was certain. The movement was slight, a swelling of the ribs, a settling, barely more than the flicker the candlelight threw across the page. On the seventh night she held her breath and placed a finger beside its painted flank, and the parchment was warm.
|
||||
|
||||
\u201CYou could simply ask,\u201D the dragon said.
|
||||
|
||||
Sable jerked her hand back. The dragon opened one eye, yellow as egg tempera, and regarded her with an expression she recognized from the abbey cats: mild contempt for a creature too slow to be interesting but too close to ignore.
|
||||
|
||||
\u201CI am called Voss,\u201D it said. \u201CThough the brother who painted me called me several other things, mostly under his breath, mostly because I would not hold still.\u201D
|
||||
|
||||
\u201CYou are paint,\u201D Sable said. It came out less firmly than she intended.
|
||||
|
||||
\u201CI am gall ink and oak soot and gum arabic,\u201D Voss replied. \u201CAlso rabbit skin glue, which I find personally offensive. But I am not paint. Paint does not get stiff from two centuries of holding the same pose.\u201D He uncurled one leg and stretched it across the margin rule, his claws dimpling the parchment. \u201CYou, on the other hand, are trespassing. This page is not assigned reading for novices.\u201D
|
||||
|
||||
\u201CI am not a novice. I clean the scriptorium.\u201D
|
||||
|
||||
\u201CEven worse. A servant reading. The brothers would be scandalized.\u201D
|
||||
|
||||
\u201CThe brothers are asleep.\u201D
|
||||
|
||||
\u201CThe brothers,\u201D Voss said, \u201Care always asleep. That is the principal advantage of monasteries.\u201D He yawned, and his breath smelled of lamp-black and something older, something mineral, like a cave after rain. A thin red tongue uncurled and tasted the air above the page.
|
||||
|
||||
Sable leaned closer. In the candlelight she could see the crosshatching of his scales, each one laid down with a single stroke of the brush. The brother who painted him had not scrimped. Most bestiary dragons were outlines stuffed with vermillion, flat and heraldic. This one had shadow under its jaw. This one had a nick in its left ear.
|
||||
|
||||
\u201CWhat do you eat?\u201D she asked.
|
||||
|
||||
\u201CSilverfish, mostly. They get into the binding paste. Occasionally a bookworm, if I am feeling athletic.\u201D He cocked his head. \u201CWhy are you not frightened?\u201D
|
||||
|
||||
\u201CShould I be?\u201D
|
||||
|
||||
\u201CConvention demands it. A maiden, a dragon. There are expectations.\u201D
|
||||
|
||||
\u201CI scrub floors and empty chamber pots. I am not anyone\u2019s idea of a maiden.\u201D
|
||||
|
||||
Voss made a sound that might have been a laugh. It was dry and papery, like a sheet of vellum being folded. \u201CGood,\u201D he said. \u201CMaidens are tedious. They always scream at exactly the wrong moment. You, at least, have the sense to ask questions first.\u201D
|
||||
|
||||
He settled back into the margin, tucking his tail around the descender of a large Q. \u201CCome back tomorrow night,\u201D he said. \u201CBring a candle that does not gutter so badly. And if you can manage it, a bit of cheese. I have not tasted cheese in two hundred years, but I believe I would still recognize it.\u201D
|
||||
|
||||
\u201CCan you eat cheese? You are made of ink.\u201D
|
||||
|
||||
\u201CI am made of ink that remembers being alive,\u201D Voss said. \u201CThere is a considerable difference.\u201D He closed his eyes. The warmth under Sable\u2019s fingertips faded, and the dragon on the page was still again, just pigment and binder on stretched calfskin, exactly as lifeless as it should have been.
|
||||
|
||||
Sable sat in the dark for a long time after the candle burned out. Then she went to the larder and stole a piece of cheese.\u2026
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@
|
||||
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
|
||||
/// 注册托管类型/宿主方法,验证参数并连接业务对象、Mount、视觉协议与窗口/任务资源。
|
||||
pub(crate) mod ui_bridge;
|
||||
|
||||
pub(crate) mod graphics;
|
||||
|
||||
@@ -1504,6 +1504,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(());
|
||||
}
|
||||
// 普通点击/焦点先登记展示焦点请求,再按 kind 调 Block.click 或 Mount.requestFocus;生效仍需后续完整刷新。
|
||||
vm.set_attr(
|
||||
desktop,
|
||||
|
||||
@@ -463,6 +463,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
crate::protocols::install(vm, roots, desktop, ui)?;
|
||||
// 每个桥接域配套自己的任务 Scheduler,再接工具入口及托管起始页工厂,最后返回可使用的 UiBridge。
|
||||
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
|
||||
crate::bridge::graphics::install(vm, roots, ui, desktop)?;
|
||||
developer_pages::install(vm, ui, &session)?;
|
||||
let bridge = UiBridge { ui, session, tasks };
|
||||
developer_pages::install_start_page(vm, &bridge)?;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct UiNode {
|
||||
pub slots: Vec<u64>,
|
||||
// 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。
|
||||
pub interactive: bool,
|
||||
pub pointer_events: bool,
|
||||
pub kind: UiKind,
|
||||
}
|
||||
|
||||
@@ -86,6 +87,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;
|
||||
}
|
||||
|
||||
@@ -499,6 +499,8 @@ pub struct MountGeometry {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub interactive: bool,
|
||||
#[serde(default)]
|
||||
pub pointer_events: bool,
|
||||
}
|
||||
|
||||
impl MountGeometry {
|
||||
@@ -592,6 +594,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,
|
||||
@@ -635,6 +650,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,
|
||||
@@ -710,6 +748,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,
|
||||
@@ -753,7 +810,7 @@ impl RenderCommand {
|
||||
size,
|
||||
color,
|
||||
wrap_bounds,
|
||||
// 根据是否提供换行边界选择 WrappedText 或 Text;两者都使用同一等级字号与设备缩放。
|
||||
// 根据是否提供换行边界选择 WrappedText 或 Text;两者都使用同一等级字号与设备缩放。
|
||||
} => match wrap_bounds {
|
||||
Some([width, height]) => DrawCommand::WrappedText {
|
||||
rect: scaled_rect(*x, *y, *width, *height, scale),
|
||||
|
||||
@@ -71,6 +71,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<'_>,
|
||||
@@ -112,6 +178,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)
|
||||
}
|
||||
|
||||
@@ -147,11 +233,13 @@ impl NativeCallable for Constructor {
|
||||
.payload::<endor_core::EndorTypeObject>(*definition, "Type")?
|
||||
.fields()
|
||||
.to_vec();
|
||||
// 这些名称由列表协议占用;允许子类覆盖会令 count/first/at 与底层成员不一致。
|
||||
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"));
|
||||
}
|
||||
// 字段按类型定义顺序占据最后 N 项;checked_sub 在实参不足时返回错误,避免下标下溢。
|
||||
|
||||
@@ -220,6 +220,7 @@ mod tests {
|
||||
protocol_key: "rect".into(),
|
||||
slots: Vec::new(),
|
||||
interactive: false,
|
||||
pointer_events: false,
|
||||
kind: UiKind::Rect {
|
||||
width: 500.0,
|
||||
height: 500.0,
|
||||
|
||||
@@ -242,6 +242,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;
|
||||
@@ -429,7 +434,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,
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
// 在 rect 内绘制不可变 3D 视图;平移/缩放改变视口矩形,不改写共享场景或相机。
|
||||
Scene3D {
|
||||
rect: Rect,
|
||||
@@ -91,7 +100,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;
|
||||
}
|
||||
@@ -134,6 +143,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;
|
||||
@@ -169,7 +187,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1149,6 +1149,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));
|
||||
|
||||
@@ -31,3 +31,6 @@ pub(crate) mod source_paint;
|
||||
pub(crate) mod text;
|
||||
/// 处理二维场景相机、局部视口投影和空间查询,供绘制与命中使用相同变换。
|
||||
pub(crate) mod viewport;
|
||||
|
||||
pub mod image;
|
||||
pub mod text_flow;
|
||||
|
||||
@@ -74,6 +74,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 } => {
|
||||
// 先将 3D 矩形与当前裁剪和目标尺寸相交;完全不可见时连中间表面都不生成。
|
||||
let left = rect.x.max(canvas.clip.x).max(0.).floor() as u32;
|
||||
@@ -174,6 +184,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用不透明 RGB 填满目标切片;此命令忽略当前裁剪和输入颜色 alpha。
|
||||
fn clear(&mut self, color: Color) {
|
||||
// Clear 填整个提供的目标切片,不受当前 clip 约束;pack 忽略 alpha,清屏色作为不透明 RGB。
|
||||
|
||||
@@ -81,13 +81,14 @@ fn decode(
|
||||
let x = number(store, command, "x")?;
|
||||
let y = number(store, command, "y")?;
|
||||
let kind = text(store, command, "kind")?;
|
||||
// 3D 命令使用不可变场景视图,不读取颜色文本;其他命令的颜色由最终 validate 统一校验。
|
||||
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" => {
|
||||
// 这里仅接受宿主生成的 PaintView payload,克隆原生 Arc,不把活动的 VM 场景图传到绘制线程。
|
||||
let snapshot = object(store.get_attr(command, "scene")?)?;
|
||||
|
||||
@@ -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)]
|
||||
@@ -84,6 +86,7 @@ struct PreparedText {
|
||||
/// 共享字体系统、字形缓存和整形缓存;调用通过全局锁串行访问。
|
||||
struct TextEngine {
|
||||
fonts: FontSystem,
|
||||
custom: Vec<(String, u64)>,
|
||||
code_family: Option<String>,
|
||||
swash: SwashCache,
|
||||
buffers: HashMap<TextKey, PreparedText>,
|
||||
@@ -125,6 +128,7 @@ impl TextEngine {
|
||||
}
|
||||
Self {
|
||||
fonts,
|
||||
custom: Vec::new(),
|
||||
code_family: mono,
|
||||
swash: SwashCache::new(),
|
||||
buffers: HashMap::new(),
|
||||
@@ -159,6 +163,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
|
||||
@@ -398,6 +403,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::*;
|
||||
|
||||
@@ -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 = ¶[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
@@ -52,8 +52,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 {
|
||||
// 原子标志合并重复退出请求;已请求直接返回 true,不在 GUI 线程重复排队清理。
|
||||
@@ -102,7 +134,8 @@ enum RequestKind {
|
||||
|
||||
/// 保留原 HTTP 请求与提交身份的执行工作,完成或失败后由 worker 回应。
|
||||
struct BuildRequest {
|
||||
request: ObservedRequest,
|
||||
request: RequestReply,
|
||||
native: Option<crate::events::Input>,
|
||||
kind: RequestKind,
|
||||
source: String,
|
||||
namespace: String,
|
||||
@@ -120,6 +153,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,
|
||||
@@ -141,6 +176,7 @@ pub fn start(
|
||||
activity.clone(),
|
||||
notify,
|
||||
Some(shutdown),
|
||||
Some(control.native_sender.clone()),
|
||||
)?;
|
||||
Ok((activity, control))
|
||||
}
|
||||
@@ -159,6 +195,7 @@ fn start_server(
|
||||
Arc::new(Mutex::new(ActivityLog::default())),
|
||||
Arc::new(|| {}),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,9 +207,15 @@ fn start_server_observed(
|
||||
activity: Activity,
|
||||
observer_notify: Notify,
|
||||
shutdown: Option<Shutdown>,
|
||||
native_port: Option<Arc<OnceLock<SyncSender<BuildRequest>>>>,
|
||||
) -> Result<thread::JoinHandle<()>> {
|
||||
// HTTP 接收与 VM 执行分离,最多8个排队请求;源代码只在单个 endor-worker 中操作 VM。
|
||||
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
|
||||
@@ -254,7 +297,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
|
||||
@@ -315,6 +360,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,
|
||||
@@ -598,7 +670,8 @@ fn submit(
|
||||
}
|
||||
};
|
||||
let job = BuildRequest {
|
||||
request,
|
||||
request: RequestReply::Http(request),
|
||||
native: None,
|
||||
kind,
|
||||
source,
|
||||
namespace: addressed.into(),
|
||||
|
||||
@@ -885,3 +885,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 = ¤t.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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,19 +354,28 @@ impl Worker {
|
||||
}
|
||||
// 事件先做 JSON 结构、kind 白名单和 Input 字段校验,再用权威发布帧核对具体资源能力。
|
||||
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"
|
||||
@@ -400,6 +409,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, ¤t)
|
||||
{
|
||||
return RequestProgress::Complete(error_response(
|
||||
StatusCode(409),
|
||||
"native target was replaced or detached",
|
||||
));
|
||||
}
|
||||
let frame = if input.window == crate::window::MAIN_WINDOW {
|
||||
¤t.document.frame
|
||||
} else {
|
||||
|
||||
@@ -193,6 +193,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(),
|
||||
@@ -204,11 +230,7 @@ fn compile_plan_with_globals(
|
||||
// 每段编译成功才把绑定/赋值名字加入下一段编译上下文,语法与代码生成都继续委托 Aster。
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1447,6 +1469,19 @@ impl Architecture {
|
||||
/// 按赋值、返回、表达式和声明种类构建 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);
|
||||
@@ -1522,3 +1557,6 @@ impl Architecture {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod manuscript_tests;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -154,6 +149,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");
|
||||
}
|
||||
@@ -175,22 +187,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 {
|
||||
/// 建立容量 64 的发送队列和单个后台 HTTP 线程;线程创建失败则返回错误。
|
||||
/// 建立容量 64 的发送队列和单个后台中继线程,事件交给原生 VM worker;线程创建失败则返回错误。
|
||||
pub fn new(
|
||||
proxy: EventLoopProxy<crate::app::AppEvent>,
|
||||
namespace: Arc<RwLock<crate::document::NamespaceState>>,
|
||||
control: crate::api::RuntimeControl,
|
||||
) -> anyhow::Result<Self> {
|
||||
// 队列最多容纳 64 项;原生发送端使用 try_send,不阻塞 GUI 事件线程,也不保证满队列时无损。
|
||||
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 || {
|
||||
// 后台线程按队列顺序逐个发送 HTTP 请求;命名空间锁只覆盖检查,不跨越网络等待。
|
||||
for mut input in receiver {
|
||||
while let Some(mut input) = receiver.receive() {
|
||||
if let Ok(current) = namespace.read() {
|
||||
if !refresh_continuous_input(&mut input, ¤t) {
|
||||
continue;
|
||||
@@ -198,20 +268,28 @@ impl Client {
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
// 传输/服务错误通过事件代理回 GUI;代理已关闭时忽略通知失败,接收端退出后线程自然结束。
|
||||
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))
|
||||
}
|
||||
/// 非阻塞尝试入队;成功仅代表队列接收,满队列或后台线程关闭返回 false。
|
||||
pub fn send(&self, input: Input) -> bool {
|
||||
// 只反馈是否成功入队;队列满或接收端关闭都返回 false,不代表 worker 已接受或执行事件。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,10 +297,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;
|
||||
}
|
||||
@@ -249,6 +340,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()
|
||||
}
|
||||
@@ -261,42 +354,6 @@ fn refresh_continuous_input(input: &mut Input, current: &crate::document::Namesp
|
||||
true
|
||||
}
|
||||
|
||||
/// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。
|
||||
fn send(input: &Input) -> anyhow::Result<()> {
|
||||
// 固定回环 API 的轻量客户端,使用 Content-Length 和 Connection: close 定界;不连接外部地址。
|
||||
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();
|
||||
// 响应读取最多 32 KiB,避免错误正文无限增长;状态从 HTTP 状态行提取,不实现通用 HTTP 客户端。
|
||||
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.
|
||||
// 409 代表过期帧冲突,按输入协议静默丢弃,不重试离散动作,避免重复点击或跨版本误操作。
|
||||
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::*;
|
||||
@@ -382,3 +439,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ pub struct TesseraApp {
|
||||
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>,
|
||||
// Enter/Space 的按下捕获;只在对应按键释放时发出一次 click,忽略系统连发。
|
||||
guest_key: Option<(NamedKey, crate::events::Input)>,
|
||||
@@ -85,7 +87,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,
|
||||
@@ -129,6 +132,8 @@ impl TesseraApp {
|
||||
activity,
|
||||
event_client,
|
||||
guest_pressed: None,
|
||||
pointer_hover: None,
|
||||
pointer_capture: None,
|
||||
guest_focus: None,
|
||||
guest_key: None,
|
||||
guest_keyboard: true,
|
||||
@@ -577,6 +582,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 = ¤t.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| {
|
||||
@@ -1185,6 +1238,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
|
||||
window.request_redraw();
|
||||
return;
|
||||
}
|
||||
self.move_pointer();
|
||||
self.developer_tools.move_pointer(
|
||||
next,
|
||||
delta,
|
||||
@@ -1208,6 +1262,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();
|
||||
@@ -1230,6 +1285,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;
|
||||
@@ -1391,6 +1456,7 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
|
||||
}
|
||||
// 失焦可能导致收不到对应释放事件,因此清空按键/鼠标捕获、平移状态及修饰键,避免恢复焦点后卡住。
|
||||
WindowEvent::Focused(false) => {
|
||||
self.cancel_pointer();
|
||||
self.guest_pressed = None;
|
||||
self.guest_key = None;
|
||||
self.developer_tools.blur();
|
||||
|
||||