26 Commits

Author SHA1 Message Date
Zayden-Jung f00fd05754 Merge pull request 'feat: 用 Endor 脚本实现龙与文字绕排并补齐通用图形输入能力' (#9) from codex/dragon-manuscript into main
Reviewed-on: #9
2026-11-12 03:12:08 +00:00
codex-bot b0ceb83320 merge: resolve manuscript conflicts with latest main comments 2026-11-12 11:11:00 +08:00
Zayden-Jung f2f19ef87b Merge pull request 'docs: 全库细化中文逻辑注释,覆盖测试、示例与脚本' (#8) from codex/chinese-comment-detail into main
Reviewed-on: #8
2026-11-12 03:06:44 +00:00
codex-bot 09c1090339 chore: normalize bundled font license line endings 2026-11-12 10:53:05 +08:00
codex-bot 2715c1ce2c feat: render interactive manuscript from Endor through generic graphics 2026-11-12 10:52:28 +08:00
codex-bot 45ff965b01 docs: 补齐各模块局部回归测试的输入与断言说明 2026-11-12 10:34:22 +08:00
codex-bot e315172ce1 docs: 解释最新主线场景性能基准的采样与计时边界 2026-11-12 10:32:25 +08:00
codex-bot cd23e70db1 docs: 逐模块细化中文逻辑注释并覆盖测试与示例 2026-11-12 10:31:41 +08:00
Zayden-Jung d29a1be329 Merge pull request 'test: add reproducible scene performance baseline on current main' (#6) from codex/scene-performance into main
Reviewed-on: #6
2026-11-11 18:39:17 +00:00
codex-bot 723e41706b fix: keep current Aster API when updating performance study 2026-11-12 02:21:34 +08:00
codex-bot caea8c62a2 Merge remote-tracking branch 'origin/main' into codex/scene-performance 2026-11-12 02:13:08 +08:00
Zayden-Jung 0b1394e407 Merge pull request 'docs: 为 Tessera 本体补充中文源码注释与阅读索引' (#7) from codex/code-comments into main
Reviewed-on: #7
2026-11-11 16:29:49 +00:00
codex-bot 1fedd21d3c docs: explain Tessera modules and execution flows in Chinese 2026-11-11 23:36:03 +08:00
codex-bot 534f6e03c1 perf: add repeatable scene benchmarks and validate borrowed dictionary keys 2026-11-11 15:50:10 +08:00
Zayden-Jung 4c3fc8b5dd Merge pull request 'feat: compose nested UI layouts with 2D and 3D scene viewports' (#5) from codex/page-surfaces into main
Reviewed-on: #5
2026-11-10 17:02:25 +00:00
codex-bot 741977c3fb docs: record verified scene delivery and manual merge status 2026-11-11 00:55:35 +08:00
codex-bot 32d5da5528 feat: bound scene visibility rendering and continuous native input 2026-11-11 00:53:21 +08:00
codex-bot 15fc01542c feat: compose interactive world ui across nested 3d viewports 2026-11-11 00:08:23 +08:00
codex-bot ad2c01bc29 feat: render programmable 3d scenes cameras and picking 2026-11-10 23:19:26 +08:00
codex-bot 040b779f5f feat: separate 2d scenes viewports and explicit cameras 2026-11-10 22:42:56 +08:00
codex-bot 32d7ada3ab feat: implement nested border-box layout and alignment 2026-11-10 22:17:19 +08:00
codex-bot 525097a0b2 Merge remote-tracking branch 'origin/main' into codex/page-surfaces 2026-11-10 21:55:47 +08:00
Zayden-Jung dff84f2b27 Merge pull request 'Add a workspace dashboard script example' (#3) from codex/workspace-dashboard-example into main
Reviewed-on: #3
2026-11-10 08:27:10 +00:00
Zayden-Jung 7007835541 Merge pull request 'chore: remove CI/CD and document local development' (#4) from codex/local-development into main
Reviewed-on: #4
2026-11-10 08:26:58 +00:00
codex-bot 82f9256a84 feat: add page surfaces and collapsible sidebar 2026-11-10 16:18:47 +08:00
codex-bot e0eff7755f chore: remove CI/CD and document local development 2026-11-10 15:17:47 +08:00
188 changed files with 15829 additions and 2251 deletions
-8
View File
@@ -1,8 +0,0 @@
.git
**/.git
target
**/target
**/AGENTS.md
**/work
**/ci-output
.env
-26
View File
@@ -1,26 +0,0 @@
# Tessera GUI automation
[workflows](workflows/README.md) contains repository workflows.
## CI source checkout
All workflows use `runs-on: ces-control`; no shared Runner configuration is changed.
`actions/checkout@v4` reads source over HTTPS using Gitea's automatically supplied
`GITEA_TOKEN`. No additional SSH key or known_hosts secrets are required, and
`persist-credentials: false` prevents credentials from being retained for later
build steps.
Consumer workflows read the exact Aster commit from the checked-out project's
`vendor/aster` gitlink, then use another standard checkout into that directory and
verify the resulting HEAD. They do not follow Aster main automatically. Aster is
currently a public repository, so reading it does not require private cross-repo
authorization. If that visibility changes, diagnose the job token's read access
instead of assuming an SSH key must be distributed.
The SSH URL in `.gitmodules` is retained for developer Git operations, which
continue to use the dedicated codex-bot identity. CI's built-in job token is
separate from local developer credentials.
Workflow definitions do not prove CI passed. CI/deployment state is not queried
automatically. Local runtime checks are not performed for Aster, Lyra or Hestia;
Tessera permits local native builds/window runs, with automated tests in CI.
-41
View File
@@ -1,41 +0,0 @@
# Tessera GUI workflows
[ci.yml](ci.yml) verifies this project on main pushes and manual dispatch from main. It runs on ces-control, with a 60-minute timeout. HTTPS checkout and job token details are in [the parent README](../README.md).
There is no production service deployment in this workflow.
The container includes the Endor examples used by the live guide and tests. It
validates the actual Aster-backed source-to-UI path, snapshot lifetime, layout and
execution budgets, failed-update preservation and stale-result invalidation, in
addition to the existing rendering checks and Clippy. Cargo uses official sources.
The primary example now executes top-level let statements and ui.page().render
through Aster's Interactive interface. Function-entry compatibility, original
source positions, page methods and page budgets are also checked. The workflow
checks the macOS packaging script's syntax without running it on Linux.
Docker is used only in this remote workflow; local compilation is native.
The test image installs DejaVu Sans/Mono and Noto CJK for font metrics and Chinese
fallback checks. Text regression tests inspect glyph selection, measured advances,
fractional scale, multiline bounds, Unicode truncation and antialiased opacity.
## Incremental verification
Gitea 1.26 concurrency groups cancel superseded runs on the same ref. Markdown-only
pushes to main skip verification; pull requests do not trigger this workflow; executable Endor examples and Rust
files (including embedded guide strings) still trigger checks. Manual dispatch
runs verification only when main is selected. Existing queued runs created from older workflow
revisions retain their old behavior.
The workflow invokes [verify-ci.sh](../../scripts/verify-ci.sh). Docker builds the
Rust 1.91.1/system/font toolchain from Dockerfile.ci over stdin, without sending a
source build context. The checkout is mounted read-only at /build. Named volumes
persist Cargo registry/git downloads and /cargo-target across container removal,
including failed tests and cancelled runs. Main push and manual runs use the main cache scope. Changing Rust/toolchain compatibility requires updating
the image and cache prefix in the script. Cargo lockfiles and fingerprints decide
which dependencies/artifacts are reusable; caches do not bypass checks.
A host flock serializes each scope's writable target cache. Signal handling
removes only the current run's named container; it never prunes shared Docker
resources or deletes cache volumes. The first run populates caches; later runs
still execute fmt, all tests and Clippy. Cargo compilation remains single-job to
limit memory pressure on the shared host. No CPU-time guarantee is implied.
-52
View File
@@ -1,52 +0,0 @@
name: Verify Tessera GUI
on:
push:
branches: [main]
paths-ignore: ["**/*.md"]
workflow_dispatch:
concurrency:
group: tessera-${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
verify:
if: gitea.ref == 'refs/heads/main'
runs-on: ces-control
timeout-minutes: 60
env:
DOCKER_BUILDKIT: "0"
steps:
- name: Checkout project
id: checkout_project
uses: actions/checkout@v4
with:
github-server-url: ${{ gitea.server_url }}
persist-credentials: false
- name: Read pinned Aster revision
id: aster_revision
run: |
set -eu
aster_sha=$(git ls-tree HEAD -- vendor/aster | awk '$1 == "160000" && $2 == "commit" && $4 == "vendor/aster" { print $3 }')
case "$aster_sha" in *[!0-9a-f]*|'') echo "Invalid Aster gitlink" >&2; exit 1;; esac
test "${#aster_sha}" -eq 40
printf 'sha=%s\n' "$aster_sha" >> "$GITHUB_OUTPUT"
- name: Checkout pinned Aster
id: checkout_aster
uses: actions/checkout@v4
with:
github-server-url: ${{ gitea.server_url }}
repository: Zayden-Jung/endorphin-aster-vm
ref: ${{ steps.aster_revision.outputs.sha }}
path: vendor/aster
persist-credentials: false
- name: Verify Aster revision
env:
EXPECTED_ASTER_SHA: ${{ steps.aster_revision.outputs.sha }}
run: test "$(git -C vendor/aster rev-parse HEAD)" = "$EXPECTED_ASTER_SHA"
- name: Verify portable GUI code with persistent Cargo caches
env:
CI_CACHE_SCOPE: ${{ gitea.event_name == 'pull_request' && format('pr-{0}', gitea.event.pull_request.number) || 'main' }}
run: sh scripts/verify-ci.sh
Generated
+132
View File
@@ -18,6 +18,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -92,6 +98,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -308,6 +320,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "crc32fast"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b929dd2a135771f40afabd1073f85eed59c800b7c61901e5c254270497dad64"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.23" version = "0.8.23"
@@ -434,12 +455,32 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.14" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "flate2"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]] [[package]]
name = "font-types" name = "font-types"
version = "0.11.3" version = "0.11.3"
@@ -779,6 +820,26 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -809,6 +870,45 @@ dependencies = [
"jni-sys 0.3.1", "jni-sys 0.3.1",
] ]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "num_enum" name = "num_enum"
version = "0.7.6" version = "0.7.6"
@@ -1104,6 +1204,11 @@ dependencies = [
[[package]] [[package]]
name = "objects" name = "objects"
version = "0.1.0" version = "0.1.0"
dependencies = [
"num-bigint",
"num-rational",
"num-traits",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
@@ -1201,6 +1306,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -1466,6 +1584,12 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]] [[package]]
name = "simd_cesu8" name = "simd_cesu8"
version = "1.2.0" version = "1.2.0"
@@ -1655,10 +1779,12 @@ dependencies = [
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit", "objc2-app-kit",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"png",
"serde", "serde",
"serde_json", "serde_json",
"softbuffer", "softbuffer",
"tiny_http", "tiny_http",
"unicode-linebreak",
"unicode-segmentation", "unicode-segmentation",
"winit", "winit",
] ]
@@ -2325,6 +2451,12 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
+21 -1
View File
@@ -1,3 +1,4 @@
# Cargo 包元数据与编译器最低版本;二进制入口默认取 src/main.rs。
[package] [package]
name = "tessera" name = "tessera"
version = "0.1.0" version = "0.1.0"
@@ -6,24 +7,43 @@ rust-version = "1.91"
description = "Cross-platform desktop rendering client" description = "Cross-platform desktop rendering client"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
# 跨平台运行依赖:语言执行、文字排版、序列化、窗口/像素表面与轻量 HTTP 服务。
# Aster 三个 crate 使用固定在本仓库 vendor 子模块中的本地源码,不从注册表选择同名实现。
[dependencies] [dependencies]
# 应用边界的错误聚合与上下文,保留底层失败原因。
anyhow = "1.0" anyhow = "1.0"
# Aster 字节码编译器,把前端结果交给同一套 VM 执行。
compiler = { path = "vendor/aster/crates/compiler" } compiler = { path = "vendor/aster/crates/compiler" }
# 系统字体发现、塑形与换行,用于测量和软件文字绘制。
cosmic-text = "0.19" cosmic-text = "0.19"
# 解码脚本加载的 PNG 图像,供通用精灵绘制与透明轮廓计算。
png = "0.18"
# 按 Unicode 断行机会准备可复用文字片段,供排除区域绕排。
unicode-linebreak = "0.1.5"
# Aster 托管对象、类型系统、VM 和可恢复调用;依赖包名 core 在 Rust 中重命名为 endor_core。
endor_core = { package = "core", path = "vendor/aster/crates/core" } endor_core = { package = "core", path = "vendor/aster/crates/core" }
# Endor 源语法解析与原始位置诊断。
frontend = { path = "vendor/aster/crates/frontend" } frontend = { path = "vendor/aster/crates/frontend" }
serde = { version = "1.0", features = ["derive"] } # 派生序列化协议,rc 特性允许发布模型中的 Arc/Rc 数据参与序列化。
serde = { version = "1.0", features = ["derive", "rc"] }
# HTTP 请求/响应与事件信封的 JSON 编解码。
serde_json = "1.0" serde_json = "1.0"
# 把宿主软件光栅化得到的像素缓冲呈现到原生窗口。
softbuffer = "0.4.8" softbuffer = "0.4.8"
# 本地 Agent API 的 HTTP 监听与请求响应,不引入异步 Web 运行时。
tiny_http = "0.12" tiny_http = "0.12"
# 按 Unicode 字素边界处理编辑/选择,避免把组合字符拆开。
unicode-segmentation = "1.12" unicode-segmentation = "1.12"
# 跨平台窗口、事件循环、DPI 与输入法事件。
winit = "0.30.13" winit = "0.30.13"
# 仅 macOS 编译原生标题栏适配,显式开启所用 AppKit/Foundation API,其他平台不引入这些依赖。
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5.2" objc2 = "0.5.2"
objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSApplication", "NSButton", "NSEvent", "NSView", "NSWindow"] } objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSApplication", "NSButton", "NSEvent", "NSView", "NSWindow"] }
objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry", "NSString", "NSUserDefaults"] } objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry", "NSString", "NSUserDefaults"] }
# 发布构建:thin LTO 与单代码生成单元偏重最终优化,strip 去除可执行文件符号以控制体积。
[profile.release] [profile.release]
lto = "thin" lto = "thin"
codegen-units = 1 codegen-units = 1
-8
View File
@@ -1,8 +0,0 @@
FROM rust:1.91.1-bookworm
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libx11-dev libxkbcommon-dev libwayland-dev libudev-dev libegl1-mesa-dev libgbm-dev fonts-dejavu-core fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/* \
&& rustup component add rustfmt clippy
ENV CARGO_BUILD_JOBS=1 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0
ENV CARGO_TARGET_DIR=/cargo-target
CMD ["sh", "-eu", "-c", "cargo fmt --check && cargo test --locked && cargo clippy --all-targets --locked -- -D warnings"]
+42 -38
View File
@@ -22,6 +22,14 @@ experimental `namespace` / `export scene` / `render` parser has been removed.
Host UI integration lives in Tessera; Aster needs no Tessera-specific changes. Host UI integration lives in Tessera; Aster needs no Tessera-specific changes.
Lyra CLI and Hestia Server are independent hosts and are not application dependencies. Lyra CLI and Hestia Server are independent hosts and are not application dependencies.
## Endor manuscript example
[illustrated-manuscript.endor](examples/illustrated-manuscript.endor) implements
cursor-following dragon animation, small directional fire and text exclusion in
ordinary Endor. It uses generic graphics resources, source paint, pointer events
and Page-owned Tasks; no native demo mode is required. See [run instructions,
script walkthrough and validation](docs/illustrated-manuscript.md).
## Native build and start ## Native build and start
Use Rust 1.91 or newer. Initialize the source submodule following the dedicated Use Rust 1.91 or newer. Initialize the source submodule following the dedicated
@@ -37,8 +45,9 @@ Cargo compiles the required Aster libraries together with Tessera. No separate
Aster build, service or deployment is required. Local builds download dependencies Aster build, service or deployment is required. Local builds download dependencies
from official crates.io by default. A temporary dependency mirror may be used from official crates.io by default. A temporary dependency mirror may be used
when the official source is unavailable, without changing global configuration. when the official source is unavailable, without changing global configuration.
Docker and automated tests Builds and validation run locally using the native Rust toolchain; no CI/CD
are not used locally. The command above produces a native executable. To create a workflow or Docker toolchain is configured. The command above produces a native
executable. To create a
Finder-launchable development app on macOS, run: Finder-launchable development app on macOS, run:
```bash ```bash
@@ -127,7 +136,7 @@ is 14 logical pixels with a 20-pixel line height; size 2 is 28/40. Row/Column la
uses measured text advances and multiline height. Device scale remains fractional, uses measured text advances and multiline height. Device scale remains fractional,
and glyphs are rasterized at the actual display resolution rather than enlarged. and glyphs are rasterized at the actual display resolution rather than enlarged.
The application does not bundle proprietary OS fonts. Linux installations need The application does not bundle proprietary OS fonts. Linux installations need
fonts such as DejaVu Sans/Mono and Noto CJK; the remote test image installs these. fonts such as DejaVu Sans/Mono and Noto CJK installed locally.
## Candidate execution and publication ## Candidate execution and publication
@@ -170,7 +179,7 @@ candidate and returns 500, retaining the previous document.
This stage supports live mounted UI for both complete programs and Console inputs, with immutable window frames on the existing worker. This stage supports live mounted UI for both complete programs and Console inputs, with immutable window frames on the existing worker.
Native mouse/keyboard activation and code-driven callbacks share onClick/click. Managed Mount objects expose each occurrence, and property dependencies and measurement caches avoid recomputing clean visual subtrees. Arrangement still traverses occurrences. Native mouse/keyboard activation and code-driven callbacks share onClick/click. Managed Mount objects expose each occurrence, and property dependencies and measurement caches avoid recomputing clean visual subtrees. Arrangement still traverses occurrences.
Managed tab chrome and multiple windows are available. Typed capability contracts Sidebar page navigation and multiple windows are available. Typed capability contracts
are installed for host protocols; the complete language/type/effect surface and are installed for host protocols; the complete language/type/effect surface and
process isolation remain incomplete. Worker threads, process isolation remain incomplete. Worker threads,
source budgets and VM instruction limits are not a complete untrusted-code sandbox. source budgets and VM instruction limits are not a complete untrusted-code sandbox.
@@ -234,13 +243,13 @@ The solver detects a previously visited nonconsecutive state as a cycle. It has
Mount.bounds() rejects reads during measurement, including old geometry on an existing Mount. The current occurrence's allocated Bounds become available while its arrange method runs. Structural contexts are prepared before layout, geometry is committed after successful layout, and onMount/onUnmount notifications then run before paint/publication. Both layout phases share one read-only VM call and instruction budget; temporary results can be modified, existing business state cannot. Read versions are captured before hooks, so a hook changing a measured dependency schedules another pass before publication. Dependencies are currently tracked per object. Source measurement results are cached by Mount identity, method, child sizes, constraints and actual object reads, including decoding an existing returned MeasureResult. Cache entries contain borrowed identities and versions, adding no GC roots. Mount-reading measurements currently bypass the cache conservatively because object-level tracking cannot distinguish layoutVersion/geometry from stable structure. Global dictionary writes or changes to another field on a read object can also cause conservative misses. Native measurements keep their shape cache, and arrangement still visits each active occurrence. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses; diagnostic statistics are rejected inside read-only rendering because allocation/count observations are not stable layout inputs. Mount.bounds() rejects reads during measurement, including old geometry on an existing Mount. The current occurrence's allocated Bounds become available while its arrange method runs. Structural contexts are prepared before layout, geometry is committed after successful layout, and onMount/onUnmount notifications then run before paint/publication. Both layout phases share one read-only VM call and instruction budget; temporary results can be modified, existing business state cannot. Read versions are captured before hooks, so a hook changing a measured dependency schedules another pass before publication. Dependencies are currently tracked per object. Source measurement results are cached by Mount identity, method, child sizes, constraints and actual object reads, including decoding an existing returned MeasureResult. Cache entries contain borrowed identities and versions, adding no GC roots. Mount-reading measurements currently bypass the cache conservatively because object-level tracking cannot distinguish layoutVersion/geometry from stable structure. Global dictionary writes or changes to another field on a read object can also cause conservative misses. Native measurements keep their shape cache, and arrangement still visits each active occurrence. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses; diagnostic statistics are rejected inside read-only rendering because allocation/count observations are not stable layout inputs.
`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Dragging a Tab away from its strip invokes that same Page method. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor). `Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor).
`container.insert(position, block)` inserts at a zero-based position from 0 through the current child count and returns a new stable slot. Existing slots and attached Mount identities survive insertion. Invalid positions and cyclic child graphs fail before replacing the child list. `page.moveTab(position)` moves the same Page to a final zero-based position in its owning window; a code reorder does not implicitly activate it. `container.insert(position, block)` inserts at a zero-based position from 0 through the current child count and returns a new stable slot. Existing slots and attached Mount identities survive insertion. Invalid positions and cyclic child graphs fail before replacing the child list. `page.moveTab(position)` moves the same Page to a final zero-based position in its owning window; a code reorder does not implicitly activate it.
Native dragging starts after 12 logical pixels and shows a blue insertion marker. Same-window drops call the actual Page `moveTab` and `activate`; cross-window drops first call `moveToWindow`. Source overrides participate. An override that declines or redirects the window move prevents the remaining destination reorder. Page identity and Page-owned tasks survive movement, while a cross-window move replaces Mounts and cancels old Mount tasks. Invalid source/target generations, layout versions or unpublished tab orders are rejected before callbacks. Callback failure stops the domain; earlier writes do not roll back. Escape cancels a drag. Overflow drops use visible gaps; drag auto-scroll remains pending. See [tab-placement.endor](examples/tab-placement.endor). The version-checked tab-drop transport remains available for managed Page movement. Same-window requests call moveTab/activate and cross-window requests call moveToWindow first. Source overrides participate; generation/layout mismatches reject before callbacks. The sidebar currently supports selection/closure, with pointer dragging deferred. See [tab-placement.endor](examples/tab-placement.endor).
`window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. A detached tab uses the release point and saved grab offset to place its new window. The window manager may adjust placement. Global coordinates are required for cross-window drops; unsupported native placement reports a diagnostic. This host tracks its own focus order when resolving overlapping windows; external-app occlusion and mixed-DPI dragging have not been fully verified. `window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. The window manager can adjust placement; unsupported native placement reports a diagnostic. Sidebar cross-window pointer dragging and mixed-DPI gestures remain unverified.
`window.close()` and a secondary native close button call the same managed method. Closing first removes Page ownership, then commits unmounts and removes that context from the runtime registry; the native resource disappears after successful publication. Retained Pages can be opened in another live Window; otherwise Aster reclaims them normally. Window resource objects may remain referenced after closing, but reject resource operations; id/pages remain queryable. A retained leaf Mount uses a weak context link and cannot keep a closed window alive through its query methods. The primary shell still defines the application's lifetime: closing its OS window exits Tessera. Full application-exit notifications remain follow-up work. `window.close()` and a secondary native close button call the same managed method. Closing first removes Page ownership, then commits unmounts and removes that context from the runtime registry; the native resource disappears after successful publication. Retained Pages can be opened in another live Window; otherwise Aster reclaims them normally. Window resource objects may remain referenced after closing, but reject resource operations; id/pages remain queryable. A retained leaf Mount uses a weak context link and cannot keep a closed window alive through its query methods. The primary shell still defines the application's lifetime: closing its OS window exits Tessera. Full application-exit notifications remain follow-up work.
@@ -248,9 +257,9 @@ A secondary window can also be closed after a callback/rendering failure or Cons
Each window has its own Presentation, layout/Mount/focus state, caches and native viewport. A single VM change batch fans out to all views, all affected frames settle before publication, and shared business references are never copied. Frames and native inputs carry window/session/layout identities; stale requests are rejected. Existing event clients default to window 1, exposed by X-Tessera-Window. Secondary native resize feeds the same worker and updates only its window constraints. Console reset preserves all old pictures with input disabled; replacement sessions discard the old native resources before projecting new resources with the same numeric IDs. Each window has its own Presentation, layout/Mount/focus state, caches and native viewport. A single VM change batch fans out to all views, all affected frames settle before publication, and shared business references are never copied. Frames and native inputs carry window/session/layout identities; stale requests are rejected. Existing event clients default to window 1, exposed by X-Tessera-Window. Secondary native resize feeds the same worker and updates only its window constraints. Console reset preserves all old pictures with input disabled; replacement sessions discard the old native resources before projecting new resources with the same numeric IDs.
Open Pages appear in the native shell tab bar. Set `page.title` to label a tab; omitted titles use their open-order number. Clicking a tab or its close control calls the actual Page's `activate()` or `close()` method in the existing VM; source overrides participate. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, and Cmd/Ctrl+W closes the active page when the page has keyboard focus. Overflow arrows expose additional pages. Closed Pages follow managed references and GC; a retained Page can be reopened. The tab projection contains opaque session-local IDs and text, with weak reverse lookup in the runtime, and adds no Page roots. Requests carry the published layout version; an old tab event is rejected. Switching creates new presentation Mounts while preserving the business objects. See [tabs.endor](examples/tabs.endor). Open Pages appear in the left sidebar. Set `page.title` to label a page; clicking its row or close control calls the actual Page `activate()` or `close()` method in the same VM. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, Cmd/Ctrl+T creates one and Cmd/Ctrl+W closes the active page. Sidebar rows scroll when needed. Source overrides, managed references, GC and stale-event rejection remain in use. See [tabs.endor](examples/tabs.endor).
The macOS title strip occupies 44 logical pixels and shares its row with the native window controls. The left rail and title strip form one neutral chrome surface. Content is inset by a 56-pixel rail, an 8-pixel top gap, and 8 pixels at the right and bottom. Page constraints use the remaining height; published Mount geometry and event coordinates remain relative to the content area. Window viewport snapshots continue to report the full native inner size. The host translates drawing and native pointer coordinates at this boundary. Console reset preserves the last picture with input disabled until a new live frame is published. The host has one collapsible left sidebar, with page navigation and Console/Sources/Network/Settings. Cmd/Ctrl+B toggles it. Collapsed, the host draws only its expand button alongside the three real macOS window controls; content fills the native inner window with no top strip or surrounding inset. Only this small control area intercepts page input. `ui.viewport()` / `window.viewport()` and the API viewport headers report the available page area, including the width recovered when the sidebar collapses. Page drawings and hit tests share one camera transform. See [page surfaces](docs/page-surfaces.md).
`window.resize(width, height)`, `setTitle(text)` and `focus()` project requests to the GUI thread. `id()` returns an opaque runtime resource ID and `viewport()` reports actual logical dimensions. Requests are asynchronous and coalesced by kind; queried dimensions change when the native window reports them, and platform bounds can adjust requested sizes. This host accepts sizes from 1 to 4096 logical pixels per axis, with a native minimum of 720 by 480. Titles are UTF-8 text up to 4096 bytes with no control characters. See [window.endor](examples/window.endor). `window.resize(width, height)`, `setTitle(text)` and `focus()` project requests to the GUI thread. `id()` returns an opaque runtime resource ID and `viewport()` reports actual logical dimensions. Requests are asynchronous and coalesced by kind; queried dimensions change when the native window reports them, and platform bounds can adjust requested sizes. This host accepts sizes from 1 to 4096 logical pixels per axis, with a native minimum of 720 by 480. Titles are UTF-8 text up to 4096 bytes with no control characters. See [window.endor](examples/window.endor).
@@ -258,7 +267,9 @@ Native creation and commands remain local until the input/candidate succeeds. Fa
The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame. The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame.
The default Column/Button policy propagates available cross-axis width minus padding and leaves height unconstrained; Row propagates available height and leaves width unconstrained. Page uses both viewport bounds. Native and source desired sizes are clamped to their supplied constraints, while source arrange can still allocate explicit child bounds. The window clips overflow and pointer targeting to its extent; TextBlock wraps to its available width; source layout now settles constraints over repeated measurement/arrangement passes as described below. See [layout-cache.endor](examples/layout-cache.endor). GET code also exposes X-Tessera-Viewport-Width/Height headers without evaluating source. Row, Column and Overlay can nest with main/cross alignment, per-child alignment, margin, asymmetric border/padding, fixed/auto/fill sizes and min/max constraints. Page owns the root; scrolling is a finite ScrollView around ordinary layout. Scene2D owns world positions and CanvasViewport provides a finite UI box with an explicit Camera2D. Multiple views can share a scene while retaining independent cameras; assigning the same camera explicitly shares navigation. The old `Page.mode` API remains for compatibility. See [scene architecture](docs/scene-architecture.md), [box layout](examples/box-layout.endor) and [scene viewports](examples/scene-viewports.endor).
Scene3D, Group3D, mesh geometry, lights and programmable perspective/orthographic cameras render through finite Viewport3D boxes. OrbitController handles rotation, pan and zoom and can be replaced by an Endor controller. Ray picking and world-space Panel3D UI use the same transforms and depth visibility. A panel can contain ordinary UI, a 2D canvas or another 3D viewport. Rendering currently uses the existing CPU compositor with bounded depth/texture surfaces; this is not a GPU renderer. See [3D scenes](examples/scene3d.endor) and [world UI](examples/world-ui.endor).
Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor). Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor).
@@ -274,25 +285,11 @@ A source subclass can override `paint(self, mount, geometry): DisplayList`. The
Renderer-dispatched paint runs in a read-only Aster call: existing heap objects cannot be modified; fresh result graphs may be allocated and filled. Existing globals, model fields, containers, Mounts and types remain protected. invalidate is rejected in this phase. All overrides share one VM budget. Read dependencies are currently recorded per object, with versions used to detect changes without depending on bounded change-notification delivery. Native frames keep their existing dependency/cache behavior. Derived allocations do not produce application change notifications, preventing temporary results from causing redraw feedback. This protects managed heap writes; trusted Rust host implementations must also respect read-only calls for external state and I/O. Paint errors stop the affected runtime before publication; failed PUT candidates preserve the previously running heap and frame. Renderer-dispatched paint runs in a read-only Aster call: existing heap objects cannot be modified; fresh result graphs may be allocated and filled. Existing globals, model fields, containers, Mounts and types remain protected. invalidate is rejected in this phase. All overrides share one VM budget. Read dependencies are currently recorded per object, with versions used to detect changes without depending on bounded change-notification delivery. Native frames keep their existing dependency/cache behavior. Derived allocations do not produce application change notifications, preventing temporary results from causing redraw feedback. This protects managed heap writes; trusted Rust host implementations must also respect read-only calls for external state and I/O. Paint errors stop the affected runtime before publication; failed PUT candidates preserve the previously running heap and frame.
The application starts with one managed New tab. The + button and Cmd/Ctrl+T The application starts with an Endor Page containing a Scene2D and CanvasViewport. Its four actions demonstrate a window-sized layout, vertically scrolling Column, horizontally scrolling Row and independent canvas. The default page is implemented in [start_page.endor](src/bridge/ui_bridge/start_page.endor), compiled once per VM in a private namespace. `ui.newTab()` and the sidebar New page action use the same factory. Cards, copy, layout, actions and the shared-state example are Endor objects. The host owns the sidebar and native window controls.
create additional managed Pages; Cmd/Ctrl+W closes the active Page. New tabs expand
from left to right while existing tabs interpolate to their new bounds when space
becomes limited. Hover and selection use the same capsule geometry, and animated
pointer hits use the rendered bounds. Source and page identity remain in the VM;
chrome animation retains only projected scalar IDs and rectangles. Tabs are capped at
208 logical pixels wide, with a 40-pixel strip and equal 4-pixel vertical insets.
The leading page symbol and close control have matching slots and edge spacing.
The default page is implemented in `src/bridge/ui_bridge/start_page.endor`, compiled once
per VM into a private namespace. Its buttons create a Page, open a working shared
counter, or show composed Blocks. `ui.newTab()` constructs the same ordinary Page
used by native + and the keyboard shortcut. Layout, callbacks, state and bindings
are Endor objects; the host does not paint an introduction over the guest page.
Console, Sources, Network and Settings are independent managed Pages with separate Console, Sources, Network and Settings are independent managed Pages with separate
view modules and window-local input/focus/scroll/selection state. They are not view modules and window-local input/focus/scroll/selection state. They are not
opened by default and have no permanent rail/menu launchers. Their future opened by default; the left sidebar provides their launchers. Existing integrations can call
discovery UI is intentionally deferred. Existing integrations can call
`ui.developerTools("Console")`, `"Sources"`, `"Network"` or `"Settings"`. `ui.developerTools("Console")`, `"Sources"`, `"Network"` or `"Settings"`.
F12, Cmd-Option-I and Cmd-Shift-I on macOS, or Ctrl-Shift-I elsewhere, F12, Cmd-Option-I and Cmd-Shift-I on macOS, or Ctrl-Shift-I elsewhere,
open/focus Console. Page closure, placement and GC use the ordinary Page methods. open/focus Console. Page closure, placement and GC use the ordinary Page methods.
@@ -410,17 +407,24 @@ UTF-8 even when launched from Finder, and decoding errors are surfaced.
## Validation ## Validation
Local native builds and window runs are allowed; do not run local automated tests Run checks from the repository root after initializing the pinned Aster submodule:
or Docker. [Remote CI](.gitea/README.md) on ces-control runs the existing renderer
checks and the new Aster script/module execution, page rendering, UI layout, source-range, lifetime, budget, ```bash
failed-update and deletion-invalidation regressions, plus Clippy. cargo fmt --check
The API regressions use actual HTTP requests, including DELETE while the worker cargo test --locked
is occupied and a queued PUT is superseded. cargo clippy --all-targets --locked -- -D warnings
Linux CI does not verify macOS window presentation. Aster has no standalone CI/CD; Lyra CI sh -n scripts/build-macos-app.sh
explicitly validates the pinned engine itself. CI uses official Cargo sources ```
and standard HTTPS checkout without custom SSH secrets. Font checks cover
proportional/monospace advances, fractional scaling, multiline height, Chinese The existing tests cover rendering, Aster execution, page layout, source ranges,
fallback, grapheme-safe truncation, antialiasing and alpha blending. lifetime, budgets, failed-update preservation and deletion invalidation. API tests
use loopback HTTP, so allow local socket access. Font checks use installed system
fonts. Automated checks do not replace native window verification: launch Tessera
and check the start page, shared-object example, tab creation/closure and developer
tools. Build the macOS bundle separately with `sh scripts/build-macos-app.sh`.
There is no repository CI/CD workflow. Commits and pull requests do not trigger
builds or deployment; record the actual local check results with each delivery.
### Source function types ### Source function types
@@ -599,7 +603,7 @@ Inside a function run by ui.spawn, `value = await task` waits for any timer/file
A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active polling. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model. A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active polling. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model.
While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit cleanup is described below. The former horizontal tab-strip gestures and animation have been replaced by sidebar page navigation. Page.moveTab, moveToWindow and detachToWindow remain available through Endor/API; sidebar pointer drag/reorder and cross-window drag are not implemented in this revision.
Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work. Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work.
+3 -3
View File
@@ -7,7 +7,7 @@ Tessera/
├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果 ├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果
├── src/ ├── src/
│ ├── main.rs 组合入口、事件循环和共享发布状态 │ ├── main.rs 组合入口、事件循环和共享发布状态
│ ├── shell/ 原生窗口、标签、开发工具、管理控件 │ ├── shell/ 原生窗口、可收起侧栏、开发工具、管理控件
│ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务 │ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务
│ ├── model/ 托管对象、挂载、绑定、依赖和发布数据 │ ├── model/ 托管对象、挂载、绑定、依赖和发布数据
│ ├── render/ 布局、绘制、命中、字体与软件光栅化 │ ├── render/ 布局、绘制、命中、字体与软件光栅化
@@ -16,7 +16,7 @@ Tessera/
├── docs/ ├── docs/
│ ├── architecture/ 本套架构说明和 UML 源文件 │ ├── architecture/ 本套架构说明和 UML 源文件
│ └── media/ 已有建模视频与字幕 │ └── media/ 已有建模视频与字幕
├── scripts/ 原生打包及已有远程检查入口 ├── scripts/ 本地原生打包及 UML 文档渲染
└── vendor/aster/ 独立 Git 子模块:语言、编译器、VM └── vendor/aster/ 独立 Git 子模块:语言、编译器、VM
``` ```
@@ -36,7 +36,7 @@ Tessera/
| 执行位置 | 核心所有物 | 交互方式 | | 执行位置 | 核心所有物 | 交互方式 |
| --- | --- | --- | | --- | --- | --- |
| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、标签动画、工具交互 | 读已发布快照;发 viewport、输入和窗口命令 | | GUI / winit 主线程 | TesseraApp、原生 Window、Surface、侧栏、页面视角交互 | 读已发布快照;发 viewport、输入和窗口命令 |
| `tessera-agent-api` | tiny_http 接收循环、请求观察 | 对写入做校验并进入最多 8 条的构建队列;读取当前发布状态 | | `tessera-agent-api` | tiny_http 接收循环、请求观察 | 对写入做校验并进入最多 8 条的构建队列;读取当前发布状态 |
| `tessera-endor-worker` | 活跃 ReplSession、候选 VM、托管对象图 | 串行执行程序、回调、布局/绘制协议及任务完成交付 | | `tessera-endor-worker` | 活跃 ReplSession、候选 VM、托管对象图 | 串行执行程序、回调、布局/绘制协议及任务完成交付 |
| 输入发送线程 | 有界输入客户端 | 把原生输入转为本机 HTTP 请求;不执行 VM | | 输入发送线程 | 有界输入客户端 | 把原生输入转为本机 HTTP 请求;不执行 VM |
+11 -1
View File
@@ -26,7 +26,17 @@
4. [建模视频](../media/endor-campus-modeling.mp4) 与 [中文字幕](../media/endor-campus-modeling.zh.srt):已有实际窗口操作记录,静音、采样帧率有说明。 4. [建模视频](../media/endor-campus-modeling.mp4) 与 [中文字幕](../media/endor-campus-modeling.zh.srt):已有实际窗口操作记录,静音、采样帧率有说明。
5. `GET /agent`:运行中的实时 API 指南,优先于长期静态样例推断接口。 5. `GET /agent`:运行中的实时 API 指南,优先于长期静态样例推断接口。
## 本轮验证方式 ## 当前本地开发方式(2026-11-10)
页面/宿主设计更新:支持 window、vertical、horizontal、canvas 四种空间;顶部标签条由可收起左侧栏替代。实现、测试与原生验收边界见 [页面空间说明](../page-surfaces.md)。下文 150 项测试及原窗口结构属于前一次移除 CI 的交付记录。
项目已移除 CI/CD 工作流、Docker 工具链定义及远程验证脚本。开发、构建、现有测试与原生窗口验收改在本机执行,命令见 [项目验证说明](../../README.md#validation)。以下保留 2026-10-25 整理时的验收记录,不代表本次检查结果。
本次使用 macOS / Rust 1.91.1:格式检查、150 项现有测试、all-target Clippy(拒绝 warnings)、打包脚本语法检查及 release app 构建均通过。应用已启动,`GET /agent` 与 `GET /code` 返回正常,窗口报告 1280 × 800 的逻辑 viewport;通过实际 REPL 创建/关闭临时标签页,页面数为 1 → 2 → 1。首次页面查询误用了不存在的 `ui.pages()`,返回 422;改为 `ui.window().pages().count` 后通过。原生 UI 检查工具启动失败,本次没有窗口外观、真实鼠标交互或跨屏验收证据。Aster 保持固定版本,未修改源码或 gitlink。
下一步优先补齐真实窗口的共享对象、标签拖放和退出清理验收,然后定义本地 `.endor` 文件的打开、保存及重启恢复方式。当前 Sources 的云文件 provider 默认断开,源码/设置尚不持久化。此外,在线 API 指南仍有旧的标签高度、overflow 箭头和增量布局状态说明,需要与现有实现及测试统一。
## 2026-10-25 整理验证方式
本轮是整理,不扩展业务功能。沿用工作约定:格式、`cargo clippy --all-targets --offline -- -D warnings`、原生 release 打包、真实窗口和少量人工 API 操作;不执行 cargo test、自动 smoke 脚本或 Docker。Clippy 会编译检查已有测试代码,但不代表测试已经执行。 本轮是整理,不扩展业务功能。沿用工作约定:格式、`cargo clippy --all-targets --offline -- -D warnings`、原生 release 打包、真实窗口和少量人工 API 操作;不执行 cargo test、自动 smoke 脚本或 Docker。Clippy 会编译检查已有测试代码,但不代表测试已经执行。
+173
View File
@@ -0,0 +1,173 @@
# Tessera 中文源码注释与阅读索引
本轮进一步细化 Tessera 自有代码的中文注释:覆盖所有 Rust 模块、运行时/API 测试、内置起始页、45 个 Endor 示例、Cargo 依赖配置及两个工具脚本。说明聚焦具体输入/输出、状态归属、计算步骤、依赖观察、取消/失败路径和资源预算;不修改接口、算法、常量或非注释程序文本。Aster 是独立维护的 vendor 子模块,其固定版本与内容沿用主线。
## 建议阅读顺序
1. [main](../src/main.rs):创建原生循环、共享文档与执行 worker。
2. [原生应用](../src/shell/app.rs):理解窗口生命周期、可用视口、输入边界和最终 present。
3. [API worker](../src/runtime/api/worker.rs) 与 [运行时](../src/runtime/endor_host.rs):理解候选/活跃 VM、输入串行化、分片刷新和发布。
4. [桥接层](../src/bridge/ui_bridge.rs)、[Block](../src/model/block.rs) 与 [Mount](../src/model/mount.rs):区分共享业务对象、每次出现和展示事务。
5. [快照](../src/model/snapshot.rs)、[源布局](../src/render/source_layout.rs) 和 [源绘制](../src/render/source_paint.rs):理解依赖缓存、只读执行及挂起工作。
6. [二维视口](../src/render/viewport.rs)、[3D](../src/render/scene3d.rs) 和 [面板映射](../src/render/scene3d/panel.rs):理解布局/世界/视图的边界。
7. [任务](../src/runtime/tasks.rs)、[provider](../src/runtime/io_tasks.rs) 和 [事件传播](../src/runtime/event_dispatch.rs):理解异步交付资格与生命周期。
## 阅读时需要保留的边界
- Block/Data 属于共享业务组成;Mount 属于一个展示位置,不能把一次出现的尺寸写回共享对象来代替排版。
- ObjectRef/WeakObjectRef 的含义由真实托管拥有关系决定;派生缓存中的身份索引不能成为隐藏根。
- 窗口线程消费完整发布值;VM、源回调和托管堆操作只在执行 worker 内推进。
- submission 防止旧请求覆盖新输入;session 防止跨 VM 资源误用;layout_version 防止旧几何上的离散操作误触。
- Pending Work 同时拥有续体与上下文;取消必须关闭原 VM 的只读执行并恢复未提交 Mount,不能只丢弃 Rust 值。
- 场景世界不是无限尺寸的排版盒子;有限视口参加布局,摄像机负责投影,命中沿相同链逆变换。
- 现有 CPU 绘制、缓存范围与输入队列限制仍按当前代码执行;说明性注释不代表性能优化已实现。
## Rust 逐文件说明
每行说明该文件本次深化注释的职责和目的。生产函数/方法的入口说明与内部关键逻辑互相补充;VM 可调用适配器说明实际参数约定,而非仅重复 trait 名称。集成测试中的每个顶层测试/辅助函数都有具体场景和断言说明,测试程序字符串与断言保持原样。
| 文件 | 注释解释的职责与目的 |
| --- | --- |
| [src/bridge/mod.rs](../src/bridge/mod.rs) | 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。 |
| [src/bridge/ui_bridge.rs](../src/bridge/ui_bridge.rs) | Endor 与 Tessera 的展示事务协调层:维护每个窗口的 Page、Mount、布局缓存和发布版本。 |
| [src/bridge/ui_bridge/bootstrap.rs](../src/bridge/ui_bridge/bootstrap.rs) | 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。 |
| [src/bridge/ui_bridge/calls.rs](../src/bridge/ui_bridge/calls.rs) | 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。 |
| [src/bridge/ui_bridge/developer_pages.rs](../src/bridge/ui_bridge/developer_pages.rs) | 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。 |
| [src/bridge/ui_bridge/page_input.rs](../src/bridge/ui_bridge/page_input.rs) | 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。 |
| [src/bridge/ui_bridge/signatures.rs](../src/bridge/ui_bridge/signatures.rs) | 声明宿主方法在 Endor 类型系统中的参数和返回契约。 |
| [src/bridge/ui_bridge/tree.rs](../src/bridge/ui_bridge/tree.rs) | 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。 |
| [src/main.rs](../src/main.rs) | 应用组合入口。先创建原生事件循环和共享发布状态,再启动 API/VM worker,最后把窗口事件交给 TesseraApp。 |
| [src/model/binding.rs](../src/model/binding.rs) | 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。 |
| [src/model/block.rs](../src/model/block.rs) | Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。 |
| [src/model/document.rs](../src/model/document.rs) | 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。 |
| [src/model/mod.rs](../src/model/mod.rs) | 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。 |
| [src/model/mount.rs](../src/model/mount.rs) | 每次展示的托管上下文:同一个 Block 出现在两个位置时,有两个独立 Mount。 |
| [src/model/object_list.rs](../src/model/object_list.rs) | 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。 |
| [src/model/presentation.rs](../src/model/presentation.rs) | 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。 |
| [src/model/protocols.rs](../src/model/protocols.rs) | 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。 |
| [src/model/scene3d.rs](../src/model/scene3d.rs) | 将 Endor 托管场景对象转换成可供渲染线程读取的 3D 数据。 |
| [src/model/snapshot.rs](../src/model/snapshot.rs) | 从可变托管对象图提取不可变 UiNode,并维护反向依赖与父子失效传播。 |
| [src/model/window.rs](../src/model/window.rs) | 托管 Window 的页面所有权、原生命令与关闭生命周期。 |
| [src/render/box_model.rs](../src/render/box_model.rs) | 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。 |
| [src/render/display_list.rs](../src/render/display_list.rs) | 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。 |
| [src/render/geometry.rs](../src/render/geometry.rs) | 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。 |
| [src/render/layout.rs](../src/render/layout.rs) | 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。 |
| [src/render/mod.rs](../src/render/mod.rs) | 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。 |
| [src/render/native_visual.rs](../src/render/native_visual.rs) | 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。 |
| [src/render/page.rs](../src/render/page.rs) | 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。 |
| [src/render/scene.rs](../src/render/scene.rs) | 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。 |
| [src/render/scene3d.rs](../src/render/scene3d.rs) | 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。 |
| [src/render/scene3d/math.rs](../src/render/scene3d/math.rs) | 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。 |
| [src/render/scene3d/panel.rs](../src/render/scene3d/panel.rs) | 连接 3D 平面与普通 UI 的坐标和绘制桥梁。 |
| [src/render/scene3d/spatial.rs](../src/render/scene3d/spatial.rs) | 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。 |
| [src/render/scroll.rs](../src/render/scroll.rs) | 逐 Mount 的滚动状态、裁剪和输入目标选择。 |
| [src/render/software_renderer.rs](../src/render/software_renderer.rs) | 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。 |
| [src/render/source_hit.rs](../src/render/source_hit.rs) | 通过真正的源/原生 hitTest 协议确定指针目标。 |
| [src/render/source_layout.rs](../src/render/source_layout.rs) | 源 measure/arrange 协议的分片布局驱动器。自底向上的测量与自顶向下的安排共享一个只读 VM 调用域。 |
| [src/render/source_paint.rs](../src/render/source_paint.rs) | 执行源 paint 覆盖并解码受检查的 PaintCommand。 |
| [src/render/text.rs](../src/render/text.rs) | 统一字体发现、文字整形、测量与抗锯齿字形绘制。 |
| [src/render/viewport.rs](../src/render/viewport.rs) | 独立 2D 相机与可逆投影。视口尺寸属于布局;平移、缩放属于视图,不改变世界对象的逻辑位置。 |
| [src/render/viewport/spatial.rs](../src/render/viewport/spatial.rs) | 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。 |
| [src/runtime/api.rs](../src/runtime/api.rs) | 本机 HTTP API 入口:路由请求、限制输入大小、观察活动,并将执行工作送入串行 worker。 |
| [src/runtime/api/guide.rs](../src/runtime/api/guide.rs) | 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。 |
| [src/runtime/api/tests.rs](../src/runtime/api/tests.rs) | API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。 |
| [src/runtime/api/worker.rs](../src/runtime/api/worker.rs) | 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。 |
| [src/runtime/endor_host.rs](../src/runtime/endor_host.rs) | 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。 |
| [src/runtime/endor_host/tests.rs](../src/runtime/endor_host/tests.rs) | 说明全部运行时测试和辅助函数的场景/断言,并解释新主线性能基准的 DPI 分组、预热、三类计时边界和统计方式。 |
| [src/runtime/endor_script.rs](../src/runtime/endor_script.rs) | 区分完整 Module 与顶层 Script,并保留输入源码位置。 |
| [src/runtime/event_dispatch.rs](../src/runtime/event_dispatch.rs) | 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。 |
| [src/runtime/events.rs](../src/runtime/events.rs) | 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。 |
| [src/runtime/io_tasks.rs](../src/runtime/io_tasks.rs) | 后台文件/网络 provider。线程池只携带纯数据、取消控制与弱 Task 身份,不持有 VM。 |
| [src/runtime/mod.rs](../src/runtime/mod.rs) | 运行时模块入口。VM 生命周期、API、源程序组织、事件与异步任务在这里组合。 |
| [src/runtime/tasks.rs](../src/runtime/tasks.rs) | owner/key 管理的异步任务与协作式源函数调度。弱索引用于查找,回调、等待对象和协程帧是托管图中的真实边。 |
| [src/runtime/vm_sequence.rs](../src/runtime/vm_sequence.rs) | 构造共享 VM 调用序列与同帧循环驱动的字节码。 |
| [src/shell/activity.rs](../src/shell/activity.rs) | 有界 HTTP 活动记录。保存请求/响应的可显示预览供 Network 工具读取,不负责执行请求。 |
| [src/shell/app.rs](../src/shell/app.rs) | winit 原生应用生命周期和窗口事件入口。这里创建窗口、发布逻辑视口、转发输入,并把不可变文档画到原生表面。 |
| [src/shell/clipboard.rs](../src/shell/clipboard.rs) | 把 UTF-8 文本读写适配到当前平台的系统剪贴板命令。 |
| [src/shell/developer_tools.rs](../src/shell/developer_tools.rs) | Console/Sources/Network/Settings 的宿主交互协调与绘制状态。 |
| [src/shell/developer_tools/console.rs](../src/shell/developer_tools/console.rs) | 绘制 Console 输入、结果历史和对应命中区域。 |
| [src/shell/developer_tools/network.rs](../src/shell/developer_tools/network.rs) | 绘制已观察到的 HTTP 活动、请求列表与详情。 |
| [src/shell/developer_tools/painter.rs](../src/shell/developer_tools/painter.rs) | 开发工具画面的绘制辅助:文字行、按钮、分隔线和滚动条。 |
| [src/shell/developer_tools/selection.rs](../src/shell/developer_tools/selection.rs) | 多行文本选择和光标导航。选择端点映射到合法字符边界,避免把 UTF-8 字节中间当成一个字符。 |
| [src/shell/developer_tools/settings.rs](../src/shell/developer_tools/settings.rs) | 绘制宿主设置页面和主题选择区域。 |
| [src/shell/developer_tools/sources.rs](../src/shell/developer_tools/sources.rs) | 绘制 AST、当前源码快照和云文件视图。 |
| [src/shell/mod.rs](../src/shell/mod.rs) | 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。 |
| [src/shell/native_chrome.rs](../src/shell/native_chrome.rs) | macOS 原生标题栏适配。定位系统窗口按钮并沿用用户的标题栏双击设置。 |
| [src/shell/sidebar.rs](../src/shell/sidebar.rs) | 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。 |
| [src/shell/sources.rs](../src/shell/sources.rs) | 云文件浏览的 provider 抽象与工作区状态。默认 provider 未连接,不假装已有外部文件。 |
| [src/shell/system_ui/interaction.rs](../src/shell/system_ui/interaction.rs) | 即时绘制控件的跨帧交互状态:稳定 WidgetId、悬停、按下捕获、键盘焦点和释放激活。 |
| [src/shell/system_ui/mod.rs](../src/shell/system_ui/mod.rs) | 宿主管理控件的共享组件入口。主题、绘制原语和跨帧交互状态分离,用户 Endor 页面不依赖这里的控件树。 |
| [src/shell/system_ui/theme.rs](../src/shell/system_ui/theme.rs) | 宿主控件的颜色与状态调色。根据背景、强调色和交互状态生成可读颜色,不改变业务状态。 |
| [src/shell/system_ui/widgets.rs](../src/shell/system_ui/widgets.rs) | 用统一主题绘制宿主管理控件,并同步注册交互目标。 |
| [src/shell/tabs.rs](../src/shell/tabs.rs) | 发布页面标签的数据结构。保留稳定页面身份、标题、选中状态和开发工具类别;页面的托管所有权由模型层管理。 |
## 其他文件
| 文件 | 本次改动及目的 |
| --- | --- |
| [src/bridge/ui_bridge/start_page.endor](../src/bridge/ui_bridge/start_page.endor) | 解释卡片源测量/背景、页面工厂、纵横布局与滚动组合、共享计数和场景位置,便于从真实 Endor 页面理解宿主边界。 |
| [scripts/build-macos-app.sh](../scripts/build-macos-app.sh) | 说明 macOS 构建环境前提、失败退出、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 |
| [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 |
| [Cargo.toml](../Cargo.toml) | 解释每个依赖负责的能力、Aster 本地路径依赖、macOS 条件依赖与 release 优化配置。 |
| [docs/code-comments.md](code-comments.md) | 更新本逐文件说明、示例索引和实际核对方式,使本次注释范围与可验证结果对应。 |
## Endor 示例逐文件说明
每个示例的类、方法及关键逻辑旁增加中文说明。`page.endor` 和 `hello.endor` 被测试固定断言源码行号,因此使用行尾注释,保留原始行数;其余示例使用独立注释行,程序符号与字面量不变。
| 文件 | 注释解释的场景与目的 |
| --- | --- |
| [examples/async-file.endor](../examples/async-file.endor) | 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。 |
| [examples/async-network.endor](../examples/async-network.endor) | 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。 |
| [examples/await-file.endor](../examples/await-file.endor) | 把 callback-free 文件 Task 与 ui.spawn 协作函数组合:await 保存执行位置,等待完成后继续,不阻塞 VM worker。 |
| [examples/box-layout.endor](../examples/box-layout.endor) | 盒模型组合:容器与 Page/相机独立,border-box 尺寸、padding、margin 和对齐分别参与布局。 |
| [examples/campus-lms.endor](../examples/campus-lms.endor) | 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。 |
| [examples/classes.endor](../examples/classes.endor) | 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。 |
| [examples/constructor-contracts.endor](../examples/constructor-contracts.endor) | 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。 |
| [examples/cooperative-tasks.endor](../examples/cooperative-tasks.endor) | 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。 |
| [examples/events.endor](../examples/events.endor) | 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。 |
| [examples/exit-lifecycle.endor](../examples/exit-lifecycle.endor) | 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。 |
| [examples/field-contracts.endor](../examples/field-contracts.endor) | 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。 |
| [examples/flex-constraints.endor](../examples/flex-constraints.endor) | flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。 |
| [examples/flex.endor](../examples/flex.endor) | 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。 |
| [examples/fonts.endor](../examples/fonts.endor) | 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。 |
| [examples/function-types.endor](../examples/function-types.endor) | Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。 |
| [examples/gc-lifecycle.endor](../examples/gc-lifecycle.endor) | 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。 |
| [examples/global-dependencies.endor](../examples/global-dependencies.endor) | 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。 |
| [examples/hello.endor](../examples/hello.endor) | 解释辅助 heading、兼容 main 入口、局部业务引用和原生布局,保留源码位置夹具。 |
| [examples/layout-cache.endor](../examples/layout-cache.endor) | 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。 |
| [examples/layout-negotiation.endor](../examples/layout-negotiation.endor) | 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。 |
| [examples/main.endor](../examples/main.endor) | 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。 |
| [examples/managed-types.endor](../examples/managed-types.endor) | 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。 |
| [examples/method-contracts.endor](../examples/method-contracts.endor) | 方法覆盖遵守参数可更宽、返回可更具体的规则。 |
| [examples/mount-lifecycle.endor](../examples/mount-lifecycle.endor) | 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。 |
| [examples/native-signatures.endor](../examples/native-signatures.endor) | 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。 |
| [examples/native-subclasses.endor](../examples/native-subclasses.endor) | 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。 |
| [examples/owned-tasks.endor](../examples/owned-tasks.endor) | owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。 |
| [examples/page.endor](../examples/page.endor) | 解释顶层脚本创建文字/矩形、Page 与 render 的关系,保留四行结构树夹具。 |
| [examples/presentable.endor](../examples/presentable.endor) | 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。 |
| [examples/property-bindings.endor](../examples/property-bindings.endor) | 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。 |
| [examples/scene-viewports.endor](../examples/scene-viewports.endor) | 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。 |
| [examples/scene3d.endor](../examples/scene3d.endor) | 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。 |
| [examples/scroll-axis.endor](../examples/scroll-axis.endor) | 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。 |
| [examples/scroll.endor](../examples/scroll.endor) | 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。 |
| [examples/source-hit.endor](../examples/source-hit.endor) | 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。 |
| [examples/source-layout.endor](../examples/source-layout.endor) | 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。 |
| [examples/source-paint.endor](../examples/source-paint.endor) | 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。 |
| [examples/student-grades.endor](../examples/student-grades.endor) | 学生成绩中心为纯内存演示,记录均虚构,不连接学校数据库。 |
| [examples/tab-placement.endor](../examples/tab-placement.endor) | 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。 |
| [examples/tabs.endor](../examples/tabs.endor) | 代码与原生侧栏都操作真实 Page 对象;activate/close/open 改变展示关系,业务对象可继续被全局引用保留。 |
| [examples/visual-protocols.endor](../examples/visual-protocols.endor) | 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。 |
| [examples/window.endor](../examples/window.endor) | 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。 |
| [examples/windows.endor](../examples/windows.endor) | 两个原生窗口共用一个 Aster VM 和 count 业务对象,各自拥有独立 Page 与 Mount。 |
| [examples/workspace.endor](../examples/workspace.endor) | 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。 |
| [examples/world-ui.endor](../examples/world-ui.endor) | 世界空间 UI:普通 Endor 树先在独立 UI 分辨率中布局/绘制,再作为 Panel3D 平面的纹理参与三维深度。 |
| [examples/wrapped-text.endor](../examples/wrapped-text.endor) | 多窗口共享长段落:同一文字对象在每个 Mount 按当前宽度独立换行,后继位置跟随实际高度。 |
## 核对方式与边界
- 与本次分支起点及最新主线比较,逐文件核对移除注释后的文本;Rust/Endor 另做词法扫描,完整保留字符串/原始字符串内容,确认新增说明没有进入程序字面量。
- 使用 rustfmt 的 `--emit stdout`、`skip_children=true` 逐文件做 Rust 语法解析,丢弃输出,不格式化文件、不运行 Cargo、不生成编译产物。
- 对工具脚本使用 `node --check` 和 `sh -n`,只检查语法,不执行渲染、构建或打包;Cargo 配置另以 TOML 解析核对。
- 检查固定行号的示例夹具、测试依赖的源码替换片段和 `git diff --check`。这些检查能证明注释外代码保持一致及语法可解析,不能替代运行时回归结果。
- 本次未执行编译、GUI 回归、Clippy 或 rustdoc;需要构建的验证应在用户已确认的远程 runner 上运行,不能把未执行步骤写成通过。实际交付结果记录在 PR 中。
+111
View File
@@ -0,0 +1,111 @@
# Endor 脚本:龙与文字实时绕排
完整程序:[examples/illustrated-manuscript.endor](../examples/illustrated-manuscript.endor)。它通过普通 Page、源 measure/paint、托管事件及 owner-scoped Task 工作。Tessera 没有 Dragon / Flame / Manuscript 专用 Rust 类型、专用启动参数或动画循环;Rust 只补齐可被其他脚本复用的图形与文字能力。
## 运行
在仓库根目录启动普通应用:
```sh
cargo run --release --locked
```
另开终端,在同一个仓库根目录提交原始 Endor 文件:
```sh
curl --request PUT --header 'Content-Type: text/plain; charset=utf-8' \
--data-binary @examples/illustrated-manuscript.endor \
http://127.0.0.1:4737/v1/namespaces/app.main/code
```
这是普通源码提交,没有预编译场景或原生替身。资源路径目前相对于应用工作目录;通过以上根目录启动方式使用 `examples/assets/manuscript/`。从其他目录启动时,修改脚本的 `assets` 路径为绝对路径。图片、字体、正文都在示例目录,不编入宿主。素材与字体许可见 [NOTICE](../examples/assets/manuscript/NOTICE.md)。
移动鼠标让龙跟随,按住左键喷火,松开停止。离开窗口、取消指针或失焦也会停止喷火。空闲两秒回到卷曲姿势。原生窗口缩放触发普通布局和 paint;窄窗口切换到脚本预备的小字号。关闭 Page 会取消其定时任务,不留下应用级动画循环。
## 脚本对象分工
| 对象 | 状态与责任 |
| --- | --- |
| `Segment` | 一节身体的位置、角度及共享图片引用 |
| `FireArt` | 一张火焰图及其原始轴角;这些校准值是示例素材数据 |
| `Flame` | 一个粒子的位置、速度、尺寸、年龄及 FireArt 引用 |
| `Dragon` | 身体链、火焰集合、目标点、输入状态、时钟样本和随机种子;实现追随、恢复、生成/更新/删除粒子及绘图描述 |
| `Manuscript : RectBlock` | 普通视觉子类;组合预处理文字、首字母图片、龙和 Page,覆盖 measure/paint,通过 onMount/onUnmount 管理 Task |
| `Page` / `Task` / `Event` / `Mount` | 沿用 Tessera 已有托管体系:Page 是任务 owner,Task 调用脚本 tick,Event 带局部指针位置,Mount 提供当前出现的几何 |
这些是同一 VM 中的普通对象与引用。身体并不是多个重量远程 actor;图片被多个出现/粒子共享,临时 PaintCommand 只描述本帧绘制。
## 从输入到一帧
1. `view.on("pointermove", dragon.pointer)` 显式订阅连续输入。宿主把原生指针转换成 Event,再交给普通传播链;脚本读取 `event.mount.bounds()`,将目标点换算到自己的 700 单位画布。按下/松开只改变脚本 `pressed`。
2. `onMount` 用 `ui.after(page,"animation",80,self.tick)` 创建 Page 所有的任务。`tick` 调用脚本 `Dragon.step()`,再安排下一个任务。80 ms 是脚本参数;宿主不知道这是龙的动画。
3. `step` 先采样通用单调时钟。头向目标移动,其余 19 节依次跟随上一节,保持间距 30,并把相邻转角差限制在 ±0.25 弧度。空闲时由脚本计算卷曲目标并插值回去。
4. 粒子在脚本中更新位置、速度与年龄,倒序删除超过 12 步的粒子。每步最多产生 1 个、总数最多 12 个,最长边仅 12–20 个画布单位。随机变化由脚本自己的确定性种子和数学函数产生。
5. 普通属性变更使源 paint 的已观察依赖失效。`paint` 从当前几何生成图像绘制命令,再由脚本选择哪些图片形成避让区,交给通用文字流布局。
6. 正常文字使用缓存整词形状;喷火时脚本请求字素粒度,并在 `heat` 中按粒子距离修改临时文字命令的位置、角度、透明度和颜色。没有把热力规则藏进宿主。
7. 返回的 DisplayList 经过现有只读 paint 校验、Frame 发布和 CPU 栅格化,在普通窗口中呈现。VM 对象不进入窗口线程。
paint 内创建/修改的是临时返回对象,不能写已有龙状态或读取文件/时钟。资源加载、时钟采样和状态更新发生在初始化、事件或任务中。
## 绕排与预处理
脚本初始化时用 `prepareText` 准备两套正文和提示文字。字体/字号/行高/颜色/设备分辨率属于准备参数;每帧只传新几何与避让区,复用字形形状和透明图像。
`Dragon.artwork` 返回普通图像命令。脚本循环调用通用 `imageExclusion(command,padding)`,由图片 alpha 轮廓产生保守凸包,并给首字母添加矩形避让。`flowText` 对整个行带求交、合并遮挡区间,再依次填充剩余的左右片段;同一个文字 cursor 跨片段继续,不复制或跳过词。单词装不下所有可用片段时才退回字素边界。
当前提供面向本示例的从左到右文字流,不声称完整支持所有文字系统的双向段落。字素绘制会改变部分字偶距;这是喷火效果的取舍。正文超出当前页的部分留在准备数据中,当前示例保持单页,未增加滚动文档业务。
## 火焰角度与尺寸
火焰 PNG 自身方向各不相同;把它们一律当作朝右图片会错位。脚本在 `FireArt.axis` 中记录每张图片原始轴角,绘制时使用:
```endor
image = p.art.image
imageScale = p.size * scale / math.max(image.width, image.height)
angle = math.atan2(p.vy, p.vx) - p.art.axis
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
```
角度以弧度表示,在屏幕坐标中正方向为顺时针;原始素材校准值与 PNG 一起属于应用数据。尺寸按最长边归一化,窄高的 PNG 不会因宽度较小而放大。舌头同样通过局部 anchor 定位,使其偏移随头部一起旋转。
## 可复用宿主接口
入口是 `g = ui.graphics`,数学入口是 `g.math`。宿主实现位于 `src/bridge/graphics.rs`、`src/render/image.rs` 和 `src/render/text_flow.rs`。
| 方法 | 行为 |
| --- | --- |
| `loadImage(path)` | 读取有界普通 PNG 文件,返回共享不可变资源及 width/height |
| `loadFont(path,family)` | 注册开放字体,验证指定 family;相同资源复用,冲突不替换既有字体 |
| `readText(path)` / `textSlice(text,start,end)` / `string(value)` | 初始化用有界 UTF-8 资源读取、按字符切片及标量文本转换 |
| `prepareText(text,font,size,lineHeight,color,rasterScale)` | 生成与障碍无关的缓存文字资源 |
| `sprite(image,x,y,width,height,angle,anchorX,anchorY,opacity)` | 返回普通 PaintCommand;旋转围绕原点,anchor 决定图片局部偏移 |
| `imageExclusion(command,padding)` | 图像实际 alpha 轮廓的保守凸包,应用该命令的平移、缩放与旋转 |
| `circleExclusion(x,y,r)` / `rectExclusion(x,y,w,h)` | 通用几何避让区 |
| `flowText(prepared,x,y,w,h,exclusions,letterMode)` | 生成可检查/修改的普通文字图像命令列表 |
| `math.float/sin/cos/sqrt/atan2/abs/min/max/clamp/floor/now` | 通用实数与图形数学;float 是显式二进制浮点入口,now 仅在事件/任务中采样 |
| `ObjectList.append/remove/extend/added` | 通用托管集合编辑/批量追加/持久添加;paint 只能编辑本次创建的临时集合 |
PNG 单张最多 4M 像素,文件最多 4 MiB;准备文字最多 16 KiB、缓存最多 8M 像素;最多 512 个避让区、1024 行和 10000 条绘图命令。资源读取不允许在 paint/layout 中执行。图形边界显式窄化数字,源语言的精确数值表示不会被改写。
原生连续指针输入需显式订阅。生产端在有界队列中只合并相同窗口/session/目标的相邻绝对移动采样,按下/松开/取消及目标变化都是顺序边界。后台 relay 直接把内部 Input envelope 送进同一 VM worker 队列并等待完成,窗口线程不等待;不绕行 HTTP。worker 等既有刷新结束后才重验 Mount/session 并更新连续事件版本,避免动画发布新帧时丢掉移动或松开。外部 HTTP 无法创建内部 envelope,继续严格校验帧版本。
## Aster 语言扩展
为使上述逻辑留在源码中,独立 Aster 分支实现了真实四则运算、非字面量负号、六种比较、if/else 与 while,并同步 ASDL、AST 生成产物、名称分析、bytecode 验证与 VM。整数/十进制/有理数保持精确;有 BinaryFloat 操作数才选择浮点算术,混合数值比较避免大整数丢精度。循环沿用同一累计指令限制与协作暂停机制。不是在 Tessera 中解释另一个小语言。
## 验证与范围
```sh
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
cargo fmt --all -- --check
TESSERA_MANUSCRIPT_SNAPSHOTS=/absolute/output/path \
cargo test --release --locked manuscript_script_snapshots -- --nocapture
```
快照测试加载实际 Endor 文件,经 VM、事件、源 paint 和软件渲染器输出 endor-rest/crossing/fire/narrow 等 PNG。覆盖持续喷火达到上限、角度校准、最长边尺寸、字形呈现、宽窄窗口,以及任务确实调用脚本和关闭 Page 后停止。另一项集成测试验证指针驱动头部移动、取消与粒子回收。不是用 Rust 动画或手工绘图替代源码执行。
当前采用既有 CPU 后端;无 GPU 实现,也未达到稳定 60 FPS。参考本机 release 样本,16 次持续喷火的 VM 更新与完整 Frame 刷新约 1.4–1.5 秒(不含 OS 呈现),不能把 80 ms 定时参数当作实际帧率。性能结果属于脚本通路而非之前已移除的原生原型。
本次还补上了指针类型的 HTTP 白名单。最初只测 VM 分发遗漏了真实 worker 入口,随后通过真实 HTTP 回归复现并修复 `unsupported event kind`;动画期间的过期事件竞争则通过内部原生 envelope 解决。测试分别覆盖 HTTP 的坐标/类型/严格版本、原生 envelope 换帧后仍投递且旧会话不穿透,以及生产端合并不跨越按下/松开。最终运行窗口已由用户确认“持续正常,松开也正常”。系统自动事件投递/截图权限未开启,因此最终展示图来自实际脚本的 VM→Frame→软件渲染快照,原生交互验证来自用户操作。
+59
View File
@@ -0,0 +1,59 @@
# Endorphin 页面空间与窗口边界
> 下文的 Page.mode 是前一阶段的兼容接口。当前默认页已迁移到普通 Page、Row/Column、滚动容器和独立 Scene2D / CanvasViewport / Camera2D。新代码请使用 [统一场景架构](scene-architecture.md),不要再把横纵排版或相机作为 Page 的固有模式。
本次按参考图的“左侧栏展开/收起”两种状态调整原生宿主。窗口保留系统关闭、最小化和缩放按钮;宿主管理入口集中在左侧栏。收起后只保留展开按钮及系统三键,页面从窗口左上角铺满,不再保留顶部标签条、左侧空轨或内容卡片边距。
页面内容、卡片、文字和操作按钮由 Endor 源码渲染。默认 [开始页](../src/bridge/ui_bridge/start_page.endor) 是真实的画布 Page,其操作创建另外三种 Page;不会把示例的静态截图当作可操作界面。
## 四种页面模式
| `page.mode` | 布局 | 浏览方式 |
| --- | --- | --- |
| `window`(默认) | 根 Page 等于可用区域,子组件按纵向流组织 | 应用自行安排内部布局或 ScrollBlock |
| `vertical` | 宽度跟随可用区域,高度至少一屏并随子组件增长 | 纵向滚动,偏移限制到内容范围 |
| `horizontal` | 高度跟随可用区域,宽度至少一屏并随子组件增长 | 横向滚动;普通纵向滚轮也可推进横向页面 |
| `canvas` | 直接子组件使用各自的 `x`、`y` 世界坐标 | 双向平移、缩放,视角不限制到内容边界 |
侧栏展开占用 256 个逻辑像素;收起归还全部宽度。`ui.viewport()`、`window.viewport()` 和 GET code 的 viewport headers 均报告实际可用页面区域。窗口变更重排同一个运行时,不重放源码。窗口模式保证根 Page 填满,但不会强迫每个子组件拉伸。
```endor
page = Page()
page.mode = "canvas"
page.title = "我的画布"
card = Button("一个真正的 Endor 组件")
card.x = -120
card.y = 90
page.render(card)
page.panX = -200
page.panY = 0
page.zoom = 1
```
`page.render(root)` 替换内容;`page.append(child)` 继续添加。直接画布子组件的 `x/y` 决定位置,内部 Row/Column 仍使用原来的流式布局;纵向、横向和窗口页面不使用子组件的 `x/y` 排列。共享 Block 可以有多个 Mount,不会为画布复制业务对象。
## 交互与坐标
- 滚轮或触控板平移;嵌套 ScrollBlock 优先处理其可滚动方向。
- 画布支持空格+左键拖动、鼠标中键拖动;已有按钮键盘焦点保留 Space 激活。
- 画布支持捏合、Cmd/Ctrl+滚轮缩放,以指针位置为锚点。
- Cmd/Ctrl+B 展开/收起侧栏;侧栏提供新建、切换和关闭页面及四个开发工具入口。
- Cmd/Ctrl+T 新建,Cmd/Ctrl+W 关闭,Ctrl+Tab / Ctrl+Shift+Tab 切换页面。
`panX/panY` 是可视区域左上角的世界坐标,`zoom` 为 0.1–4。变换是 `screen = (world - pan) * zoom`;绘制使用正变换,原生点击/滚动使用逆变换。VM 中 Mount 始终使用世界坐标,worker 根据同一视角重新检查可见性。Page 自己保存视角,切换后仍保留;有限页面在内容缩短或窗口变大时限制偏移。
画布没有内容边界裁剪,但保留现有运行预算:坐标为有限数且绝对值不超过 1,000,000,维度、对象数和绘制命令数仍有上限。“无限”指可以向内容以外导航,不代表无限内存或数值范围。
原生相机事件 `page-pan/page-zoom` 经过真实 HTTP worker,再由所属 VM 更新 Page;它们使用 `mount=0` 和当前 window/session/layout_version。平移 delta 使用可视区域像素;缩放 delta 为 `[factor,0]`,point 是可视区域内的锚点。普通点击与嵌套滚动的 point 使用世界坐标。过期事件返回 409;不适用于当前模式或参数非法的相机事件在执行前拒绝。
## 实现位置与验证边界
- [page.rs](../src/render/page.rs):模式约束、视角、正逆坐标变换和有限页面偏移限制。
- [sidebar.rs](../src/shell/sidebar.rs):唯一宿主管理栏及折叠后点击遮挡范围。
- [page_input.rs](../src/bridge/ui_bridge/page_input.rs):串行 VM 内的视角变更。
- [API 回归](../src/runtime/api/tests.rs):真实 HTTP 平移、指针锚定缩放、负世界坐标点击、过期/非法事件。
- [宿主回归](../src/runtime/endor_host/tests.rs):四种模式、实时内容增长、共享对象坐标、视角恢复和默认页真实按钮。
本地格式、153 项测试和 all-target Clippy 均通过;release app 已构建并实际启动。运行中平移与缩放事件返回 200,并读回 Page.panX=80、Page.zoom=2,随后恢复初始视角。最初 HTTP worker 漏接相机事件导致 400,现由完整链路回归覆盖。
macOS 当前未授予本次工具模拟输入和窗口截图权限。模拟滚轮未生效,无法据此声称真实鼠标/触控板、捏合或窗口外观验收通过。侧栏指针拖放、跨窗口拖放尚未迁移;原有 `moveTab/moveToWindow/detachToWindow` 与版本校验的 API 保留。没有修改 Aster 或引入新的 GUI/语言依赖。
+63
View File
@@ -0,0 +1,63 @@
# 场景交互性能研究
## 结论
性能问题有可验证的解决路径,但单改 Aster 字典不能让当前世界 UI 达到稳定 60 FPS。本 PR 交付可重复基准和研究记录;去除字符串键比较临时分配的改动仍在独立 Aster 分支,未随本 PR 集成;面板纹理缓存实验因当前示例收益不足而撤回。没有实现 GPU 后端或宣称完成性能目标。
## 方法
在 Apple M4 Pro、macOS 15.6 本机使用 release 构建,固定 1024×800 逻辑视口、1× / 2× 像素比例,分别加载 world-ui 和 scene3d 示例。每组执行 33 次真实 scene-orbit 运行时分发,前 3 次作为预热,统计后 30 次的均值、P50、P95。每次先测分发到文档更新,再测变化后的软件绘制,最后测相同显示列表的缓存绘制。没有运行 Console 编译来代替原生事件。
```sh
cargo test --release --locked scene_interaction_performance -- --ignored --nocapture --test-threads=1
```
这是无窗口的运行时与软件渲染基准,包含回调、快照、布局和运行时收尾,但不包含本地 HTTP、OS 事件排队、窗口呈现或输入到显示延迟。阶段耗时不能简单当作实测 FPS。时间是本机样本,不是跨设备保证;基准默认 ignored,普通回归不以绝对耗时断言通过。
## 对照结果
世界 UI、2× 像素比例,单位 ms:
| 条件 | 更新均值 | 更新 P95 | 变化绘制均值 | 变化绘制 P95 |
| --- | ---: | ---: | ---: | ---: |
| 原始 Aster | 8.456 | 8.843 | 18.704 | 20.742 |
| 借用字符串键比较 | 6.842 | 7.226 | 18.598 | 20.665 |
| 再加面板纹理缓存实验 | 6.823 | 7.053 | 18.410 | 20.482 |
去掉字符串键比较的分配后,本组更新均值减少约 19%,绘制基本不变。面板缓存的绘制收益约 1%,不足以证明值得在本次增加缓存与失效复杂度,因此撤回。
作为对照,普通 scene3d 示例在 2× 下更新约 0.94 ms、变化绘制约 6.15 ms;它的视口尺寸、覆盖像素和 UI 复杂度不同,不能据此把两个示例的全部差值都归给某一层。
## Aster 发现与保留的修改
`EndorDictObject` 用插入有序的数组存储,`dict_position` 逐项调用 `key_equal`。原实现每次比较都会把内联文本或托管 String 转成新的 String;查找名称时会重复分配和复制候选键。
本次只改为借用字符串切片比较。保留线性查找、原 key_equal 分支、对象检查、只读依赖观察、键的插入身份与顺序、写入验证和引用计数流程。新增测试覆盖空文本、普通文本、Unicode、托管 / 内联字符串等价替换、删除和引用释放。
没有把通用字典直接改成 HashMap,也没有绕过 heap.replace_payload 的事务性边验证。前者还需处理托管键相等 / 哈希与顺序语义;后者涉及 GC、失败原子性和只读保护。它们不属于本次已经证明安全的改动。
## 尚未解决的主要成本
1. 世界 UI 的摄像机变化后,当前统计仍为 measured=8、arranged=88;普通 3D 为 measured=0、arranged=14。数据证明相机更新没有彻底绕开排版。
2. 3D 变化帧仍在窗口线程逐像素进行 CPU 绘制。当前世界 UI 的绘制本身已超过 60 Hz 的 16.7 ms 预算,其他 CPU 优化不能消除这个事实。
3. 原生连续输入仍逐项经本地 HTTP 发送,有界队列不等于按显示帧合并;真实排队和呈现延迟需要另外测量。
## 后续实施顺序与条件
1. **相机更新专用路径。** 复用已有 Mount 几何、UI 排版和绘制结果,仅更新视图 / 投影、可见性和命中映射。必须检查源 measure / arrange / paint 的实际读取依赖;源函数若读取相机,应继续正确失效。不能无条件跳过所有布局或绘制。验收:不依赖相机的 UI 在旋转时零测量、零重新排版,依赖相机的源协议回归仍通过。
2. **按帧收集连续输入。** 原生操作直接进入进程内通道,连续手势按捕获会话 / 目标与顺序合并;点击、页面切换和运行时替换保持边界。外部 HTTP API 保留严格版本校验。验收包括缩放锚点、非交换手势顺序、嵌套捕获与过期会话,不能只检查最终数值。
3. **GPU 合成与独立渲染。** 现有不可变网格、变换、相机、深度和 UV 数据可以成为渲染输入;VM 对象留在运行时线程。GPU 应直接向窗口表面呈现,同时合成 2D / 3D 内容,避免每帧 GPU→CPU 回读后再交给 softbuffer。跨线程队列只保留最新可呈现快照;纹理、缓冲与缓存均需资源预算和正确失效。
建议验收目标:固定示例 2× 下相机更新 P95 ≤2 ms、GPU 绘制 P95 ≤8 ms,另外记录真实窗口的帧间隔和输入到呈现延迟。目标尚未达到,也没有用无窗口基准宣称原生 60 FPS。
## 检查说明
Aster 全工作区测试发现 compiler 的 classes 测试中两项解析失败(第三行 expected EOF),以及 core 的 managed_coroutine_hard_budget_accumulates_and_error_releases_frames 指令预算失败。将对象实现恢复为未优化的基线后,这三项失败均能复现,说明不是本次借用字符串比较引入;不把全工作区标为全绿。对象层测试通过,Aster 全工作区 Clippy / 格式通过;GUI 为 186 项通过、1 项默认忽略的基准,GUI Clippy / 格式通过。该基准已经另行在 release 模式实际执行。
本次没有修改用户指向的 builtins.rs 第 176 行(该处是函数对象定义)。实际热路径修改位于 Aster 的 store_builtins.rs。
## PR #6 主线兼容修复
2026-11-12:合入最新 GUI main,保留 main 的 Aster f3e1670,而非旧性能分支 fd247ac。旧引用缺少 GUI 目前使用的精确读取依赖接口,不能为性能实验回退。上文数值是原有实验记录,不代表当前主线已经采用借用键优化。本 PR 的最终差异仅包括本研究记录和默认 ignored 的基准;Aster 优化需单独更新其分支并审阅后再集成。PR 标题中的 WIP 将移除,交付范围为可运行基准,不承诺上述后续性能目标。
本次兼容修复检查:GUI `cargo test --locked` 为 186 passed / 1 ignored;Clippy(全部 target,警告视为错误)、格式和差异空白检查通过。最初沙箱内 9 项 HTTP 测试因不能监听本地端口失败;允许回环网络后全套重跑通过。本次未重跑历史 release 性能采样,也未修改 Aster。
+109
View File
@@ -0,0 +1,109 @@
# UI、场景、视口与摄像机
本次完整实现目标的持续记录。完成布局并不等于完成场景或原生交互验收。
## 边界
- Page 负责页面身份、生命周期和根内容。Row、Column、Overlay 负责有限盒子内的排版。
- Scene2D、Scene3D 管理世界对象。世界不是一个无限宽高的布局盒子。
- CanvasViewport、Viewport3D 是有限的 UI 盒子,可以加入 Row、Column、Overlay,也可以放进世界内的有限 UI 面板。
- Camera2D、透视和正交摄像机是显式对象。视口保存自己的尺寸、裁剪和投影比例;共享场景不隐式共享摄像机。
- 摄像机控制器独立于摄像机数据。移动相机只改变投影、可见性和命中坐标,不应重新排版无关内容。
- 视口覆盖 UI 不随世界移动。世界内 UI 面板拥有有限大小,使用普通布局,再由对应的世界变换呈现。
- 嵌套视口的输入逐级逆变换,尊重裁剪和捕获;同一手势不能同时驱动内外相机。
- 无限导航受数值、可见对象、递归、内存和绘制预算约束;引用环必须在发布部分结果前拒绝。
## 当前实现状态
| 阶段 | 状态 | 证据 / 剩余工作 |
| --- | --- | --- |
| 基线审查 | 完成 | 保留共享 Block / 独立 Mount、源协议和已合并工作区示例 |
| 嵌套布局与盒模型 | 完成 | Row / Column / Overlay、双轴对齐、子项覆盖、外边距、非对称内边距和边框、尺寸策略、裁剪;见下面接口 |
| 独立 2D 场景与视口 | 完成 | 独立场景/摄像机、多视口及嵌套、覆盖 UI、投影/命中、原生输入路由、真实 HTTP 与像素回归;旧 Page.mode 仅保留兼容 |
| 3D 场景、视口和摄像机 | 完成 | 软件深度缓冲、网格/变换/灯光、透视/正交相机、可替换控制器、射线拾取、世界空间 UI 及 2D/3D 嵌套 |
| 可见性与资源防护 | 完成 | 2D 屏幕索引、3D BVH / 视锥剔除、场景缓存、连续原生事件版本更新、字形 LOD 和嵌套资源预算 |
| 原生综合验收 | 可取得检查完成 | release 构建与实际窗口截图,运行实例的按钮 / 旋转 / 缩放事件及过期拒绝均通过;系统不允许模拟鼠标,未冒充硬件验收 |
| 完成交付 | 已提交可评审 PR | [PR #5](https://gitea.entroia.com/Zayden-Jung/endorphin-tessera-gui/pulls/5) 已移除 Draft,Gitea 返回 mergeable=true;等待用户手动合并 |
## 有限盒子的接口
现有 `Row(padding, gap, children...)`、`Column(padding, gap, children...)` 保留,新增 `Overlay(padding, gap, children...)`。Overlay 为构造协议兼容保留 gap 参数,但叠放不使用 gap;子项顺序决定绘制和命中顺序。
- `mainAlign`: `start`、`center`、`end`、`space-between`。Overlay 对应横向对齐,不使用 space-between 分散规则。
- `crossAlign`: `start`、`center`、`end`、`stretch`。Row 对应纵向;Column 对应横向。Overlay 对应纵向。
- `alignSelf`: `auto` 继承所在父容器的 crossAlign,或以上交叉轴选项。属性在对象上,每个 Mount 根据自己的父容器解释。
- `marginTop/Right/Bottom/Left`: 外边距参与父容器的测量、主轴分配和对齐。不折叠外边距,不支持负外边距。
- `paddingTop/Right/Bottom/Left`: 数值覆盖对应边;`auto` 回退到构造参数或容器的 padding 属性。非容器回退为 0。
- `borderTop/Right/Bottom/Left` 和 `borderColor`: 边框占据 border box 内侧,压缩内容空间。原生绘制产生实心直角边框;自定义 paint 可调用基类绘制,也可自行提供完整外观。
- `boxWidth/boxHeight`: `auto`、非负数值或 `fill`。保留 RectBlock / ScrollBlock 原有 width/height 和源子类字段语义,通用盒尺寸使用独立名称。数值表示 border box,不包含外边距。
- `minWidth/minHeight/maxWidth/maxHeight`: 盒尺寸边界,最低值不得超过最高值。父分配小于最低值时,外部有限约束仍然生效。
- 主轴 `fill` 使用零基准分享剩余空间;未显式设置 flex 时权重为 1。最大值饱和后,剩余空间继续分给其他项目。交叉轴 fill 使用父容器提供的有限内容尺寸。没有有限父分配的 fill 报错。
- `stretch` 仅拉伸交叉轴 auto 尺寸,遵守盒尺寸边界;数值固定尺寸保留。显式源 arrange 返回的布局始终优先。
- `overflow`: `visible` 或 `clip`;后者同时裁剪后代绘制和指针命中。需要滚动时使用 ScrollBlock,滚动轴与内层 Row/Column 排版独立。
尺寸、边距和边框必须有限、非负,受现有 1000000 上限限制。根约束、每个 Mount 的偏好尺寸、分配、收敛判定及源协议缓存均保持独立。示例见 [box-layout.endor](../examples/box-layout.endor)。
布局阶段验证:本地 `cargo test --locked` 的 162 项回归全部通过,`cargo clippy --all-targets --locked -- -D warnings`、格式与差异空白检查通过。新增用例执行实际 Endor 脚本,覆盖共享对象在不同父容器中的对齐、实时边距变更、flex 分配、叠放、尺寸边界、文本换行、有限 fill、无界 fill 拒绝、源 arrange 优先、裁剪命中和可运行示例。本阶段尚未重新打包或进行原生指针验收。
## 2D 场景与视口
`Scene2D(children...)` 持有世界内容。直接子项的 x/y 是世界位置;Row/Column/Overlay 仍然只解释自己的有限内容布局。给世界内卡片设置 boxWidth,即可在其内部换行和嵌套排版。
`CanvasViewport(width, height, scene)` 是可参加普通排版的有限盒子,世界范围不参与它的本征尺寸。视口可使用通用盒模型尺寸、边框、内边距和 flex。每个新建视口拥有自己的 `Camera2D()`;显式赋值 `second.camera = first.camera` 才会共享视角。摄像机 x/y 是可视内容左上角的世界位置,zoom 范围 0.01–100;也可以 `Camera2D(x, y, zoom)` 创建。嵌套投影还受合成变换预算限制。
视口的有限内容框裁剪世界绘制和输入,Overlay 中的兄弟 UI 保持固定。场景内可以放另一个 CanvasViewport:布局坐标不变,投影逐层合成,指针逆变换至目标组件坐标。相机改变时复用不变的测量结果。`view.mounts().at(index).fitView()` 使用指定 Mount 的有限尺寸及场景直接子项的有限边界适配摄像机,不把无限世界当作排版边界。
滚轮优先交给命中的可滚动 ScrollView;其余平移交给最内层画布。空格加左键或中键拖动锁定开始时的视口,不因指针跨到另一个视口而切换相机;捏合及 Cmd/Ctrl+滚轮围绕指针缩放。`viewport-pan` / `viewport-zoom` 使用真实 Mount 身份及帧版本,通过 HTTP worker 串行修改所属 VM 的摄像机。非法缩放锚点在进入 VM 前拒绝,过期帧返回 409。
`ScrollView` 是现有 ScrollBlock 的名义子类型别名,滚动轴和其内容 Row/Column 排版独立。默认开始页已使用普通 Page + 独立场景/视口,纵横示例使用 ScrollView 对应的滚动容器组合;不再设置 Page.mode。原有 Page.mode/panX/panY/zoom 暂保留兼容旧脚本,不用于新架构示例。
示例见 [scene-viewports.endor](../examples/scene-viewports.endor)。回归包含两个视口共享同一业务对象、显式共享相机、嵌套变换、真实点击回调、相机修改时测量数为 0、真实 HTTP 链路及软件渲染像素中的缩放/裁剪。它们不能替代原生鼠标和触控板验收。
2D 阶段最终验证:169 项本地回归全部通过,all-target Clippy 拒绝 warnings 通过,编辑器未报告错误。默认页的真实按钮事件也已迁移验证。未将原生窗口验收记为完成。
## 3D 基础接口与当前边界
- `Scene3D(nodes...)` 管理世界对象;`Group3D(nodes...)` 组合变换。`children` 是 ObjectList,可显式替换;共享节点可以出现多次,路径循环会拒绝。
- `Box3D(width,height,depth,color)` 创建网格;`Geometry3D(ObjectList(Vec3(...),...), ObjectList(Triangle3D(a,b,c),...))` 创建不可变顶点/索引缓冲,再用 `Mesh3D(geometry,color)` 实例化。可替换 mesh.geometry;修改原输入 Vec3 不会改写已建立的不可变几何。
- 网格/组有 x/y/z、rotationX/Y/Z(角度)、scaleX/Y/Z;局部缩放、X/Y/Z 旋转、平移依序应用,再继承父组变换。`translate(dx,dy,dz)`、`rotate(dx,dy,dz)` 可在 Endor 中直接调用。
- `DirectionalLight(dx,dy,dz,color,intensity)` 使用传播方向,Scene3D.ambient 为基础照明。当前材质是完全不透明的平面着色。
- `Viewport3D(width,height,scene)` 是普通有限 UI 盒子,可以放入 Row、Column、Overlay 或 Scene2D 中。背景与 3D 内容在独立表面上渲染,再交给现有原生软件合成器;采用实际深度缓冲、六面视锥裁剪,没有用静态图替代三维场景。
- 每个新 Viewport3D 有独立 PerspectiveCamera;可用 `view.camera = OrthographicCamera()` 替换,或显式共享同一个 camera。摄像机字段为 x/y/z、targetX/Y/Z、upX/Y/Z、near/far、透视 fov(角度)、正交 height。宽高比由每次视口投影提供,不写回共享摄像机。`camera.translate(...)` 同时平移位置与观察目标。
- `OrbitController()` 独立于摄像机数据,默认绑定到新视口。左键拖动旋转,中键/空格拖动平移,滚轮/捏合缩放。view.controller 可替换为提供 `update(camera, gesture)` 的 Endor 对象,或设为字符串 `none` 禁用。CameraGesture 提供 kind、dx/dy、factor、width/height、pointX/pointY。透视缩放改变观察距离,正交缩放改变 height。
- `view.mounts().at(index).pick3D(x,y)` 接受投影前页面空间中的指针坐标,返回 RayHit3D 或空值;结果包含实际托管 mesh、世界 point/normal、distance、triangle 和 barycentric。原生指针点击对应视口时,标准 click Event.hit 提供相同结果;点击空白时为空。拾取与绘制共享摄像机和变换,遵守 near/far。
固定 Aster 版本不支持数值算术表达式,不能用 `x = x + dx` 编译控制器。因此示例使用可运行的原生 translate/rotate 操作;自定义控制器可以调用这些方法或直接设置摄像机字段。未修改 Aster 源码或 gitlink。
3D 表面最长边按需降采样至 2048,单表面不超过 4194304 像素;缓存最多 8 个不可变表面、总计 8388608 像素。三角形、顶点、场景节点、灯光、光栅工作和摄像机范围均有检查;超过光栅预算返回错误,不发布截断的半幅结果。降采样保留原始视口宽高比,使绘制与拾取使用相同投影。
示例见 [scene3d.endor](../examples/scene3d.endor)。测试覆盖真正的 Endor 构造/修改、最终渲染像素、深度顺序、透视/正交独立视图、近面裁剪、射线回调、可替换控制器的真实 HTTP 路由以及循环/非法几何拒绝。原生硬件交互尚未验收。
3D 基础阶段检查:176 项本地回归全部通过,all-target Clippy 拒绝 warnings 通过。后续世界空间 UI 与嵌套资源/可见性工作尚未完成,因此整体目标及 PR 仍处于进行中。
## 世界空间 UI 与组合
`Panel3D(worldWidth, worldHeight, content)` 把普通 Endor Block 放进 3D 场景。面板有独立的 uiWidth/uiHeight(默认 512×320,整数 1–2048)和不透明 background;本次 UI 表面不实现透明材质。世界尺寸控制平面几何,UI 尺寸提供有限排版约束,两者互不代替。面板与组/网格一样支持位置、旋转、缩放及 translate/rotate。
内容仍是同一个托管对象树,正常执行 measure/arrange/paint/hitTest;每次出现有自己的 Mount、有限尺寸、滚动状态和纹理。一个组件可以放进多个不同尺寸的面板,不复制其业务状态。3D 摄像机移动不改变 UI 的排版尺寸。UI 绘制指令按面板收集,透视裁剪时保留 UV,并按深度进行透视校正插值;普通网格会正确遮挡面板。
指针先沿视口摄像机发射射线,再沿可见面板的逆世界变换映射到 UI 像素,然后继续通过内部 CanvasViewport 的逆变换。被其他网格挡住的 UI 无法收到指针命中。拖动捕获使用平面的连续逆投影,因此指针移出面板后不会把同一手势交给另一个摄像机。Mount.toView(x,y) 将组件本地坐标转到页面视图坐标;toLocal(x,y) 执行反向转换,无法命中可见面板时返回空值。pick3D 另提供实际 node/mesh 和实例编号。
面板 UI 可以继续嵌入 CanvasViewport 或 Viewport3D,后者也可以再包含 Panel3D;普通 Overlay 的兄弟 UI 仍固定在其 UI 空间。引用环在派生布局前拒绝;世界 UI 最多嵌套 8 层,光栅/纹理像素采用统一总预算。滚动视口与普通画布的世界内容不会撑大外层有限视口或外层滚动范围。
示例见 [world-ui.endor](../examples/world-ui.endor)。世界 UI 阶段的 181 项完整本地回归和 all-target Clippy 均通过;新增回归覆盖真正的像素、托管回调、材质遮挡、多面板独立尺寸、再次嵌入 3D、真实 HTTP 射线/UV 摄像机输入、循环和递归预算。
## 可见性、连续输入与资源
3D 场景快照构建世界 AABB 的 BVH,摄像机变化复用相同的不可变场景和索引。视锥查询剔除不可见网格,射线查询按近到远缩小候选;序列化不携带缓存,反序列化后可重建。几何或变换变化会生成新场景和新索引。
普通 2D 交互和相机目标使用 128px 屏幕网格。每个对象最多进入 256 格,索引总计最多 65536 项;巨大矩形和项目化的 3D 面板 UI 进入精确命中的后备集合。后备集合仍遵循原有裁剪、投影和反向绘制顺序,不因近面投影或索引预算遗漏对象。源 hitTest 仍逐一执行其真实协议,未更改源覆盖行为。
原生输入使用有界 64 项队列。同一个运行时的连续 viewport / scene / scroll 事件在出队时更新到当前帧版本,保留原窗口、会话和捕获 Mount;目标消失、控制器禁用、运行时更换或失败时丢弃。所有窗口共用同一事件客户端和权威文档。离散点击及外部 HTTP 请求仍严格拒绝过时版本,不自动重放业务动作。队列满时仍可拒绝新输入,不保证任意输入速率下无损。
文字先裁剪再遍历可见字形像素;字体超过 512 设备像素时使用有界字形位图采样,避免高倍率缩放产生巨大单字形分配。字形缓存逐字检查 4096 项 / 32 MiB 阈值,整形缓存继续限制字符串数量与源字节数。极端放大时清晰度受字形 LOD 限制。面板派生槽位限制为非负 i64,避免哈希高位造成偶发排版错误;重复标识拒绝发布。
## 原生验证记录
2026-11-11:本地完整回归、格式、all-target Clippy 和 release 应用打包;实际启动 Tessera.app,通过本机 API 加载 world-ui 示例,并取得原生窗口截图,确认立方体、倾斜面板和内部 Canvas 的显示。系统 `CGPreflightScreenCaptureAccess` 为 true,`CGPreflightPostEventAccess` 为 false;集成的原生自动操作工具还因 OpenSSL 配置读取权限启动失败。因此 API / VM 事件与像素测试不宣称为硬件鼠标或触控板验收。最终检查数量及交付提交在 PR 中记录。
最终完整回归为 186 项通过;运行中的原生实例验证世界 UI 按钮将计数从 0 改为 1、scene-orbit 改变摄像机、scene-zoom 改变观察距离、重复旧帧请求返回 HTTP 409,空闲连续采样的版本保持一致。保留应用窗口供用户直接操作。
+12 -2
View File
@@ -2,6 +2,10 @@
| File | Purpose | | File | Purpose |
| --- | --- | | --- | --- |
| [world-ui.endor](world-ui.endor) | Interactive Endor UI on a transformed 3D plane, with shared counter state, nested canvas, programmable camera and fixed UI controls. |
| [scene3d.endor](scene3d.endor) | Shared 3D meshes, transforms and light with independent perspective/orthographic cameras, orbit controls and Endor buttons. |
| [scene-viewports.endor](scene-viewports.endor) | One Scene2D in two CanvasViewports with independent Camera2D, a nested viewport, shared state and fixed overlay UI. |
| [box-layout.endor](box-layout.endor) | Nested Row/Column/Overlay, shared text, finite border-box sizing, margins, borders, alignment and clipping. |
| [gc-lifecycle.endor](gc-lifecycle.endor) | A Page/Block/Task/await-frame cycle: hidden retained Blocks keep running, then releasing the last references permits collection despite rendering caches. | | [gc-lifecycle.endor](gc-lifecycle.endor) | A Page/Block/Task/await-frame cycle: hidden retained Blocks keep running, then releasing the last references permits collection despite rendering caches. |
| [scroll-axis.endor](scroll-axis.endor) | One shared TextBlock in vertical and horizontal viewports, live axis changes and independent offsets. | | [scroll-axis.endor](scroll-axis.endor) | One shared TextBlock in vertical and horizontal viewports, live axis changes and independent offsets. |
| [windows.endor](windows.endor) | Two real native windows in one Aster VM, live shared counter state and explicit Page ownership. | | [windows.endor](windows.endor) | Two real native windows in one Aster VM, live shared counter state and explicit Page ownership. |
@@ -26,8 +30,14 @@
Submit a file as the UTF-8 body of a namespace PUT request. No namespace, Submit a file as the UTF-8 body of a namespace PUT request. No namespace,
scene, render block or imports belong in the source. scene, render block or imports belong in the source.
The live `/agent` guide embeds page.endor exactly. Remote CI executes the examples through The live `/agent` guide embeds page.endor exactly. Local tests execute examples through
Aster and checks the resulting coordinates and source ranges after VM cleanup. Aster and check the resulting coordinates and source ranges after VM cleanup.
For Page window/vertical/horizontal/canvas modes, see [page surfaces](../docs/page-surfaces.md)
and the running start page's four working actions.
The file is client-side sample material; Tessera's API stores source by namespace. The file is client-side sample material; Tessera's API stores source by namespace.
Return to the [project guide](../README.md). Return to the [project guide](../README.md).
## 龙与文字绕排
`illustrated-manuscript.endor` 通过通用图形资源、源 paint、指针事件与 Page 任务实现。龙、火焰和文字受热逻辑均在 Endor 中。资源在 `assets/manuscript/`;从仓库根目录启动应用并提交源码,详见 [运行和脚本解释](../docs/illustrated-manuscript.md)。
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# Illustrated Manuscript assets and attribution
Dragon sprites (head, tongue, front/back wings, body-1 through body-19), fire
sprites (Layer 2 through Layer 11), dropcap.png and story.txt are from
https://github.com/dengshu2/illustrated-manuscript (main, retrieved 2026-11-12).
The upstream package.json declares `ISC`; upstream has no separate LICENSE file.
Its complete README is retained as UPSTREAM.md. It credits the original demo to
River Marchand (@Riyvir), Neither/Nor: https://neithernor.studio/.
Reference live demo: https://illustrated-manuscript.vercel.app/.
The native chain animation, exclusion and fire algorithms are adapted from that
project's src/dragon.js and src/main.js; this port contains no JavaScript runtime.
IMFellEnglish-Regular.ttf is the unmodified IMFeENrm28P.ttf from
https://github.com/google/fonts/tree/main/ofl/imfellenglish.
Copyright (c) 2010, Igino Marini. SIL Open Font License 1.1, retained in OFL.txt.
The face is embedded for offline, reproducible manuscript typography.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+67
View File
@@ -0,0 +1,67 @@
# Illustrated Manuscript
An interactive illuminated manuscript page featuring an animated dragon that follows your cursor, breathes fire on click, and causes text to flow around it in real-time.
**[→ Live Demo](https://illustrated-manuscript.vercel.app/)**
## Overview
This project renders a medieval manuscript page on Canvas, featuring a hand-painted dragon made of sprite segments. The dragon tracks the mouse cursor with chain-physics (20 body segments with angle constraints), and clicking makes it breathe fire. Text reflows around the dragon body and fire particles each frame using [Pretext](https://github.com/chenglou/pretext)'s `layoutNextLine` API — no DOM measurements, no CSS reflow.
### Features
- **Real-time text exclusion** — text wraps around arbitrary moving shapes (dragon body + fire particles), re-laid out every frame via Pretext
- **Chain-physics dragon** — 20-segment inverse-kinematics body, hand-painted sprites, wing flapping, idle rest pose
- **Fire-breathing** — click to spawn particle bursts from the dragon's mouth; particles push and distort nearby text characters
- **Illuminated drop cap** — hand-drawn initial letter in manuscript tradition
- **Medieval aesthetic** — IM Fell English font, parchment tones, hand-painted sprites throughout
## Tech Stack
- **[Pretext](https://github.com/chenglou/pretext)** — Pure JS/TS text measurement & layout engine by [Cheng Lou](https://x.com/_chenglou). Enables laying out text without DOM reflow, line by line with variable widths
- **WebGL2 + 2D Canvas** — offscreen 2D Canvas compositing, uploaded to WebGL2 texture for display
- **Vite** — build tool
## Project Structure
```
src/
config.js — constants, responsive layout calculations
dragon.js — dragon physics, sprite rendering, fire particle system
story.js — manuscript story text
main.js — entry point: WebGL pipeline, input, text layout, render loop
public/
dragon-sprites/ — 23 hand-painted dragon body part PNGs
fire-sprites/ — 10 fire particle PNGs
img/dropcap.png — illuminated drop cap
(IM Fell English loaded via Google Fonts)
```
## Getting Started
```bash
npm install
npm run dev
```
Open in browser. Desktop recommended — mobile support is limited.
## How It Works
1. **Text preparation**: story text is segmented and measured once via `prepareWithSegments()`
2. **Each frame**: dragon segments and fire particles generate exclusion rectangles
3. **Text layout**: for each line, available width ranges are computed by subtracting exclusion zones, then `layoutNextLine()` fills each range
4. **Fire text effect**: characters near fire particles are displaced, rotated, and color-shifted based on proximity
5. **Rendering**: everything composites on an offscreen 2D Canvas, then uploads to a WebGL2 fullscreen quad
## Credits
Created by [River Marchand (@Riyvir)](https://x.com/Riyvir), Creative Director at [Neither/Nor](https://neithernor.studio/).
Built with [Pretext](https://github.com/chenglou/pretext) by [Cheng Lou](https://x.com/_chenglou) — a pure JavaScript/TypeScript library for multiline text measurement & layout, enabling web UIs to bypass DOM measurements and reflow entirely.
> *"This little illuminated dragon is very happy about Pretext."* — [@Riyvir](https://x.com/Riyvir/status/2038093450139279426)
## License
ISC
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

+47
View File
@@ -0,0 +1,47 @@
It was the third candle the abbess had confiscated, and Sable had nodded gravely each time and stolen another from the chapel stores. She kept reading by its unsteady light, hunched beneath her wool blanket in the scriptorium where the novices slept on straw pallets between the copying desks.
It was the bestiary that held her. Not the psalter, not the gospels, not the lives of saints with their wooden sufferings. The bestiary. Some brother at Lindisfarne had painted it two centuries ago, and his creatures had a quality she could not name, a weight to them, as if they had been observed from life rather than copied from pattern books. His lions looked hungry. His basilisks looked bored.
And his dragon, coiled in the lower margin of the forty-seventh leaf, looked like it was breathing.
She had watched it for six nights before she was certain. The movement was slight, a swelling of the ribs, a settling, barely more than the flicker the candlelight threw across the page. On the seventh night she held her breath and placed a finger beside its painted flank, and the parchment was warm.
\u201CYou could simply ask,\u201D the dragon said.
Sable jerked her hand back. The dragon opened one eye, yellow as egg tempera, and regarded her with an expression she recognized from the abbey cats: mild contempt for a creature too slow to be interesting but too close to ignore.
\u201CI am called Voss,\u201D it said. \u201CThough the brother who painted me called me several other things, mostly under his breath, mostly because I would not hold still.\u201D
\u201CYou are paint,\u201D Sable said. It came out less firmly than she intended.
\u201CI am gall ink and oak soot and gum arabic,\u201D Voss replied. \u201CAlso rabbit skin glue, which I find personally offensive. But I am not paint. Paint does not get stiff from two centuries of holding the same pose.\u201D He uncurled one leg and stretched it across the margin rule, his claws dimpling the parchment. \u201CYou, on the other hand, are trespassing. This page is not assigned reading for novices.\u201D
\u201CI am not a novice. I clean the scriptorium.\u201D
\u201CEven worse. A servant reading. The brothers would be scandalized.\u201D
\u201CThe brothers are asleep.\u201D
\u201CThe brothers,\u201D Voss said, \u201Care always asleep. That is the principal advantage of monasteries.\u201D He yawned, and his breath smelled of lamp-black and something older, something mineral, like a cave after rain. A thin red tongue uncurled and tasted the air above the page.
Sable leaned closer. In the candlelight she could see the crosshatching of his scales, each one laid down with a single stroke of the brush. The brother who painted him had not scrimped. Most bestiary dragons were outlines stuffed with vermillion, flat and heraldic. This one had shadow under its jaw. This one had a nick in its left ear.
\u201CWhat do you eat?\u201D she asked.
\u201CSilverfish, mostly. They get into the binding paste. Occasionally a bookworm, if I am feeling athletic.\u201D He cocked his head. \u201CWhy are you not frightened?\u201D
\u201CShould I be?\u201D
\u201CConvention demands it. A maiden, a dragon. There are expectations.\u201D
\u201CI scrub floors and empty chamber pots. I am not anyone\u2019s idea of a maiden.\u201D
Voss made a sound that might have been a laugh. It was dry and papery, like a sheet of vellum being folded. \u201CGood,\u201D he said. \u201CMaidens are tedious. They always scream at exactly the wrong moment. You, at least, have the sense to ask questions first.\u201D
He settled back into the margin, tucking his tail around the descender of a large Q. \u201CCome back tomorrow night,\u201D he said. \u201CBring a candle that does not gutter so badly. And if you can manage it, a bit of cheese. I have not tasted cheese in two hundred years, but I believe I would still recognize it.\u201D
\u201CCan you eat cheese? You are made of ink.\u201D
\u201CI am made of ink that remembers being alive,\u201D Voss said. \u201CThere is a considerable difference.\u201D He closed his eyes. The warmth under Sable\u2019s fingertips faded, and the dragon on the page was still again, just pigment and binder on stretched calfskin, exactly as lifeless as it should have been.
Sable sat in the dark for a long time after the candle burned out. Then she went to the larder and stole a piece of cheese.\u2026
+7
View File
@@ -1,18 +1,25 @@
// 异步 UTF-8 文件读取:在 Console 调用 reader.read(绝对路径),后台 provider 只交付文本,VM worker 执行业务回调。
// owner/key 相同的新读取替换旧任务;失败只记录 Task.error,不执行 apply。
// 持有三个显示对象与当前任务;task 初值占位,第一次 read 后替换为真实 Task。
class FileReader { class FileReader {
output: TextBlock output: TextBlock
problem: TextBlock problem: TextBlock
status: TextBlock status: TextBlock
task: Object task: Object
// 仅成功时调用,result 在成功回调期间已可读;写原 TextBlock 触发显示刷新。
function apply(self: FileReader): Unit { function apply(self: FileReader): Unit {
self.output.text = self.task.result() self.output.text = self.task.result()
} }
// 以 self 为 owner、file 为 key 启动读取,清空旧输出,并把状态/错误文字绑定到当前任务。
function read(self: FileReader, path: String): Unit { function read(self: FileReader, path: String): Unit {
self.task = ui.readText(self, "file", path, self.apply) self.task = ui.readText(self, "file", path, self.apply)
self.output.text = "" self.output.text = ""
// Task.status/error 是零参数只读方法,可直接作为绑定计算;换任务后绑定也随本次调用更新。
self.status.bind("text", self.task.status) self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error) self.problem.bind("text", self.task.error)
} }
} }
// 先建立界面对象,保持 reader 为可从 Console 操作的全局业务入口。
output = TextBlock("尚未读取文件") output = TextBlock("尚未读取文件")
problem = TextBlock("") problem = TextBlock("")
status = TextBlock("") status = TextBlock("")
+7
View File
@@ -1,21 +1,28 @@
// 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。
// 完整响应保存为 String,界面只显示完成提示,避免把可能很大的响应放进文字控件。
// response 是结果业务存储;output/problem/status 仅呈现任务进度与提示。
class TextFetcher { class TextFetcher {
output: TextBlock output: TextBlock
problem: TextBlock problem: TextBlock
status: TextBlock status: TextBlock
task: Object task: Object
response: String response: String
// 成功后取出完整 UTF-8 结果,写 response 供 Console 查询;provider 失败不会进入此方法。
function apply(self: TextFetcher): Unit { function apply(self: TextFetcher): Unit {
self.response = self.task.result() self.response = self.task.result()
self.output.text = "请求完成;在 REPL 查看 fetcher.response" self.output.text = "请求完成;在 REPL 查看 fetcher.response"
} }
// 同一 self/request 身份的新请求取消旧交付,重置旧内容并把显示绑定到新任务。
function fetch(self: TextFetcher, url: String): Unit { function fetch(self: TextFetcher, url: String): Unit {
self.task = ui.fetchText(self, "request", url, self.apply) self.task = ui.fetchText(self, "request", url, self.apply)
self.response = "" self.response = ""
self.output.text = "" self.output.text = ""
// 绑定读取当前 Task 方法,完成和失败由 worker 的任务状态变更传播。
self.status.bind("text", self.task.status) self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error) self.problem.bind("text", self.task.error)
} }
} }
// 界面不自动访问网络,用户以 Console 明确指定 URL 后才创建任务。
output = TextBlock("尚未请求网络") output = TextBlock("尚未请求网络")
problem = TextBlock("") problem = TextBlock("")
status = TextBlock("") status = TextBlock("")
+7
View File
@@ -1,24 +1,31 @@
// 把 callback-free 文件 Task 与 ui.spawn 协作函数组合:await 保存执行位置,等待完成后继续,不阻塞 VM worker。
// readText 的 file 与 spawn 的 workflow 使用不同 key,分别管理读取和等待流程。
// 保存 provider Task、协程 Task 和结果;两种任务都是托管对象,可以从 Console 独立查看。
class AwaitReader { class AwaitReader {
output: TextBlock output: TextBlock
status: TextBlock status: TextBlock
task: Object task: Object
file: Object file: Object
response: String response: String
// await 在文件未完成时挂起并保留 self/操作数;恢复后只执行后半段一次,再把字符串作为 workflow.result。
function apply(self: AwaitReader): String { function apply(self: AwaitReader): String {
self.response = await self.file self.response = await self.file
self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()" self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()"
return self.response return self.response
} }
// 先创建读任务,再调度普通函数 apply;spawn 而非新 async 语法提供可暂停执行上下文。
function read(self: AwaitReader, path: String): Unit { function read(self: AwaitReader, path: String): Unit {
self.file = ui.readText(self, "file", path) self.file = ui.readText(self, "file", path)
self.task = ui.spawn(self, "workflow", self.apply) self.task = ui.spawn(self, "workflow", self.apply)
self.response = "" self.response = ""
self.output.text = "等待文件" self.output.text = "等待文件"
// 显示的是 workflow 状态,等待 provider 时它仍报告 running;结束后 result 可从 Task 或 response 读取。
self.status.bind("text", self.task.status) self.status.bind("text", self.task.status)
} }
} }
output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)") output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)")
status = TextBlock("") status = TextBlock("")
// 占位值只用于满足初始字段构造;在 read 内被真实 Task 替换,页面建立不自动读文件。
reader = AwaitReader(output,status,ui.state(0),ui.state(0),"") reader = AwaitReader(output,status,ui.state(0),ui.state(0),"")
page = Page() page = Page()
page.title = "await 文件" page.title = "await 文件"
+47
View File
@@ -0,0 +1,47 @@
// 盒模型组合:容器与 Page/相机独立,border-box 尺寸、padding、margin 和对齐分别参与布局。
// 同一文字共享内容,在左右不同有限分配中各自换行;外层 Overlay 负责叠放固定角标并裁剪溢出。
// 内部布局基于容器自己的约束;Page 和相机只提供外部展示/投影上下文。
// 文字 boxWidth=fill 占父分配的可用宽度,marginBottom 在文字边框盒外参与排布。
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
shared.boxWidth = "fill"
shared.marginBottom = 8
// 左 Column 使用 fill、min/max 宽度限制和四边边框;非对称 paddingLeft 覆盖统一 padding。
left = Column(16, 12, TextBlock("Row / Column", 2), shared, Button("继续探索"))
left.boxWidth = "fill"
left.minWidth = 180
left.maxWidth = 360
left.borderLeft = 2
left.borderTop = 2
left.borderRight = 2
left.borderBottom = 2
left.borderColor = "#CBD5E1"
left.paddingLeft = 24
left.crossAlign = "center"
// 右 Column 也引用同一 shared,但最大宽只有 240,因此对应 Mount 的换行高度可以不同。
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
right.boxWidth = "fill"
right.maxWidth = 240
right.crossAlign = "end"
// Row 分配有限 760 主轴空间,扣 padding/gap 后在两侧 fill 子项之间分配并服从上限。
content = Row(24, 24, left, right)
content.boxWidth = 760
content.crossAlign = "start"
// 角标是 Overlay 的另一子项,alignSelf 与 margin 控制它在容器交叉轴上的位置。
badge = TextBlock("Endorphin · layout")
badge.marginRight = 12
badge.marginBottom = 12
badge.alignSelf = "end"
// 固定 800x400 的 Overlay 不按线性顺序挤开兄弟,overflow=clip 同时约束绘制和命中。
surface = Overlay(0, 0, content, badge)
surface.boxWidth = 800
surface.boxHeight = 400
surface.mainAlign = "end"
surface.overflow = "clip"
// Page 只提供展示入口,根容器自身的盒模型规则不依赖页面导航方式。
page = Page()
page.title = "嵌套盒模型"
page.render(surface)
+40 -5
View File
@@ -1,7 +1,9 @@
// Endor Campus — a multi-page learning environment inspired by Canvas LMS. // 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。
// Fictional student/course data. Paste the complete source into Console and Enter. // 无服务器、账号登录或真实上传;提交/评分按钮只应用预设字段值,其他页通过绑定读同一状态。
// All seven tabs are ordinary managed Pages. There is no server or Canvas login. // 作为一次完整输入提交;七页共用同一VM,会话重置后数据消失。
// 白色圆角卡片容器,继承 Column 的排版,只覆盖自身背景绘制。
class CampusPanel: Column { class CampusPanel: Column {
// 按当前出现位置 geometry 创建一条背景命令,子项由展示遍历继续绘制。
function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
fill.kind = "roundedRect" fill.kind = "roundedRect"
@@ -14,18 +16,23 @@ class CampusPanel: Column {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// 通过额外 width 字段固定表格列的期望宽,保持成绩表头和行对齐。
class CampusCell: Column { class CampusCell: Column {
width: Number width: Number
// 先算子内容自然高度,再改新测量结果宽度,保留真实字体引起的高度。
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints) result = Column.measure(self, mount, constraints)
result.width = self.width result.width = self.width
return result return result
} }
} }
// 页面内容里的演示导航栏,与应用自身的原生页面侧栏是两棵不同控件树。
class CampusSidebar: Column { class CampusSidebar: Column {
// 返回固定168x620期望尺寸,外层 ScrollBlock 承担超出区域的滚动。
function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(168, 620) return MeasureResult(168, 620)
} }
// 为导航区创建深红背景,位置/大小来自当前Mount,文字等子项保持独立命令。
function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
fill.kind = "roundedRect" fill.kind = "roundedRect"
@@ -38,11 +45,14 @@ class CampusSidebar: Column {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// Button 子类增加背景字段,固定导航按钮尺寸,并保留原生事件目标能力。
class CampusNav: Button { class CampusNav: Button {
background: String background: String
// 导航按钮期望144x40,确保四个入口宽高一致。
function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(144, 40) return MeasureResult(144, 40)
} }
// 取原生按钮背景命令改色/圆角;文字由后面 replace 后的子项绘制,不在此重复生成。
function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList {
fill = Button.paint(self, mount, geometry).commands.at(0) fill = Button.paint(self, mount, geometry).commands.at(0)
fill.color = self.background fill.color = self.background
@@ -50,6 +60,7 @@ class CampusNav: Button {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// 课程业务记录,grade/progress 被多个页面绑定读取,其余静态字段构建时直接读取。
class CampusCourse { class CampusCourse {
title: String title: String
code: String code: String
@@ -57,6 +68,7 @@ class CampusCourse {
grade: String grade: String
progress: String progress: String
} }
// 实验状态集中在一个对象,course 指向同一课程模型,评分因此能影响全站课程摘要。
class CampusWork { class CampusWork {
status: String status: String
score: String score: String
@@ -65,12 +77,14 @@ class CampusWork {
todo: String todo: String
result: String result: String
course: CampusCourse course: CampusCourse
// 模拟提交只改状态、文件名提示、待办和结果文字,不访问磁盘或网络。
function submit(self: CampusWork, event: Event): Unit { function submit(self: CampusWork, event: Event): Unit {
self.status = "已提交 · 等待评分" self.status = "已提交 · 等待评分"
self.submission = "lab01.endor · 已提交示例内容" self.submission = "lab01.endor · 已提交示例内容"
self.todo = "已完成本次实验提交" self.todo = "已完成本次实验提交"
self.result = "示例提交成功,可以切到作业中心查看同一条记录。" self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
} }
// 应用预设92分和反馈,并更新所属课程成绩/进度;绑定让仪表盘、课程、成绩页同步观察。
function assess(self: CampusWork, event: Event): Unit { function assess(self: CampusWork, event: Event): Unit {
self.status = "已评分" self.status = "已评分"
self.score = "92 / 100" self.score = "92 / 100"
@@ -81,6 +95,7 @@ class CampusWork {
self.course.progress = "已完成 4 / 6 个学习单元" self.course.progress = "已完成 4 / 6 个学习单元"
self.result = "已应用预设评分;仪表盘、课程主页和成绩总览同步更新。" self.result = "已应用预设评分;仪表盘、课程主页和成绩总览同步更新。"
} }
// 恢复初始实验与课程状态,保留所有原对象和绑定,便于反复演示。
function reset(self: CampusWork, event: Event): Unit { function reset(self: CampusWork, event: Event): Unit {
self.status = "未提交" self.status = "未提交"
self.score = "— / 100" self.score = "— / 100"
@@ -92,20 +107,25 @@ class CampusWork {
self.result = "已恢复实验、课程成绩和学习进度的示例状态。" self.result = "已恢复实验、课程成绩和学习进度的示例状态。"
} }
} }
// 用统一包装构造表格单元格;传入的content是已有Block引用。
function campusCell(content: Block, width: Number): Block { function campusCell(content: Block, width: Number): Block {
return CampusCell(0, 0, content, width) return CampusCell(0, 0, content, width)
} }
// 显式事件适配器把click的Event与Page.open可选Window参数分开,不能直接把Event误当窗口。
class CampusTarget { class CampusTarget {
page: Page page: Page
// Consume the click Event here; Page.open's optional argument is a Window. // 事件参数在适配器这一层消化,Page.open 只接收其声明的可选 Window。
// 消费事件但不传给Page.open;无参open表示在合适窗口打开/激活目标页。
function open(self: CampusTarget, event: Event): Unit { self.page.open() } function open(self: CampusTarget, event: Event): Unit { self.page.open() }
} }
// 为普通按钮创建持有目标Page的适配器,并把其绑定方法注册为click回调。
function campusLink(label: String, target: Page): Button { function campusLink(label: String, target: Page): Button {
button = Button(label) button = Button(label)
action = CampusTarget(target) action = CampusTarget(target)
button.on("click", action.open) button.on("click", action.open)
return button return button
} }
// 构造带指定背景的导航按钮,替换原生文字子槽为白字,然后复用事件适配器连接目标页。
function campusNav(label: String, target: Page, background: String): Button { function campusNav(label: String, target: Page, background: String): Button {
button = CampusNav(label, background) button = CampusNav(label, background)
button.replace(0, TextBlock(label, 1, "#FFFFFF")) button.replace(0, TextBlock(label, 1, "#FFFFFF"))
@@ -113,6 +133,7 @@ function campusNav(label: String, target: Page, background: String): Button {
button.on("click", action.open) button.on("click", action.open)
return button return button
} }
// 用目标Page和四个色调参数构建整块导航;不同页面复用目标资源但用不同高亮背景。
function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block { function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block {
return CampusSidebar(12, 10, return CampusSidebar(12, 10,
TextBlock("Endor", 2, "#FFFFFF"), TextBlock("Endor", 2, "#FFFFFF"),
@@ -125,6 +146,7 @@ function campusNavigation(home: Page, course: Page, assignments: Page, grades: P
TextBlock("2026 秋季学期", 1, "#E7BAC0"), TextBlock("2026 秋季学期", 1, "#E7BAC0"),
TextBlock("本地演示", 1, "#E7BAC0")) TextBlock("本地演示", 1, "#E7BAC0"))
} }
// 把演示导航与标题/正文横排,外层有限ScrollBlock启用双轴,容纳固定宽度演示内容。
function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block { function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block {
scroll = ScrollBlock(1120, 680, scroll = ScrollBlock(1120, 680,
Row(24, 28, navigation, Row(24, 28, navigation,
@@ -135,6 +157,7 @@ function campusShell(navigation: Block, title: String, subtitle: String, body: B
scroll.axis = "both" scroll.axis = "both"
return scroll return scroll
} }
// 卡片静态显示课程代码/标题/教师,grade 用绑定随评分变化,入口按钮引用真实课程Page。
function campusCourseCard(course: CampusCourse, page: Page, color: String): Block { function campusCourseCard(course: CampusCourse, page: Page, color: String): Block {
return CampusPanel(0, 0, return CampusPanel(0, 0,
RectBlock(248, 44, 8, color), RectBlock(248, 44, 8, color),
@@ -145,6 +168,7 @@ function campusCourseCard(course: CampusCourse, page: Page, color: String): Bloc
ui.textOf(course, "grade", 2, color), ui.textOf(course, "grade", 2, color),
campusLink("进入课程", page))) campusLink("进入课程", page)))
} }
// 成绩表一行复用固定列宽单元格,当前grade为响应式文字,详情链接指向指定课程页。
function campusGradeRow(course: CampusCourse, target: Page): Block { function campusGradeRow(course: CampusCourse, target: Page): Block {
return Column(0, 12, return Column(0, 12,
Row(0, 0, Row(0, 0,
@@ -154,6 +178,7 @@ function campusGradeRow(course: CampusCourse, target: Page): Block {
campusLink("课程详情", target)), campusLink("课程详情", target)),
RectBlock(752, 1, 0, "#EDF0F3")) RectBlock(752, 1, 0, "#EDF0F3"))
} }
// 组合课程概况/进度和静态学习单元;上方两个链接复用作业/成绩Page,不创建新页面。
function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block { function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block {
return Column(0, 20, return Column(0, 20,
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)), Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
@@ -171,11 +196,13 @@ function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String
RectBlock(752, 1, 0, "#EDF0F3"), RectBlock(752, 1, 0, "#EDF0F3"),
TextBlock(moduleThree, 1, "#526372"))) TextBlock(moduleThree, 1, "#526372")))
} }
// The shared object graph is created once; each Page projects those same objects. // 共享对象图只构造一次,各Page的文字绑定投影同一业务字段。
// 只创建三份课程模型和一份实验模型,后续各页读取这些对象,避免页面间状态副本不一致。
campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师 · 周二 / 周四", "86.0%", "已完成 3 / 6 个学习单元") campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师 · 周二 / 周四", "86.0%", "已完成 3 / 6 个学习单元")
campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元") campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元")
campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元") campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元")
campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming) campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming)
// 先创建全部七个Page,允许导航按钮互相引用;随后逐一render建立内容。
campusDashboard = Page() campusDashboard = Page()
campusDashboard.title = "Campus · 仪表盘" campusDashboard.title = "Campus · 仪表盘"
campusProgrammingPage = Page() campusProgrammingPage = Page()
@@ -190,10 +217,12 @@ campusGrades = Page()
campusGrades.title = "Campus · 成绩总览" campusGrades.title = "Campus · 成绩总览"
campusWorkPage = Page() campusWorkPage = Page()
campusWorkPage.title = "实验 01 · 提交详情" campusWorkPage.title = "实验 01 · 提交详情"
// 预建四种高亮导航树,课程三页共享course导航,Page切换时各自拥有独立Mount。
campusHomeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#A64350", "#852B38", "#852B38", "#852B38") campusHomeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#A64350", "#852B38", "#852B38", "#852B38")
campusCourseNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#A64350", "#852B38", "#852B38") campusCourseNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#A64350", "#852B38", "#852B38")
campusAssignmentNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#A64350", "#852B38") campusAssignmentNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#A64350", "#852B38")
campusGradeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#852B38", "#A64350") campusGradeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#852B38", "#A64350")
// 仪表盘展示课程卡和当前实验摘要,status/todo均从同一campusWork绑定读取。
campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎回来。这里是你的课程与近期学习安排。", campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎回来。这里是你的课程与近期学习安排。",
Column(0, 20, Column(0, 20,
Row(0, 16, Row(0, 16,
@@ -206,6 +235,7 @@ campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎
ui.textOf(campusWork, "todo", 1, "#7C8793"), ui.textOf(campusWork, "todo", 1, "#7C8793"),
campusLink("打开作业详情", campusWorkPage)), campusLink("打开作业详情", campusWorkPage)),
TextBlock("虚构课程与学生数据 · Endor 本地多页面演示", 1, "#8C959F")))) TextBlock("虚构课程与学生数据 · Endor 本地多页面演示", 1, "#8C959F"))))
// 程序设计课程附加实验入口;数学和英语页复用相同概况结构但读取不同课程模型。
campusProgrammingPage.render(campusShell(campusCourseNav, "计算机程序设计", "课程 / CS 101 / 主页", campusProgrammingPage.render(campusShell(campusCourseNav, "计算机程序设计", "课程 / CS 101 / 主页",
Column(0, 16, Column(0, 16,
campusCourseBody(campusProgramming, "通过实际编程理解对象、函数与交互。", "01 对象与字段 · 已完成", "02 函数与方法 · 已完成", "03 事件与可视界面 · 进行中", campusAssignments, campusGrades), campusCourseBody(campusProgramming, "通过实际编程理解对象、函数与交互。", "01 对象与字段 · 已完成", "02 函数与方法 · 已完成", "03 事件与可视界面 · 进行中", campusAssignments, campusGrades),
@@ -214,6 +244,7 @@ campusMathPage.render(campusShell(campusCourseNav, "高等数学 I", "课程 / M
campusCourseBody(campusMathematics, "学习极限、微分与积分的基本方法。", "01 函数与极限 · 已完成", "02 导数及其应用 · 已完成", "03 积分方法 · 进行中", campusAssignments, campusGrades))) campusCourseBody(campusMathematics, "学习极限、微分与积分的基本方法。", "01 函数与极限 · 已完成", "02 导数及其应用 · 已完成", "03 积分方法 · 进行中", campusAssignments, campusGrades)))
campusEnglishPage.render(campusShell(campusCourseNav, "学术英语", "课程 / ENG 102 / 主页", campusEnglishPage.render(campusShell(campusCourseNav, "学术英语", "课程 / ENG 102 / 主页",
campusCourseBody(campusEnglish, "阅读学术文本,练习清晰的论证与表达。", "01 Academic reading · 已完成", "02 Evidence and arguments · 已完成", "03 Research writing · 进行中", campusAssignments, campusGrades))) campusCourseBody(campusEnglish, "阅读学术文本,练习清晰的论证与表达。", "01 Academic reading · 已完成", "02 Evidence and arguments · 已完成", "03 Research writing · 进行中", campusAssignments, campusGrades)))
// 作业中心只有程序设计实验为共享动态模型,其余两项是固定演示文字。
campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本学期 / 全部课程 / 作业与提交", campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本学期 / 全部课程 / 作业与提交",
Column(0, 20, Column(0, 20,
CampusPanel(20, 12, CampusPanel(20, 12,
@@ -227,6 +258,7 @@ campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本
CampusPanel(20, 12, CampusPanel(20, 12,
TextBlock("Reading response", 1, "#263444"), TextBlock("Reading response", 1, "#263444"),
TextBlock("ENG 102 · 已评分 · 88 / 100", 1, "#7C8793"))))) TextBlock("ENG 102 · 已评分 · 88 / 100", 1, "#7C8793")))))
// 成绩总览复用课程grade绑定和实验score/feedback,评分无需逐页手动写UI。
campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学期 / 当前课程成绩", campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学期 / 当前课程成绩",
Column(0, 20, Column(0, 20,
CampusPanel(20, 16, CampusPanel(20, 16,
@@ -242,12 +274,14 @@ campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学
Row(0, 24, TextBlock("实验 01:对象与函数"), ui.textOf(campusWork, "score", 1, "#315E86")), Row(0, 24, TextBlock("实验 01:对象与函数"), ui.textOf(campusWork, "score", 1, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987"), ui.textOf(campusWork, "feedback", 1, "#6D7987"),
campusLink("打开作业与反馈", campusWorkPage))))) campusLink("打开作业与反馈", campusWorkPage)))))
// 三个操作按钮连接同一个实验对象的submit/assess/reset,均为真实源方法回调。
campusSubmitButton = Button("模拟提交实验") campusSubmitButton = Button("模拟提交实验")
campusSubmitButton.on("click", campusWork.submit) campusSubmitButton.on("click", campusWork.submit)
campusAssessButton = Button("应用示例评分") campusAssessButton = Button("应用示例评分")
campusAssessButton.on("click", campusWork.assess) campusAssessButton.on("click", campusWork.assess)
campusResetButton = Button("恢复演示数据") campusResetButton = Button("恢复演示数据")
campusResetButton.on("click", campusWork.reset) campusResetButton.on("click", campusWork.reset)
// 详情页组合要求、提交状态、操作按钮和反馈;所有输入仍只是预设本地状态转换。
campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情", campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情",
Column(0, 20, Column(0, 20,
Row(0, 16, campusLink("返回课程", campusProgrammingPage), campusLink("全部作业", campusAssignments), campusLink("成绩总览", campusGrades)), Row(0, 16, campusLink("返回课程", campusProgrammingPage), campusLink("全部作业", campusAssignments), campusLink("成绩总览", campusGrades)),
@@ -265,4 +299,5 @@ campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函
Row(0, 24, TextBlock("成绩与反馈"), ui.textOf(campusWork, "score", 2, "#315E86")), Row(0, 24, TextBlock("成绩与反馈"), ui.textOf(campusWork, "score", 2, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987")), ui.textOf(campusWork, "feedback", 1, "#6D7987")),
ui.textOf(campusWork, "result", 1, "#7C8793")))) ui.textOf(campusWork, "result", 1, "#7C8793"))))
// 逐页render会改变当前活动页,最后显式选择仪表盘作为打开后的首页。
campusDashboard.activate() campusDashboard.activate()
+9
View File
@@ -1,28 +1,37 @@
// 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。
// 两个 textOf 共享同一模型字段,修改一次业务值会更新两个展示位置。
// 定义业务状态和公共事件入口,Number/String 字段在构造与赋值时按声明契约检查。
class Counter { class Counter {
value: Number value: Number
lastEvent: String lastEvent: String
// 保存实际事件种类,再通过 self 调用可覆盖的方法;接收者保持具体子类身份。
function activate(self: Counter, event: Event): Unit { function activate(self: Counter, event: Event): Unit {
self.lastEvent = event.kind self.lastEvent = event.kind
self.change() self.change()
} }
// 基类默认行为把值置为 10,子类可复用入口并替换这部分策略。
function change(self: Counter): Unit { function change(self: Counter): Unit {
self.value = 10 self.value = 10
} }
} }
// 继承 Counter 的字段与 activate,仅替换 change;构造参数仍按继承字段顺序传入。
class DemoCounter: Counter { class DemoCounter: Counter {
// 覆盖把值设为 12,演示从继承方法内部发起的调用也会选中实际接收者的方法。
function change(self: DemoCounter): Unit { function change(self: DemoCounter): Unit {
self.value = 12 self.value = 12
} }
} }
// 创建唯一模型,之后所有绑定和按钮回调都指向它,无需把 GUI 状态复制到另一份存储。
model = DemoCounter(9, "等待点击") model = DemoCounter(9, "等待点击")
first = ui.textOf(model, "value", 2) first = ui.textOf(model, "value", 2)
second = ui.textOf(model, "value", 2) second = ui.textOf(model, "value", 2)
eventName = ui.textOf(model, "lastEvent") eventName = ui.textOf(model, "lastEvent")
button = Button("调用继承方法,再分派到覆盖方法") button = Button("调用继承方法,再分派到覆盖方法")
// 注册绑定方法,运行时将 click 的 Event 传给 activate;最后 Page.render 才把对象树显示出来。
button.onClick(model.activate) button.onClick(model.activate)
page = Page() page = Page()
page.render(Column(24, 16, TextBlock("Endor class:继承、self 与动态方法分派", 2), Row(0, 16, first, second), button, eventName)) page.render(Column(24, 16, TextBlock("Endor class:继承、self 与动态方法分派", 2), Row(0, 16, first, second), button, eventName))
+9
View File
@@ -1,11 +1,20 @@
// 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。
// 省略 RectBlock 的 radius/color 不应使字符串 tag 被错当成原生颜色参数。
// 在原生矩形后增加 tag 字段;位置构造仍保留 width/height 及可选圆角/颜色规则。
class TaggedRect: RectBlock { tag: String } class TaggedRect: RectBlock { tag: String }
// 在可变数量 Block 子项之后接收源 tag,子项列表与字段不混用。
class TaggedColumn: Column { tag: String } class TaggedColumn: Column { tag: String }
// 保留原生事件 kind,再追加业务字符串字段。
class TaggedEvent: Event { tag: String } class TaggedEvent: Event { tag: String }
// 原生列表成员之后追加 tag,列表元素仍可保存不同托管类型。
class TaggedList: ObjectList { tag: String } class TaggedList: ObjectList { tag: String }
// 将需要更新的矩形作为强业务引用保存,按钮回调只修改它的颜色。
class Actions { class Actions {
rect: TaggedRect rect: TaggedRect
// 写原对象的 color,挂载显示由依赖跟踪刷新,不需要重新构造整个页面。
function recolor(self: Actions): Unit { self.rect.color = "#237A45" } function recolor(self: Actions): Unit { self.rect.color = "#237A45" }
} }
// 这里刻意省略原生可选项,用末尾字符串初始化子类字段;后面的列表/Column 做对应可变参数演示。
rect = TaggedRect(180, 60, "省略 radius/color 后的字段") rect = TaggedRect(180, 60, "省略 radius/color 后的字段")
event = TaggedEvent("click", "事件字段") event = TaggedEvent("click", "事件字段")
items = TaggedList(rect, event, "列表成员之后的字段") items = TaggedList(rect, event, "列表成员之后的字段")
+7
View File
@@ -1,5 +1,10 @@
// 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。
// 这里刻意重复增量语句,方便看到暂停点;取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
// 每个实例有独立 steps 状态,两个任务不会把局部帧或计数存储混在一起。
class CooperativeWork { class CooperativeWork {
steps: Object steps: Object
// 普通零参数方法被 spawn 调度后可跨量子继续;每条 increment 更新同一实例的计数。
// 连续语句无需手工 yield,VM 按累计预算暂停,恢复时不会从第一条重放。
function run(self: CooperativeWork): Unit { function run(self: CooperativeWork): Unit {
self.steps.increment() self.steps.increment()
self.steps.increment() self.steps.increment()
@@ -67,10 +72,12 @@ class CooperativeWork {
self.steps.increment() self.steps.increment()
} }
} }
// 创建两个独立 owner,虽然 key 都叫 work,也不会相互替换;owner/key 组合才是任务身份。
left = CooperativeWork(ui.state(0)) left = CooperativeWork(ui.state(0))
right = CooperativeWork(ui.state(0)) right = CooperativeWork(ui.state(0))
leftTask = ui.spawn(left,"work",left.run) leftTask = ui.spawn(left,"work",left.run)
rightTask = ui.spawn(right,"work",right.run) rightTask = ui.spawn(right,"work",right.run)
// 状态文字绑定到 Task.status,业务进度可在 Console 查询 left/right 的 steps.value。
leftStatus = TextBlock("") leftStatus = TextBlock("")
rightStatus = TextBlock("") rightStatus = TextBlock("")
leftStatus.bind("text",leftTask.status) leftStatus.bind("text",leftTask.status)
+10
View File
@@ -1,3 +1,6 @@
// 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。
// EventLog 用一个递增序列记录实际次序;改变监听器/传播标记可对比跳过与停止的语义。
// 保存各阶段序号与最近 phase;count 是共享状态,其他字段直接写在本模型中。
class EventLog { class EventLog {
count: Object count: Object
captureStep: Number captureStep: Number
@@ -5,24 +8,29 @@ class EventLog {
targetStep: Number targetStep: Number
bubbleStep: Number bubbleStep: Number
phase: String phase: String
// 祖先捕获阶段先递增序列,再保存 captureStep 和 Event.phase。
function capture(self: EventLog, event: Event): Unit { function capture(self: EventLog, event: Event): Unit {
self.count.increment() self.count.increment()
self.captureStep = self.count.value self.captureStep = self.count.value
self.phase = event.phase self.phase = event.phase
} }
// 目标监听器的执行点;它排在目标类型 onEvent 方法之后。
function target(self: EventLog, event: Event): Unit { function target(self: EventLog, event: Event): Unit {
self.count.increment() self.count.increment()
self.targetStep = self.count.value self.targetStep = self.count.value
self.phase = event.phase self.phase = event.phase
} }
// 祖先冒泡阶段记录最后序号,未被 stopPropagation 时才到达。
function bubble(self: EventLog, event: Event): Unit { function bubble(self: EventLog, event: Event): Unit {
self.count.increment() self.count.increment()
self.bubbleStep = self.count.value self.bubbleStep = self.count.value
self.phase = event.phase self.phase = event.phase
} }
} }
// 覆盖目标方法,比额外 on(click,...) 监听器更早处理目标阶段事件。
class EventButton: Button { class EventButton: Button {
model: EventLog model: EventLog
// 把模型序列递增后写 methodStep,供断言与 capture/target/bubble 比较。
function onEvent(self: EventButton, event: Event): Unit { function onEvent(self: EventButton, event: Event): Unit {
self.model.count.increment() self.model.count.increment()
self.model.methodStep = self.model.count.value self.model.methodStep = self.model.count.value
@@ -32,6 +40,8 @@ class EventButton: Button {
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击") model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model) button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model)
root = Column(24, 16, TextBlock("同一对象系统中的事件分派", 2), button, ui.textOf(model.count, "value"), ui.textOf(model, "phase")) root = Column(24, 16, TextBlock("同一对象系统中的事件分派", 2), button, ui.textOf(model.count, "value"), ui.textOf(model, "phase"))
// capture 选项显式选择捕获;不带选项的祖先监听器默认在冒泡阶段执行。
// 返回的令牌可 disconnect,同次派发中断开尚未执行的监听器也应生效。
capture = root.on("click", model.capture, "capture") capture = root.on("click", model.capture, "capture")
target = button.on("click", model.target) target = button.on("click", model.target)
bubble = root.on("click", model.bubble) bubble = root.on("click", model.bubble)
+7
View File
@@ -1,17 +1,24 @@
// 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。
// 退出先取消任务、关闭窗口,再进行允许的卸载回调;已停止 VM 的清理不能重新进入源代码。
// 在原生文字类型上增加共享日志,用来观察每个 Mount 的卸载通知。
class ExitText: TextBlock { class ExitText: TextBlock {
log: Object log: Object
// 每次真实卸载递增同一 log;普通重绘不调用,重复 shutdown 也不应重复调用。
function onUnmount(self: ExitText, mount: Mount): Unit { function onUnmount(self: ExitText, mount: Mount): Unit {
self.log.increment() self.log.increment()
} }
} }
log = ui.state(0) log = ui.state(0)
// 文字业务对象只创建一次,Page 与另一窗都引用它,但两边有各自的 Mount 生命周期。
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log) watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
page = Page() page = Page()
page.title = "退出生命周期" page.title = "退出生命周期"
page.render(Column(24,16,watched,ui.textOf(log,"value"))) page.render(Column(24,16,watched,ui.textOf(log,"value")))
// 创建同 VM 内的另一原生窗口;迁移 other 后仍保留 watched 的业务身份。
second = Window() second = Window()
second.setTitle("Exit lifecycle secondary") second.setTitle("Exit lifecycle secondary")
other = Page() other = Page()
other.render(watched) other.render(watched)
other.moveToWindow(second) other.moveToWindow(second)
// 应用级长定时任务用于观察退出会取消尚未完成的工作,不让退出过程等满 30 秒。
pending = ui.sleep(ui,"pending",30000) pending = ui.sleep(ui,"pending",30000)
+6
View File
@@ -1,12 +1,18 @@
// 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。
// 绑定显示读取该字段,GUI 回调与 Console 赋值受同一契约约束。
// 增加 value:Number;构造时先传原生 text,再传源字段值。
class CounterText: TextBlock { class CounterText: TextBlock {
value: Number value: Number
// 收到 click 后把已有数值改为 10,依赖文字随之更新,不修改原生 text 内容。
function change(self: CounterText, event: Event): Unit { function change(self: CounterText, event: Event): Unit {
self.value = 10 self.value = 10
} }
} }
// 声明 target 必须是 Block,可接收 CounterText,因为它是 TextBlock 的子类。
class Model { class Model {
target: Block target: Block
} }
// 构造唯一受检对象,Model、事件回调和 textOf 均引用它。
counter = CounterText("共享的原生子类对象", 9) counter = CounterText("共享的原生子类对象", 9)
model = Model(counter) model = Model(counter)
button = Button("把 Number 字段改成 10") button = Button("把 Number 字段改成 10")
+9
View File
@@ -1,11 +1,17 @@
// flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。
// 增长达到上限或收缩达到下限后,剩余空间/缺额重新分给其他尚可调整的项,固定项保持 60。
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
class Strip: Row { class Strip: Row {
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(340, 60) return MeasureResult(340, 60)
} }
} }
// 分别持有两个可调整矩形,按钮切换增长/收缩策略,而不是手动写最终 bounds。
class Actions { class Actions {
first: RectBlock first: RectBlock
second: RectBlock second: RectBlock
// 两项 basis=100,按1:3分增长;首项 max=105 触顶后,多余增长转给第二项。
function grow(self: Actions): Unit { function grow(self: Actions): Unit {
self.first.flexMin = 0 self.first.flexMin = 0
self.first.flexMax = 105 self.first.flexMax = 105
@@ -14,6 +20,7 @@ class Actions {
self.first.flex = 1 self.first.flex = 1
self.second.flex = 3 self.second.flex = 3
} }
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
function shrink(self: Actions): Unit { function shrink(self: Actions): Unit {
self.first.flexMax = 300 self.first.flexMax = 300
self.first.flexMin = 150 self.first.flexMin = 150
@@ -25,6 +32,7 @@ class Actions {
self.second.flexShrink = 1 self.second.flexShrink = 1
} }
} }
// 自然宽20/40与basis可不同;fixed不设置权重,保持原始60宽。
first = RectBlock(20, 20, 4, "#A4C8F0") first = RectBlock(20, 20, 4, "#A4C8F0")
second = RectBlock(40, 20, 4, "#A6D8B2") second = RectBlock(40, 20, 4, "#A6D8B2")
fixed = RectBlock(60, 20, 4, "#E8C990") fixed = RectBlock(60, 20, 4, "#E8C990")
@@ -33,6 +41,7 @@ grow = Button("增长:上限后重新分配")
grow.onClick(actions.grow) grow.onClick(actions.grow)
shrink = Button("收缩:最小尺寸后重新分配") shrink = Button("收缩:最小尺寸后重新分配")
shrink.onClick(actions.shrink) shrink.onClick(actions.shrink)
// 先初始化增长策略再挂载,首次发布即采用105/135/60,不需等待按钮点击。
actions.grow() actions.grow()
strip = Strip(10, 10, first, second, fixed) strip = Strip(10, 10, first, second, fixed)
page = Page() page = Page()
+7
View File
@@ -1,8 +1,14 @@
// 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
class Actions { class Actions {
filler: RectBlock filler: RectBlock
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
function balanced(self: Actions): Unit { self.filler.flex = 1 } function balanced(self: Actions): Unit { self.filler.flex = 1 }
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
function narrow(self: Actions): Unit { self.filler.flex = 3 } function narrow(self: Actions): Unit { self.filler.flex = 3 }
} }
// 共享文字的 flex=1 是业务布局策略,具体几何与自然尺寸缓存仍属于各 Mount。
paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。") paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
paragraph.flex = 1 paragraph.flex = 1
filler = RectBlock(20, 20) filler = RectBlock(20, 20)
@@ -12,6 +18,7 @@ balanced = Button("等比例分配")
balanced.onClick(actions.balanced) balanced.onClick(actions.balanced)
narrow = Button("文字只占四分之一") narrow = Button("文字只占四分之一")
narrow.onClick(actions.narrow) narrow.onClick(actions.narrow)
// 首排两个可伸缩子项,次排只有文字 flex 与固定 300px 兄弟,因此首排按钮不会改次排宽度。
first = Row(10, 10, paragraph, filler) first = Row(10, 10, paragraph, filler)
second = Row(10, 10, paragraph, RectBlock(300, 20)) second = Row(10, 10, paragraph, RectBlock(300, 20))
page = Page() page = Page()
+6
View File
@@ -1,5 +1,11 @@
// 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。
// 布局使用实际字形宽度,iiii 与 WWWW 后的色块位置应不同,不能用字符数量估算宽度。
// 字号2与独立颜色用于标题,中文是否可见取决于运行环境可用CJK字体。
let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422") let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422")
// 同一文字控件包含中英文和重音字符,用于观察字体回退与基线。
let detail = ui.text("中文 English 123 — café", 1, "#606060") let detail = ui.text("中文 English 123 — café", 1, "#606060")
// 两组相同尺寸色块跟在窄/宽字符后,12px gap 保持一致,差异来自真实文字测量。
let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF")) let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF"))
// 根Column统一padding和间距,render后这些对象才有具体Mount与绘图命令。
let page = ui.page() let page = ui.page()
page.render(ui.column(24, 12, title, detail, row)) page.render(ui.column(24, 12, title, detail, row))
+9
View File
@@ -1,13 +1,22 @@
// Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。
// 参数和返回注解在真实调用边界校验,事件注册同样使用这些签名。
// Number 输入原样返回,用于说明参数类型在进入函数体前检查。
function identity(value: Number): Number { return value } function identity(value: Number): Number { return value }
// 接受 Function 值并零参数调用,结果仍需符合本函数的 Number 返回契约。
function callNumber(callback: Function): Number { return callback() } function callNumber(callback: Function): Number { return callback() }
// 接受声明 Visual 能力的对象,返回 Block;调用时两侧契约都要成立。
function visual(value: Visual): Block { return value } function visual(value: Visual): Block { return value }
// 保留共享 state,read 作为普通绑定函数使用,click 作为接收 Event 的事件回调。
class Actions { class Actions {
state: Object state: Object
// 读取模型值而不修改业务状态,保存 actions.read 时接收者已绑定。
function read(self: Actions): Number { return self.state.value } function read(self: Actions): Number { return self.state.value }
// 事件入口可忽略 Event 内容,但参数契约仍要求它是 Event,再修改同一 state。
function click(self: Actions, event: Event): Unit { self.state.increment() } function click(self: Actions, event: Event): Unit { self.state.increment() }
} }
state = ui.state(0) state = ui.state(0)
actions = Actions(state) actions = Actions(state)
// 保存绑定方法对象,之后无参调用仍使用 actions 作为 self;不是重新按字符串查找对象。
savedFunction = actions.read savedFunction = actions.read
initial = identity(callNumber(savedFunction)) initial = identity(callNumber(savedFunction))
button = Button("调用带类型检查的对象方法") button = Button("调用带类型检查的对象方法")
+10 -4
View File
@@ -1,12 +1,13 @@
// Record ui.stats().objects, call mountCycle(), then inspect phase.value and // 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。
// pending.value.status(). Close the tab: the referenced Block still waits. // 关闭临时页仍可因业务引用保留等待任务;releaseCycle 清掉外部引用后,缓存不应让整个引用环常驻。
// releaseCycle() removes both external references; the next objects query // 比较操作前后对象数时,关闭页面只解除展示根;holder/pending 外部引用也释放后才应回到基线。
// should return to the same baseline before the five-minute wait finishes.
holder = ui.state(0) holder = ui.state(0)
pending = ui.state(0) pending = ui.state(0)
phase = ui.state(0) phase = ui.state(0)
// 通过 peer 指回包含自己的 Page,配合等待协程形成包含视觉缓存读依赖的引用环。
class CycleRect: RectBlock { class CycleRect: RectBlock {
peer: Object peer: Object
// sleep 归属该 rect,await 保留协程帧;先写 phase=1,只有实际恢复后才写 2 并改 Page 标题。
function wait(self: CycleRect): Unit { function wait(self: CycleRect): Unit {
gate = ui.sleep(self,"sleep",300000) gate = ui.sleep(self,"sleep",300000)
phase.set(1) phase.set(1)
@@ -14,15 +15,18 @@ class CycleRect: RectBlock {
phase.set(2) phase.set(2)
self.peer.title = "等待已结束" self.peer.title = "等待已结束"
} }
// 故意读取 peer.title,让布局依赖包含循环中的 Page;缓存应借用观察,不增加长期 GC 根。
function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult {
observed = self.peer.title observed = self.peer.title
return MeasureResult(180,40) return MeasureResult(180,40)
} }
// 绘制也读取 peer.title,检查绘制缓存与等待帧不会共同意外保活循环。
function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList {
observed = self.peer.title observed = self.peer.title
return RectBlock.paint(self,mount,geometry) return RectBlock.paint(self,mount,geometry)
} }
} }
// 创建临时 Page 和 CycleRect,相互引用后挂载,再把 Page/Task 分别放入两个外部 holder。
function mountCycle(): Unit { function mountCycle(): Unit {
transient = Page() transient = Page()
transient.title = "临时引用环" transient.title = "临时引用环"
@@ -32,11 +36,13 @@ function mountCycle(): Unit {
phase.set(0) phase.set(0)
pending.set(ui.spawn(rect,"workflow",rect.wait)) pending.set(ui.spawn(rect,"workflow",rect.wait))
} }
// 先关闭临时页,随后去掉两个外部引用;等待依赖无需等满五分钟,失去可达性后应整体回收。
function releaseCycle(): Unit { function releaseCycle(): Unit {
holder.value.close() holder.value.close()
holder.set(0) holder.set(0)
pending.set(0) pending.set(0)
} }
// 基线页面始终存在,用它比较操作前后的托管对象数,不把临时页面当作基线。
base = Page() base = Page()
base.title = "GC 基线" base.title = "GC 基线"
base.render(TextBlock("关闭临时 Page 后应回收引用环",2)) base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
+5
View File
@@ -1,11 +1,16 @@
// 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使绘制失效。
color = "#ED6A5A" color = "#ED6A5A"
// 保持原生几何,覆盖颜色策略,使示例只观察全局读取的依赖粒度。
class GlobalPaint: RectBlock { class GlobalPaint: RectBlock {
// 调用原生基类产出新命令,读取当前 color 并写到新命令;不在只读阶段修改全局。
function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList { function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self,mount,geometry) drawing = RectBlock.paint(self,mount,geometry)
drawing.commands.at(0).color = color drawing.commands.at(0).color = color
return drawing return drawing
} }
} }
// 显示固定大小矩形和操作提示,后续通过 Console 改同一个会话的 color。
rect = GlobalPaint(480,120,12,"#DDEEFF") rect = GlobalPaint(480,120,12,"#DDEEFF")
page = Page() page = Page()
page.title = "全局名字依赖" page.title = "全局名字依赖"
+7 -7
View File
@@ -1,10 +1,10 @@
function heading(value: Text): View { function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。
return ui.text(value, 2, "#242422") return ui.text(value, 2, "#242422") // 这里只创建文字对象,具体宽高由实际字体和出现位置约束测量。
} }
function main(): View { function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。
let title = heading("Hello Endor") let title = heading("Hello Endor") // 调用上述辅助函数,局部变量持有同一托管文字对象。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 矩形使用逻辑尺寸,圆角与颜色由原生载荷保存。
let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI")) let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI")) // Row 依次摆放文字、固定间隔和文字;共享几何仍由每次出现位置承担。
return ui.column(24, 12, title, rectangle, detail) return ui.column(24, 12, title, rectangle, detail) // Column 的 24px 内边距和 12px 间隔决定最终命令位置;返回后发布快照拥有所需数据。
} }
+297
View File
@@ -0,0 +1,297 @@
// All application state, motion, fire, hit reactions and scheduling live here.
// Tessera only supplies generic graphics/text/geometry, input and owned Tasks.
g = ui.graphics
math = g.math
assets = "examples/assets/manuscript/"
font = g.loadFont(assets + "IMFellEnglish-Regular.ttf", "IM FELL English")
story = g.textSlice(g.readText(assets + "story.txt"), 1, 1000000)
bodyText = g.prepareText(story, font, 21, 34, "#2A1A0A", 2)
smallText = g.prepareText(story, font, 16, 26, "#2A1A0A", 2)
hint = g.prepareText("Move the pointer. Hold to breathe fire.", font, 14, 22, "#705B44", 2)
function sprite(image: Object, x: Number, y: Number, scale: Number, angle: Number, ax: Number, ay: Number): Object {
return g.sprite(image, x, y, image.width * scale, image.height * scale, angle, ax, ay, 1)
}
function wrappedAngle(angle: Number): Number {
result = angle
while result > math.pi { result = result - math.pi * 2 }
while result < -math.pi { result = result + math.pi * 2 }
return result
}
class Segment {
x: Number
y: Number
angle: Number
image: Object
}
// Asset orientation is application data: angles are radians, clockwise in
// screen coordinates. Each PNG is calibrated against its own long axis.
class FireArt {
image: Object
axis: Number
}
class Direction { angle: Number }
class Flame {
x: Number
y: Number
vx: Number
vy: Number
size: Number
age: Number
art: FireArt
}
class Dragon {
segments: Object
flames: Object
fireImages: Object
tongue: Object
frontWing: Object
backWing: Object
targetX: Number
targetY: Number
pressed: Number
time: Number
lastMove: Number
seed: Number
function random(self: Dragon): Number {
self.seed = self.seed + 1
value = math.sin(self.seed * 12.9898) * 43758.5453
return value - math.floor(value)
}
function pointer(self: Dragon, event: Event): Unit {
bounds = event.mount.bounds()
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
self.targetX = (event.x - (bounds.width - 700 * scale) / 2) / scale
self.targetY = (event.y - 30) / scale
self.lastMove = math.now()
}
function down(self: Dragon, event: Event): Unit {
self.pointer(event)
self.pressed = 1
}
function up(self: Dragon, event: Event): Unit { self.pressed = 0 }
function leave(self: Dragon, event: Event): Unit {
self.pressed = 0
self.lastMove = -10
}
function step(self: Dragon): Unit {
self.time = math.now()
if self.time - self.lastMove > 2 {
x = math.float(150)
y = math.float(110)
i = 0
while i < self.segments.count {
s = self.segments.at(i)
angle = -i / 19 * math.pi * 0.7
if i > 0 {
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
}
s.x = s.x + (x - s.x) * 0.12
s.y = s.y + (y - s.y) * 0.12
s.angle = s.angle + wrappedAngle(angle - s.angle) * 0.12
i = i + 1
}
} else {
head = self.segments.at(0)
dx = self.targetX - head.x
dy = self.targetY - head.y
distance = math.sqrt(dx * dx + dy * dy)
if distance > 4 {
speed = math.min(distance, math.max(12, distance * 0.15))
head.x = head.x + dx / distance * speed
head.y = head.y + dy / distance * speed
head.angle = math.atan2(dy, dx)
}
i = 1
while i < self.segments.count {
previous = self.segments.at(i - 1)
s = self.segments.at(i)
angle = math.atan2(previous.y - s.y, previous.x - s.x)
s.angle = previous.angle + math.clamp(wrappedAngle(angle - previous.angle), -0.25, 0.25)
s.x = previous.x - math.cos(s.angle) * 30
s.y = previous.y - math.sin(s.angle) * 30
i = i + 1
}
}
i = self.flames.count
while i > 0 {
i = i - 1
p = self.flames.at(i)
p.age = p.age + 1
p.x = p.x + p.vx
p.y = p.y + p.vy
p.vx = p.vx * 0.95
p.vy = p.vy * 0.95 - 0.6
if p.age >= 12 { self.flames.remove(i) }
}
if self.pressed > 0 {
head = self.segments.at(0)
i = 0
while i < 1 {
if self.flames.count < 12 {
angle = head.angle + (self.random() - 0.5) * 0.25
speed = 20 + self.random() * 12
size = 12 + self.random() * 8
art = self.fireImages.at(math.floor(self.random() * self.fireImages.count))
self.flames.append(Flame(head.x + math.cos(head.angle) * 30, head.y + math.sin(head.angle) * 30, math.cos(angle) * speed, math.sin(angle) * speed, size, 0, art))
}
i = i + 1
}
}
}
function artwork(self: Dragon, ox: Number, oy: Number, scale: Number): Object {
commands = ObjectList()
wing = self.segments.at(5)
commands.append(sprite(self.backWing, ox + wing.x * scale, oy + wing.y * scale, scale * 0.24, wing.angle + math.sin(self.time * 3) * 0.4, 1, 1))
i = self.segments.count
while i > 0 {
i = i - 1
s = self.segments.at(i)
ax = math.float(0.5)
if i == 0 {
ax = 0.45
commands.append(sprite(self.tongue, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, -s.image.width * 0.3 / self.tongue.width, 0.5))
}
commands.append(sprite(s.image, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle, ax, 0.5))
if i == 5 { commands.append(sprite(self.frontWing, ox + s.x * scale, oy + s.y * scale, scale * 0.24, s.angle - math.sin(self.time * 3 + 0.5) * 0.4, 1, 1)) }
}
i = 0
while i < self.flames.count {
p = self.flames.at(i)
image = p.art.image
imageScale = p.size * scale / math.max(image.width, image.height)
angle = math.atan2(p.vy, p.vx) - p.art.axis
drawing = sprite(image, ox + p.x * scale, oy + p.y * scale, imageScale, angle, 0.5, 0.5)
drawing.opacity = 1 - p.age / 12
if p.age > 4 { drawing.tint = "#E08A30" }
if p.age > 8 { drawing.tint = "#F0C030" }
commands.append(drawing)
i = i + 1
}
return commands
}
function heat(self: Dragon, commands: ObjectList, ox: Number, oy: Number, scale: Number): Unit {
i = 0
while i < commands.count {
c = commands.at(i)
j = 0
while j < self.flames.count {
p = self.flames.at(j)
dx = c.x - ox - p.x * scale
if math.abs(dx) < 60 * scale {
dy = c.y - oy - p.y * scale
if math.abs(dy) < 60 * scale {
distance = math.sqrt(dx * dx + dy * dy)
if distance > 0.1 {
if distance < 60 * scale {
weight = (1 - distance / (60 * scale)) * (1 - p.age / 12)
c.x = c.x + dx / distance * weight * 8 * scale
c.y = c.y + dy / distance * weight * 8 * scale
c.angle = c.angle + weight * 0.12
c.opacity = math.max(0.3, c.opacity - weight * 0.2)
c.tint = "#C4402A"
}
}
}
}
j = j + 1
}
i = i + 1
}
}
}
class Manuscript: RectBlock {
dragon: Dragon
page: Object
prepared: Object
small: Object
initial: Object
timer: Object
function measure(self: Manuscript, mount: Mount, limits: Constraints): MeasureResult {
return MeasureResult(math.min(limits.maxWidth, 1000), math.min(limits.maxHeight, 900))
}
function onMount(self: Manuscript, mount: Mount): Unit {
self.timer = ui.after(self.page, "animation", 80, self.tick)
}
function onUnmount(self: Manuscript, mount: Mount): Unit { self.timer.cancel() }
function tick(self: Manuscript): Unit {
self.dragon.step()
self.timer = ui.after(self.page, "animation", 80, self.tick)
}
function paint(self: Manuscript, mount: Mount, bounds: Bounds): DisplayList {
scale = math.max(0.2, math.min(1, (bounds.width - 40) / 700))
ox = bounds.x + (bounds.width - 700 * scale) / 2
oy = bounds.y + 30
margin = 45 * scale
text = self.prepared
line = 34
if bounds.width < 540 {
text = self.small
line = 26
}
initialHeight = line * 7
initialWidth = self.initial.width / self.initial.height * initialHeight
art = self.dragon.artwork(ox, oy, scale)
exclusions = ObjectList(g.rectExclusion(ox + margin, oy + margin, initialWidth + 12, initialHeight))
i = 0
while i < art.count {
exclusions.append(g.imageExclusion(art.at(i), 6 * scale))
i = i + 1
}
letters = 0
if self.dragon.flames.count > 0 { letters = 1 }
drawing = g.flowText(text, ox + margin, oy + margin, 700 * scale - margin * 2, math.max(0, bounds.height - 90 - margin), exclusions, letters)
if self.dragon.flames.count > 0 { self.dragon.heat(drawing.commands, ox, oy, scale) }
commands = RectBlock.paint(self, mount, bounds).commands
commands.append(g.sprite(self.initial, ox + margin, oy + margin, initialWidth, initialHeight, 0, 0, 0, 1))
commands.extend(drawing.commands)
commands.extend(art)
footer = g.flowText(hint, bounds.x + 20, bounds.y + bounds.height - 28, math.max(0, bounds.width - 40), 24, ObjectList(), 0)
commands.extend(footer.commands)
return DisplayList(commands)
}
}
segments = ObjectList()
headImage = g.loadImage(assets + "dragon-sprites/head.png")
segments.append(Segment(math.float(150), math.float(110), math.float(0), headImage))
function makeBody(): Unit {
i = 1
x = math.float(150)
y = math.float(110)
while i < 20 {
angle = -i / 19 * math.pi * 0.7
x = x - math.cos(angle) * 30
y = y - math.sin(angle) * 30
image = g.loadImage(assets + "dragon-sprites/body-" + g.string(i) + ".png")
segments.append(Segment(x, y, angle, image))
i = i + 1
}
}
makeBody()
fireImages = ObjectList()
// Layer 2..11: baseline directions differ; these values belong to this artwork.
fireDirections = ObjectList(Direction(-0.50), Direction(-0.30), Direction(-0.55), Direction(0.40), Direction(-1.35), Direction(-1.57), Direction(-1.57), Direction(0.05), Direction(-1.40), Direction(1.12))
function loadFire(): Unit {
i = 2
while i <= 11 {
image = g.loadImage(assets + "fire-sprites/Layer " + g.string(i) + ".png")
fireImages.append(FireArt(image, fireDirections.at(i - 2).angle))
i = i + 1
}
}
loadFire()
dragon = Dragon(segments, ObjectList(), fireImages, g.loadImage(assets + "dragon-sprites/tongue.png"), g.loadImage(assets + "dragon-sprites/wing-front.png"), g.loadImage(assets + "dragon-sprites/wing-back.png"), math.float(150), math.float(110), 0, math.now(), -10, math.float(42))
page = Page()
page.title = "Illustrated Manuscript · Endor"
view = Manuscript(700, 800, 0, "#F4EEE0", dragon, page, bodyText, smallText, g.loadImage(assets + "dropcap.png"), ObjectList())
view.flex = 1
view.boxWidth = "fill"
page.crossAlign = "stretch"
moveConnection = view.on("pointermove", dragon.pointer)
downConnection = view.on("pointerdown", dragon.down)
upConnection = view.on("pointerup", dragon.up)
leaveConnection = view.on("pointerleave", dragon.leave)
cancelConnection = view.on("pointercancel", dragon.leave)
page.render(view)
+12
View File
@@ -1,32 +1,44 @@
// 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。
// ViewportBand 还显式读取实际页面视口,使原生 resize 可通过依赖触发重测。
// 只负责测量需要的宽度,和绘制颜色分成两个业务对象以便观察独立失效。
class WidthModel { class WidthModel {
width: Number width: Number
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
function expand(self: WidthModel, event: Event): Unit { function expand(self: WidthModel, event: Event): Unit {
self.width = 260 self.width = 260
} }
} }
// 保存 paint 使用的颜色,不参与 CachedRect 的测量。
class Tint { class Tint {
color: String color: String
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
function blue(self: Tint, event: Event): Unit { function blue(self: Tint, event: Event): Unit {
self.color = "#526AC7" self.color = "#526AC7"
} }
} }
// 原生矩形的尺寸/颜色策略分别由源协议覆盖,业务模型通过字段持有。
class CachedRect: RectBlock { class CachedRect: RectBlock {
dimensions: WidthModel dimensions: WidthModel
tint: Tint tint: Tint
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(self.dimensions.width, 60) return MeasureResult(self.dimensions.width, 60)
} }
// 复用原生矩形命令,再改新命令的颜色;修改新结果被允许,但 paint 不能修改已有业务对象。
function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry) drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint.color drawing.commands.at(0).color = self.tint.color
return drawing return drawing
} }
} }
// 宽度来自 ui.viewport 的当前逻辑页面区域,显示窗口变化也能成为测量依赖。
class ViewportBand: RectBlock { class ViewportBand: RectBlock {
// 返回可用视口宽度,最终仍服从父容器约束;外层 padding 会减少分配宽度。
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(ui.viewport().width, 24) return MeasureResult(ui.viewport().width, 24)
} }
} }
// 创建相互独立的宽度/颜色模型并绑定两个按钮,用相同对象观察缓存与可见结果。
dimensions = WidthModel(200) dimensions = WidthModel(200)
tint = Tint("#ED6A5A") tint = Tint("#ED6A5A")
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint) rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
+12
View File
@@ -1,24 +1,34 @@
// 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。
// paragraph 被两个容器共享;每个 Mount 使用独立约束,不能把新宽度写回共享文字状态。
// 保存第一个段落出现位置的分配宽度,按钮只改策略值。
class Policy { class Policy {
width: Number width: Number
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
function narrow(self: Policy, event: Event): Unit { self.width = 280 } function narrow(self: Policy, event: Event): Unit { self.width = 280 }
// 选择 560 逻辑像素宽度,段落变矮;另一个容器宽度不跟着改变。
function wide(self: Policy, event: Event): Unit { self.width = 560 } function wide(self: Policy, event: Event): Unit { self.width = 560 }
} }
// 在原生 Column 排列基础上覆盖第一个稳定子槽的宽度。
class ArrangedColumn: Column { class ArrangedColumn: Column {
policy: Policy policy: Policy
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement { function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
result = Column.arrange(self, mount, geometry) result = Column.arrange(self, mount, geometry)
result.children.at(0).width = self.policy.width result.children.at(0).width = self.policy.width
return result return result
} }
} }
// 通过源 measure 改整个容器的期望宽度,以对比 arrange 分配路径。
class MeasuredColumn: Column { class MeasuredColumn: Column {
width: Number width: Number
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints) result = Column.measure(self, mount, constraints)
result.width = self.width result.width = self.width
return result return result
} }
} }
// 两个容器使用同一个 paragraph,分别由 Policy=280 与 measure.width=360 约束。
policy = Policy(280) policy = Policy(280)
paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。") paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。")
tail = TextBlock("这一行跟随上方段落。") tail = TextBlock("这一行跟随上方段落。")
@@ -26,10 +36,12 @@ arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360) measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360)
narrow = Button("arrange 为 280") narrow = Button("arrange 为 280")
wide = Button("arrange 为 560") wide = Button("arrange 为 560")
// 保留事件订阅引用,两个按钮调用同一 Policy 的不同方法。
narrowConnection = narrow.on("click", policy.narrow) narrowConnection = narrow.on("click", policy.narrow)
wideConnection = wide.on("click", policy.wide) wideConnection = wide.on("click", policy.wide)
page = Page() page = Page()
page.title = "约束协商" page.title = "约束协商"
page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured)) page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured))
// 成功输入结束后宿主才消费原生窗口命令;窗口实际尺寸再回传为下一次布局约束。
ui.window().resize(850, 700) ui.window().resize(850, 700)
ui.window().setTitle("测量与排列直到稳定") ui.window().setTitle("测量与排列直到稳定")
+4
View File
@@ -1,5 +1,9 @@
// 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。
// 返回 Column 视觉对象,宿主选择/挂载页面;这里的 title 和 rectangle 只在 VM 内建立业务引用。
function main(): View { function main(): View {
// 文字内容与矩形参数和 page 示例一致,最终绘图命令应等价。
let heading = ui.text("Hello Endor", 2, "#242422") let heading = ui.text("Hello Endor", 2, "#242422")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
// 布局边距与间隔保持一致;不同 VM 的对象/Mount 身份独立,命令相同不表示共享对象。
return ui.column(24, 12, heading, rectangle) return ui.column(24, 12, heading, rectangle)
} }
+5
View File
@@ -1,9 +1,14 @@
// 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。
// 两个文字绑定读同一 counter;原生点击和 Console 都能操作该对象并传播显示变化。
// state 提供可写 value 和 increment 方法,textOf 建立显式字段到文本的依赖。
counter = ui.state(9) counter = ui.state(9)
first = ui.textOf(counter, "value", 2) first = ui.textOf(counter, "value", 2)
second = ui.textOf(counter, "value", 2) second = ui.textOf(counter, "value", 2)
title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2) title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2)
button = Button("增加计数") button = Button("增加计数")
// 零参数源回调委托原生 state.increment;事件分发器根据签名调用,不强塞 Event 参数。
function increment(): Unit { counter.increment() } function increment(): Unit { counter.increment() }
// 把实际函数作为回调而非函数名文本;render 之前只创建业务对象,render 后才有出现位置。
button.onClick(increment) button.onClick(increment)
page = Page() page = Page()
page.render(Column(24, 16, title, Row(0, 16, first, second, button))) page.render(Column(24, 16, title, Row(0, 16, first, second, button)))
+10
View File
@@ -1,23 +1,33 @@
// 方法覆盖遵守参数可更宽、返回可更具体的规则。
// WideRect 复用基类原生测量后返回 Sized 子类结果;事件改变基础 width 后按同一路径重测。
// MeasureResult 的名义子类,不增加字段,用于演示返回值协变。
class Sized: MeasureResult {} class Sized: MeasureResult {}
// 用源方法显式委托原生测量,保留 Mount/Constraints 的基类契约。
class BaseRect: RectBlock { class BaseRect: RectBlock {
// 原生方法按给定出现位置与约束测量,不把几何存进共享对象。
function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult {
return RectBlock.measure(self, mount, constraints) return RectBlock.measure(self, mount, constraints)
} }
} }
// 覆盖参数放宽为 Object,返回缩窄为 Sized;不能反过来缩窄参数或放宽返回。
class WideRect: BaseRect { class WideRect: BaseRect {
// 调用基类后复制测量数值到新的 Sized;新结果仍满足 MeasureResult 协议。
function measure(self: WideRect, mount: Object, constraints: Object): Sized { function measure(self: WideRect, mount: Object, constraints: Object): Sized {
measured = BaseRect.measure(self, mount, constraints) measured = BaseRect.measure(self, mount, constraints)
return Sized(measured.width, measured.height) return Sized(measured.width, measured.height)
} }
} }
// 保存具体 WideRect 和计数器,使宽度修改和点击次数同时作为业务变化传播。
class Actions { class Actions {
rect: WideRect rect: WideRect
count: Object count: Object
// 先改 width 再递增计数,新的布局刷新会使用上述动态测量覆盖。
function widen(self: Actions): Unit { function widen(self: Actions): Unit {
self.rect.width = 260 self.rect.width = 260
self.count.increment() self.count.increment()
} }
} }
// 初始矩形宽 180;按钮事件完成后应变为 260,同时文字显示计数。
rect = WideRect(180, 60, 8, "#ED6A5A") rect = WideRect(180, 60, 8, "#ED6A5A")
count = ui.state(0) count = ui.state(0)
actions = Actions(rect, count) actions = Actions(rect, count)
+10
View File
@@ -1,3 +1,6 @@
// 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。
// 普通数据变化不会重新挂载;页面关闭时子项先卸载,父项随后卸载,旧 Mount 不因重开而复活。
// order 是统一时序,mounted/unmounted 是子项次数;latest 保存最近通知的实际 Mount。
class LifecycleLog { class LifecycleLog {
order: Object order: Object
mounted: Object mounted: Object
@@ -8,8 +11,10 @@ class LifecycleLog {
childUnmounted: Number childUnmounted: Number
latest: Object latest: Object
} }
// 共享文字用 log 记录两个出现位置,源钩子接收的是当前具体 Mount。
class WatchedText: TextBlock { class WatchedText: TextBlock {
log: LifecycleLog log: LifecycleLog
// 记录顺序和次数,保存 Mount 并修改原文字;运行时在回调后重新收敛才发布。
function onMount(self: WatchedText, mount: Mount): Unit { function onMount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.mounted.increment() self.log.mounted.increment()
@@ -17,6 +22,7 @@ class WatchedText: TextBlock {
self.log.latest.set(mount) self.log.latest.set(mount)
self.setText("同一个对象,两个独立挂载位置") self.setText("同一个对象,两个独立挂载位置")
} }
// 记录已经脱离的出现位置;保留 Mount 可查询 mounted,但不能再当有效 bounds 使用。
function onUnmount(self: WatchedText, mount: Mount): Unit { function onUnmount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.unmounted.increment() self.log.unmounted.increment()
@@ -24,17 +30,21 @@ class WatchedText: TextBlock {
self.log.latest.set(mount) self.log.latest.set(mount)
} }
} }
// 父容器也记录钩子序号,便于对比挂载与子先父后的卸载顺序。
class WatchedColumn: Column { class WatchedColumn: Column {
log: LifecycleLog log: LifecycleLog
// 记录父挂载顺序,不修改容器结构;子项随后各自执行通知。
function onMount(self: WatchedColumn, mount: Mount): Unit { function onMount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.parentMounted = self.log.order.value self.log.parentMounted = self.log.order.value
} }
// 父卸载时子项卸载已完成,序列号应位于所有子项通知之后。
function onUnmount(self: WatchedColumn, mount: Mount): Unit { function onUnmount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment() self.log.order.increment()
self.log.parentUnmounted = self.log.order.value self.log.parentUnmounted = self.log.order.value
} }
} }
// 多个 state 容器分别保存计数/最后 Mount,root 最后参数是源 log 字段,前面才是可变视觉子项。
log = LifecycleLog(ui.state(0), ui.state(0), ui.state(0), 0, 0, 0, 0, ui.state(0)) log = LifecycleLog(ui.state(0), ui.state(0), ui.state(0), 0, 0, 0, 0, ui.state(0))
child = WatchedText("等待挂载", 2, log) child = WatchedText("等待挂载", 2, log)
root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log) root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log)
+7
View File
@@ -1,12 +1,19 @@
// 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。
// 同一事件注册机制按实际签名调用;label 绑定再把事件 kind 转成可显示的字符串。
// 保留最后一次收到的 Event 对象,初值 kind=waiting;不是只存一份格式化事件文本。
received = ui.state(Event("waiting")) received = ui.state(Event("waiting"))
count = ui.state(0) count = ui.state(0)
// 只读计算提取当前事件种类,作为 label.text 绑定的依赖。
function eventKind(): String { return received.value.kind } function eventKind(): String { return received.value.kind }
label = TextBlock("waiting") label = TextBlock("waiting")
subscription = label.bind("text", eventKind) subscription = label.bind("text", eventKind)
withEvent = Button("传递 Event 给原生 set") withEvent = Button("传递 Event 给原生 set")
// received.set 是一参数原生方法,分发器传入实际 click Event。
firstConnection = withEvent.on("click", received.set) firstConnection = withEvent.on("click", received.set)
withoutEvent = Button("调用零参数原生 increment") withoutEvent = Button("调用零参数原生 increment")
// count.increment 的签名为零参数,分发器调用时不传多余 Event。
secondConnection = withoutEvent.on("click", count.increment) secondConnection = withoutEvent.on("click", count.increment)
// 把已绑定状态与两种回调放到同一页面,便于对比输入效果。
page = Page() page = Page()
page.title = "原生方法签名" page.title = "原生方法签名"
page.render(Column(24, 16, TextBlock("源码和原生回调共用参数契约"), label, ui.textOf(count, "value"), withEvent, withoutEvent)) page.render(Column(24, 16, TextBlock("源码和原生回调共用参数契约"), label, ui.textOf(count, "value"), withEvent, withoutEvent))
+10
View File
@@ -1,33 +1,43 @@
// 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。
// label 在树中出现多次但仍是同一对象,原生方法和源字段写入共同更新各出现位置。
// 扩展 TextBlock 的业务字段;其文本/颜色仍由原生父类存储与绘制。
class StatusText: TextBlock { class StatusText: TextBlock {
activeColor: String activeColor: String
// 用继承的 setText/setColor 改原对象;activeColor 是构造时追加的源字段。
function activate(self: StatusText, event: Event): Unit { function activate(self: StatusText, event: Event): Unit {
self.setText("继承的原生文本已更新") self.setText("继承的原生文本已更新")
self.setColor(self.activeColor) self.setColor(self.activeColor)
} }
} }
// 继续继承源子类,添加 message 并覆盖事件入口,保留 TextBlock 的原生能力。
class CustomStatus: StatusText { class CustomStatus: StatusText {
message: String message: String
// 显式调用基类 setText 以示范原生未绑定方法的接收者传递,再用继承方法改颜色。
function activate(self: CustomStatus, event: Event): Unit { function activate(self: CustomStatus, event: Event): Unit {
TextBlock.setText(self, self.message) TextBlock.setText(self, self.message)
self.setColor(self.activeColor) self.setColor(self.activeColor)
} }
} }
// 按钮保存目标 Block,事件到来时把同一 Event 转交给目标的动态 activate。
class ActionButton: Button { class ActionButton: Button {
target: Block target: Block
// 不复制目标或创建新展示对象,直接调用目标业务方法,选择实际类型的覆盖实现。
function activate(self: ActionButton, event: Event): Unit { function activate(self: ActionButton, event: Event): Unit {
self.target.activate(event) self.target.activate(event)
} }
} }
// Page 也可增加源字段;caption 用于构建页内标题,不替代原生窗口标题。
class CustomPage: Page { class CustomPage: Page {
caption: String caption: String
} }
// 前面位置参数供原生 TextBlock 构造,后面的颜色/消息供继承字段;所有引用都保留托管类型。
label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作") label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作")
button = ActionButton("调用源码子类方法", label) button = ActionButton("调用源码子类方法", label)
button.onClick(button.activate) button.onClick(button.activate)
+8
View File
@@ -1,15 +1,22 @@
// owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。
// 每个 owner 下同 key 的重复点击替换旧定时器;任务生命周期不由画面是否可见推断。
// 把 owner 与要修改的 state 分开持有,使同一个调度方法可用于三个作用域。
class Actions { class Actions {
owner: Object owner: Object
state: Object state: Object
// 定时任务成功交付后修改共享计数,零参数签名符合 timer 回调要求。
function complete(self: Actions): Unit { self.state.increment() } function complete(self: Actions): Unit { self.state.increment() }
// 每次启动 1500ms 的 load 定时器;相同 owner/key 只有最新任务继续等待。
function schedule(self: Actions, event: Event): Unit { function schedule(self: Actions, event: Event): Unit {
ui.after(self.owner, "load", 1500, self.complete) ui.after(self.owner, "load", 1500, self.complete)
} }
} }
state = ui.state(0) state = ui.state(0)
// 该 Block 从未进入页面树,但全局引用仍持有它,因此它拥有的任务可执行。
hidden = TextBlock("这个 Block 没有挂载,也能拥有任务") hidden = TextBlock("这个 Block 没有挂载,也能拥有任务")
page = Page() page = Page()
page.title = "任务生命周期" page.title = "任务生命周期"
// 三个 Action 共享一个 state,却分别归属 Page、Block、应用,便于比较关闭页面的取消边界。
pageActions = Actions(page, state) pageActions = Actions(page, state)
blockActions = Actions(hidden, state) blockActions = Actions(hidden, state)
appActions = Actions(ui, state) appActions = Actions(ui, state)
@@ -20,5 +27,6 @@ pageConnection = pageButton.on("click", pageActions.schedule)
blockConnection = blockButton.on("click", blockActions.schedule) blockConnection = blockButton.on("click", blockActions.schedule)
appConnection = appButton.on("click", appActions.schedule) appConnection = appButton.on("click", appActions.schedule)
page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton)) page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton))
// 额外应用级任务延迟 500ms,用于展示没有点击也可按 worker 空闲轮询交付。
initial = ui.after(ui, "initial", 500, appActions.complete) initial = ui.after(ui, "initial", 500, appActions.complete)
ui.window().setTitle("Owned Tasks") ui.window().setTitle("Owned Tasks")
+4 -4
View File
@@ -1,4 +1,4 @@
let heading = ui.text("Hello Endor", 2, "#242422") let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 创建固定逻辑尺寸矩形;12 为圆角半径,业务对象尚未出现在任何页面中。
let myui = ui.page() let myui = ui.page() // 创建托管 Page 资源;它负责持有展示根和页面生命周期。
myui.render(ui.column(24, 12, heading, rectangle)) myui.render(ui.column(24, 12, heading, rectangle)) // 把文字和矩形放入 Column,padding=24、gap=12;render 选择此页并生成出现位置及发布帧。
+11
View File
@@ -1,27 +1,38 @@
// 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。
// 生成的视图保存 source 指向业务模型,绑定和按钮继续使用原对象,展示不是业务数据副本。
// name/color 是业务字段,零参读取方法提供只读绑定,update 提供事件修改。
class Product { class Product {
name: String name: String
color: String color: String
// 返回名称并记录实际属性读取,后续 name 修改会使文本绑定重算。
function label(self: Product): String { return self.name } function label(self: Product): String { return self.name }
// 只读返回颜色,绑定到新建色块的 color。
function shade(self: Product): String { return self.color } function shade(self: Product): String { return self.color }
// 点击更新原业务对象,现有绑定会同步文字和矩形,不重新调用整个 present 构造树。
function update(self: Product, event: Event): Unit { function update(self: Product, event: Event): Unit {
self.name = "原业务对象已更新" self.name = "原业务对象已更新"
self.color = "#237A45" self.color = "#237A45"
} }
// 在指定 Window 上下文创建文字、色块和按钮,连接原 self 的方法后返回组合根。
function present(self: Product, window: Window): Block { function present(self: Product, window: Window): Block {
title = TextBlock(self.name) title = TextBlock(self.name)
swatch = RectBlock(180,48,8,self.color) swatch = RectBlock(180,48,8,self.color)
// 绑定令牌本身不承载另一份业务状态,绑定计算仍调用同一 Product。
titleBinding = title.bind("text",self.label) titleBinding = title.bind("text",self.label)
colorBinding = swatch.bind("color",self.shade) colorBinding = swatch.bind("color",self.shade)
button = Button("调用业务对象方法") button = Button("调用业务对象方法")
connection = button.on("click",self.update) connection = button.on("click",self.update)
result = Column(24,16,title,swatch,button) result = Column(24,16,title,swatch,button)
// 保留原始业务身份,便于代码从展示根回到来源;contextWindow 仅记录创建时的窗口编号。
result.source = self result.source = self
result.contextWindow = window.id() result.contextWindow = window.id()
return result return result
} }
} }
// 显式声明能力并验证方法契约;只有同名 present 不会自动成为 Presentable。
ui.implement(Product,Presentable) ui.implement(Product,Presentable)
product = Product("普通对象的显式展示能力", "#526AC7") product = Product("普通对象的显式展示能力", "#526AC7")
// 先获得视觉根,再由 ui.open 把它挂载到页面;最后设置 Page 标题。
view = ui.present(product) view = ui.present(product)
page = ui.open(view) page = ui.open(view)
page.title = "Presentable" page.title = "Presentable"
+9
View File
@@ -1,10 +1,17 @@
// 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。
// 绑定在目标隐藏时也会更新;两个页面共享同一组 Block,不需要两份模型。
// width/color/label 是唯一业务来源,绑定计算只读,expand 是显式事件写入入口。
class Model { class Model {
width: Number width: Number
color: String color: String
label: String label: String
// 返回 Number,目标 width 属性同时做类型/尺寸验证。
function getWidth(self: Model): Number { return self.width } function getWidth(self: Model): Number { return self.width }
// 返回颜色文本;依赖只包含实际读取的模型属性。
function getColor(self: Model): String { return self.color } function getColor(self: Model): String { return self.color }
// 返回当前说明文字,供 TextBlock.text 绑定使用。
function getLabel(self: Model): String { return self.label } function getLabel(self: Model): String { return self.label }
// 一个事件同时改三个属性,随后绑定传播,布局与绘制在完整刷新后发布。
function expand(self: Model, event: Event): Unit { function expand(self: Model, event: Event): Unit {
self.width = 260 self.width = 260
self.color = "#237A45" self.color = "#237A45"
@@ -14,6 +21,7 @@ class Model {
model = Model(120, "#526AC7", "点击更新共享对象") model = Model(120, "#526AC7", "点击更新共享对象")
rect = RectBlock(120, 40, 8, "#526AC7") rect = RectBlock(120, 40, 8, "#526AC7")
label = TextBlock("初始化") label = TextBlock("初始化")
// 保存三个可断开的绑定令牌;令牌不强持有隐藏目标,目标仍需由业务对象图保留。
widthBinding = rect.bind("width", model.getWidth) widthBinding = rect.bind("width", model.getWidth)
colorBinding = rect.bind("color", model.getColor) colorBinding = rect.bind("color", model.getColor)
labelBinding = label.bind("text", model.getLabel) labelBinding = label.bind("text", model.getLabel)
@@ -23,6 +31,7 @@ root = Column(24, 16, label, rect, button)
page = Page() page = Page()
page.title = "通用属性绑定" page.title = "通用属性绑定"
page.render(root) page.render(root)
// 第二页面使用同一根容器,切页只更换 Mount 展示上下文;最后 activate 回第一页面。
second = Page() second = Page()
second.title = "同一组 Block" second.title = "同一组 Block"
second.render(root) second.render(root)
+35
View File
@@ -0,0 +1,35 @@
// 一个二维世界可被多个有限视口观察,Scene2D 的对象位置共享,默认 Camera2D 各自独立。
// 内层视口也可作为世界子项;HUD 在外层 Overlay 中,所以不会随世界相机变换。
// 场景保存世界对象,视口负责有限窗口与相机变换。
count = ui.state(0)
increment = Button("共享计数 +1")
increment.onClick(count.increment)
// 业务卡片带世界 x/y 和边框,count 文字与按钮共同保留同一 state。
card = Column(20, 12, TextBlock("世界里的 UI", 2), ui.textOf(count, "value", 3), increment)
card.boxWidth = 220
card.x = -60
card.y = 40
card.borderLeft = 2
card.borderColor = "#8CA5B8"
// 有限内层视口看自己的小场景,再以世界坐标放进外层 Scene2D。
inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9")))
inner.x = 260
inner.y = 40
// 两个外层视口引用同一 world,卡片/内层视口因此各产生两个出现位置。
world = Scene2D(card, inner)
// 左右视口尺寸和相机平移/缩放不同;显式赋同一 camera 才会共享导航状态。
left = CanvasViewport(460, 320, world)
left.camera.x = -100
right = CanvasViewport(320, 320, world)
right.camera.x = -80
right.camera.zoom = 0.65
// 如需联动,在 Console 将 right.camera 赋为 left.camera;共享场景本身不会自动共享相机。
// 先横排两个视图,再把固定 HUD 叠在它们之上,输入命中也使用组合后的几何。
views = Row(0, 20, left, right)
hud = Column(16, 8, TextBlock("一个世界,多个视角", 2), TextBlock("滚轮平移 · 空格/中键拖动 · 捏合或 Cmd/Ctrl+滚轮缩放"))
hud.alignSelf = "end"
page = Page()
page.title = "场景与视口"
page.render(Overlay(12, 0, views, hud))
+57
View File
@@ -0,0 +1,57 @@
// 同一 Scene3D 在透视/正交视口中展示;相机控制独立,网格业务变换由两边共享。
// 三维内容与普通 Endor 二维控件一起排版,viewport 有限尺寸不限制场景世界坐标。
// 保存左侧视口和共享立方体;动作可改相机位置或真实网格变换。
class SceneActions {
view: Viewport3D
cube: Mesh3D
// 把左相机放到 z=6 正面,保持相机既有 target。
function front(self: SceneActions): Unit {
self.view.camera.x = 0
self.view.camera.y = 0
self.view.camera.z = 6
}
// 把同一相机移到侧上方,右侧正交相机保持原设置。
function side(self: SceneActions): Unit {
self.view.camera.x = 6
self.view.camera.y = 2
self.view.camera.z = 0
}
// 修改共享 cube 的欧拉旋转,因此两个视口都看到物体变化。
function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) }
// 示范 Event.hit.mesh 的真实托管网格材质写入;此文件尚未把 select 注册到视口。
function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" }
}
// 先构造并变换两个 Box3D,再由 Group3D 组成场景节点;几何与相机是不同对象。
cube = Box3D(1.6, 1.6, 1.6, "#6687AB")
cube.rotationY = 25
cube.rotationX = 15
small = Box3D(0.7, 0.7, 0.7, "#DBA36E")
small.x = 1.8
small.z = -0.8
group = Group3D(cube, small)
// 薄盒作地面,方向光与 ambient 一起影响材质颜色,不是二维背景矩形。
floor = Box3D(6, 0.08, 4, "#D8DEE6")
floor.y = -1.1
light = DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9)
scene = Scene3D(group, floor, light)
scene.ambient = 0.35
// 左视口使用默认透视相机,右视口显式换成 OrthographicCamera 并设置可见高度。
perspective = Viewport3D(480, 360, scene)
perspective.camera.z = 6
orthographic = Viewport3D(320, 360, scene)
orthographic.camera = OrthographicCamera()
orthographic.camera.height = 5
orthographic.camera.x = 4
orthographic.camera.y = 2
orthographic.camera.z = 5
// 按钮绑定零参数方法;页面文字说明原生默认控制器的交互,代码可继续替换 controller。
actions = SceneActions(perspective, cube)
front = Button("正面")
front.onClick(actions.front)
side = Button("侧面")
side.onClick(actions.side)
rotate = Button("物体旋转 +15°")
rotate.onClick(actions.rotate)
page = Page()
page.title = "场景、视口和摄像机"
page.render(Column(24, 16, TextBlock("同一个 3D 场景,两个摄像机", 2), Row(0, 16, perspective, orthographic), Row(0, 12, front, side, rotate), TextBlock("左键拖动旋转 · 中键或空格拖动平移 · 滚轮缩放\n摄像机和控制器都是可编程对象;2D 控件由 Endor 自己排版。")))
+10
View File
@@ -1,21 +1,31 @@
// 滚动轴策略影响可滚动方向与内容测量约束:vertical 限宽并允许长高,horizontal 允许横向展开。
// 同一 paragraph 出现两次,文字内容共享,换行/偏移/范围在各 Mount 中独立。
// 通过具体出现位置的 Mount 操作偏移;ScrollBlock.axis 则是对象级策略,可影响其所有出现位置。
class Actions { class Actions {
vertical: ScrollBlock vertical: ScrollBlock
horizontal: ScrollBlock horizontal: ScrollBlock
// 只对 vertical 第一个 Mount 累加纵偏移,运行时按当前范围夹取。
function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) } function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) }
// 只对 horizontal 第一个 Mount 累加横偏移,禁止轴仍保持零。
function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) } function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) }
// 分别把两个出现位置移回原点,不修改共享段落文字。
function reset(self: Actions): Unit { function reset(self: Actions): Unit {
self.vertical.mounts().at(0).scrollTo(0, 0) self.vertical.mounts().at(0).scrollTo(0, 0)
self.horizontal.mounts().at(0).scrollTo(0, 0) self.horizontal.mounts().at(0).scrollTo(0, 0)
} }
// 把左对象策略改为双轴,下一次求解重新计算约束和范围。
function both(self: Actions): Unit { self.vertical.axis = "both" } function both(self: Actions): Unit { self.vertical.axis = "both" }
} }
paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。") paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。")
// 纵向区域padding后内容宽为244,长文字按此有限宽换行。
vertical = ScrollBlock(260, 140, paragraph) vertical = ScrollBlock(260, 140, paragraph)
vertical.padding = 8 vertical.padding = 8
vertical.axis = "vertical" vertical.axis = "vertical"
// 横向区域沿滚动轴允许内容自然展开,所以同一文字可以比左侧更宽、更矮。
horizontal = ScrollBlock(260, 140, paragraph) horizontal = ScrollBlock(260, 140, paragraph)
horizontal.padding = 8 horizontal.padding = 8
horizontal.axis = "horizontal" horizontal.axis = "horizontal"
// 绑定四个零参数按钮回调;最终把两视图与控制区放在普通 Column/Row 中。
actions = Actions(vertical, horizontal) actions = Actions(vertical, horizontal)
down = Button("向下滚动") down = Button("向下滚动")
down.onClick(actions.down) down.onClick(actions.down)
+8
View File
@@ -1,10 +1,16 @@
// 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。
// 滚动只改变投影/裁剪,不复制业务计数;被裁剪内容不能通过原始矩形接受点击。
// 保留一个源 paint 委托,检验源命令与原生命令应用相同滚动裁剪。
class Painted: RectBlock { class Painted: RectBlock {
// 使用当前 geometry 交给原生基类;外层滚动变换/clip 由展示流程统一组合。
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList { function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
return RectBlock.paint(self,mount,geometry) return RectBlock.paint(self,mount,geometry)
} }
} }
// 三个按钮复用同一个 Action,任意可见按钮点击都递增同一 count。
class Actions { class Actions {
count: Object count: Object
// 签名接受 Event,业务逻辑只需修改计数,不直接处理滚动位置。
function click(self: Actions, event: Event): Unit { self.count.increment() } function click(self: Actions, event: Event): Unit { self.count.increment() }
} }
count = ui.state(0) count = ui.state(0)
@@ -15,12 +21,14 @@ last = Button("底部按钮")
firstConnection = first.on("click",actions.click) firstConnection = first.on("click",actions.click)
middleConnection = middle.on("click",actions.click) middleConnection = middle.on("click",actions.click)
lastConnection = last.on("click",actions.click) lastConnection = last.on("click",actions.click)
// 高内容夹有三个按钮与矩形,使固定320x180 scroller产生可观察的横纵滚动范围。
body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last) body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last)
scroller = ScrollBlock(320,180,body) scroller = ScrollBlock(320,180,body)
root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller) root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller)
page = Page() page = Page()
page.title = "滚动示例" page.title = "滚动示例"
page.render(root) page.render(root)
// 另一窗口复用 root/scroller;它们的第二个 Mount 偏移与主窗口第一个 Mount 无关。
other = Window() other = Window()
other.setTitle("Scroll Secondary") other.setTitle("Scroll Secondary")
second = Page() second = Page()
+8
View File
@@ -1,23 +1,31 @@
// 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。
// 圆外部分可落到父容器监听器;按下与释放须经相同合法目标复核。
// 扩展矩形的绘制、命中和事件方法,用同一个 hits 状态观察交互结果。
class CircleTarget: RectBlock { class CircleTarget: RectBlock {
hits: Object hits: Object
// point 已转换到该出现位置局部坐标;使用圆心(40,40)、半径40,而不是整个 160x80 外框。
function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool { function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool {
return point.inCircle(40, 40, 40) return point.inCircle(40, 40, 40)
} }
// 把原生命令宽度缩为 80、圆角半径设为 40,得到与 hitTest 对应的圆形区域。
function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList { function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry) drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).width = 80 drawing.commands.at(0).width = 80
drawing.commands.at(0).radius = 40 drawing.commands.at(0).radius = 40
return drawing return drawing
} }
// 圆内点击递增自身计数并停止向祖先传播;父容器不会为同一次点击再递增。
function onEvent(self: CircleTarget, event: Event): Unit { function onEvent(self: CircleTarget, event: Event): Unit {
self.hits.increment() self.hits.increment()
event.stopPropagation() event.stopPropagation()
} }
} }
hits = ui.state(0) hits = ui.state(0)
// 父监听器单独计数;它和圆形目标显示在同页,便于比较穿过透明区的输入。
background = ui.state(0) background = ui.state(0)
circle = CircleTarget(160, 80, 0, "#237A45", hits) circle = CircleTarget(160, 80, 0, "#237A45", hits)
root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value")) root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value"))
// 订阅在父 Column 上,圆外有效区域可回退到父目标;源命中只读检查不执行这些业务回调。
connection = root.on("click", background.increment) connection = root.on("click", background.increment)
page = Page() page = Page()
page.render(root) page.render(root)
+10
View File
@@ -1,25 +1,35 @@
// 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
// 保存宽度与左偏移;两字段只在事件回调中修改。
class Metrics { class Metrics {
width: Number width: Number
left: Number left: Number
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
function expand(self: Metrics, event: Event): Unit { function expand(self: Metrics, event: Event): Unit {
self.width = 260 self.width = 260
self.left = 68 self.left = 68
} }
} }
// 复用原生矩形载荷,仅覆盖尺寸策略;同一 rect 放在不同父容器可得到不同测量。
class MeasuredRect: RectBlock { class MeasuredRect: RectBlock {
// 首次测量前结构 Mount 链接已建立,因此可从 parent.block 读取当前出现位置的父策略。
// 此阶段还不能查询最终 bounds,返回值使用 MeasureResult 名义类型。
function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult {
parent = mount.parent() parent = mount.parent()
return MeasureResult(parent.block.metrics.width, 60) return MeasureResult(parent.block.metrics.width, 60)
} }
} }
// 在原生排列结果上改第一个子槽 x,其他子项仍用 Column 的堆叠规则。
class OffsetColumn: Column { class OffsetColumn: Column {
metrics: Metrics metrics: Metrics
// Arrangement 里的 x 是当前排列使用的坐标;读取 metrics.left 成为排列依赖。
function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement { function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement {
positions = Column.arrange(self, mount, geometry) positions = Column.arrange(self, mount, geometry)
positions.children.at(0).x = self.metrics.left positions.children.at(0).x = self.metrics.left
return positions return positions
} }
} }
// 将原生参数与源字段传给具体子类,button 事件更新模型,Page.render 建立完整展示关系。
metrics = Metrics(200, 44) metrics = Metrics(200, 44)
rect = MeasuredRect(80, 20, 0, "#526AC7") rect = MeasuredRect(80, 20, 0, "#526AC7")
button = Button("更新布局") button = Button("更新布局")
+11
View File
@@ -1,15 +1,22 @@
// 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。
// 同一 rect 出现两次,两份命令随同一 tint 更新;业务身份共享,几何按 Mount 分开。
// 原生矩形测量不变,仅将绘制颜色切换为源字段 tint。
class TintRect: RectBlock { class TintRect: RectBlock {
tint: String tint: String
// 从原生基类取得当前出现位置的矩形命令,再修改本次返回列表中的颜色并交回。
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList { function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry) drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint drawing.commands.at(0).color = self.tint
return drawing return drawing
} }
} }
// 在原生文字前增加圆角底色,背景大小使用实际分配 geometry 而不是固定猜测值。
class Badge: TextBlock { class Badge: TextBlock {
background: String background: String
// 先完整填写 roundedRect 命令,再取原生文字命令改成白色;列表顺序决定先画背景后画文字。
function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList { function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
// 命令 kind 决定宿主解码所需字段,坐标/宽高来自本次 Mount,radius/color 由业务策略提供。
fill.kind = "roundedRect" fill.kind = "roundedRect"
fill.x = geometry.x fill.x = geometry.x
fill.y = geometry.y fill.y = geometry.y
@@ -17,17 +24,21 @@ class Badge: TextBlock {
fill.height = geometry.height fill.height = geometry.height
fill.radius = 6 fill.radius = 6
fill.color = self.background fill.color = self.background
// 复用原生文字塑形和换行所需参数,避免手工文字命令漏掉边界。
text = TextBlock.paint(self, mount, geometry).commands.at(0) text = TextBlock.paint(self, mount, geometry).commands.at(0)
text.color = "#FFFFFF" text.color = "#FFFFFF"
return DisplayList(ObjectList(fill, text)) return DisplayList(ObjectList(fill, text))
} }
} }
// 保存目标 TintRect,onEvent 是托管事件目标方法覆盖,不额外创建另一份状态。
class RecolorButton: Button { class RecolorButton: Button {
target: TintRect target: TintRect
// 事件阶段可修改已有业务字段;依赖跟踪检测 tint 变化后重绘两个出现位置。
function onEvent(self: RecolorButton, event: Event): Unit { function onEvent(self: RecolorButton, event: Event): Unit {
self.target.tint = "#526AC7" self.target.tint = "#526AC7"
} }
} }
// 两次使用 rect 是同一对象,Badge 与按钮独立;根容器只组合它们,不复制业务数据。
rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A") rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A")
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45") badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
button = RecolorButton("通过对象方法改变绘制颜色", rect) button = RecolorButton("通过对象方法改变绘制颜色", rect)
+20 -2
View File
@@ -1,14 +1,19 @@
// Student grades demo. Fictional records; in-memory only. // 学生成绩中心为纯内存演示,记录均虚构,不连接学校数据库。
// Paste the entire file into Tessera Console, then press Enter. // 按钮操作 GradeCenter/Student 原对象;成绩行与摘要用 textOf 绑定,同一学生在全部/复核视图中复用业务身份。
// 整个文件作为一次完整源码输入提交;状态只存在本次 VM,会话重置后不保留。
// 为 Column 增加期望宽度字段,使手工表格各列对齐,而非让文字自然宽度决定列边界。
class GradeCell: Column { class GradeCell: Column {
width: Number width: Number
// 先保留 Column 的自然高度,再把新测量结果宽度设为字段值,最终仍由父约束协商。
function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult {
size = Column.measure(self, mount, constraints) size = Column.measure(self, mount, constraints)
size.width = self.width size.width = self.width
return size return size
} }
} }
// 背景绘制由源覆盖,子项仍按普通 Column 排列与绘制。
class GradePanel: Column { class GradePanel: Column {
// 按本次 geometry 生成白色圆角底板;不重绘或复制子项命令,后代由统一遍历处理。
function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList { function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand() fill = PaintCommand()
fill.kind = "roundedRect" fill.kind = "roundedRect"
@@ -21,6 +26,7 @@ class GradePanel: Column {
return DisplayList(ObjectList(fill)) return DisplayList(ObjectList(fill))
} }
} }
// 姓名/学号和各科成绩/状态是业务字段;数值修改受 Number 声明检查。
class Student { class Student {
name: String name: String
number: String number: String
@@ -29,9 +35,11 @@ class Student {
english: Number english: Number
status: String status: String
} }
// 用统一无 padding/gap 的单格容器包装任意 Block,末尾 width 是源字段构造参数。
function gradeCell(content: Block, width: Number): Block { function gradeCell(content: Block, width: Number): Block {
return GradeCell(0, 0, content, width) return GradeCell(0, 0, content, width)
} }
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与固定列宽保持表格结构。
function gradeRow(student: Student): Block { function gradeRow(student: Student): Block {
return Column(0, 12, return Column(0, 12,
Row(0, 0, Row(0, 0,
@@ -43,6 +51,7 @@ function gradeRow(student: Student): Block {
gradeCell(ui.textOf(student, "status", 1, "#526C83"), 160)), gradeCell(ui.textOf(student, "status", 1, "#526C83"), 160)),
RectBlock(730, 1, 0, "#EDF0F3")) RectBlock(730, 1, 0, "#EDF0F3"))
} }
// 保存全部/复核两棵现成子树与可替换 table,同时持有要复核的真实 Student。
class GradeCenter { class GradeCenter {
table: Column table: Column
all: Column all: Column
@@ -50,20 +59,24 @@ class GradeCenter {
student: Student student: Student
pending: Number pending: Number
message: String message: String
// 替换稳定槽0的内容为全部行,复用已有学生,不重新请求/重建数据。
function showAll(self: GradeCenter, event: Event): Unit { function showAll(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.all) self.table.replace(0, self.all)
self.message = "正在查看全部 6 位学生" self.message = "正在查看全部 6 位学生"
} }
// 切换到预先构造的复核行;这是特定示例子树,不是动态过滤算法。
function showAttention(self: GradeCenter, event: Event): Unit { function showAttention(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.attention) self.table.replace(0, self.attention)
self.message = "林沐的数学成绩有一条复核记录" self.message = "林沐的数学成绩有一条复核记录"
} }
// 写预设数学成绩68及状态,清待复核数;相关绑定在两个视图都读同一 studentLin。
function review(self: GradeCenter, event: Event): Unit { function review(self: GradeCenter, event: Event): Unit {
self.student.math = 68 self.student.math = 68
self.student.status = "已复核" self.student.status = "已复核"
self.pending = 0 self.pending = 0
self.message = "已录入复核结果:林沐 · 数学 58 → 68" self.message = "已录入复核结果:林沐 · 数学 58 → 68"
} }
// 恢复初始学生值/计数并显示全部列表,便于重复观察同一操作。
function reset(self: GradeCenter, event: Event): Unit { function reset(self: GradeCenter, event: Event): Unit {
self.student.math = 58 self.student.math = 58
self.student.status = "待复核" self.student.status = "待复核"
@@ -72,6 +85,7 @@ class GradeCenter {
self.message = "已恢复示例数据" self.message = "已恢复示例数据"
} }
} }
// 下面六条数据均本地构造;allGradeRows 与 attentionGradeRows 对 studentLin 建立不同视觉行但共享其业务值。
studentLin = Student("林沐", "2026001", 86, 58, 91, "待复核") studentLin = Student("林沐", "2026001", 86, 58, 91, "待复核")
studentChen = Student("陈知夏", "2026002", 94, 96, 93, "已确认") studentChen = Student("陈知夏", "2026002", 94, 96, 93, "已确认")
studentXu = Student("许星河", "2026003", 82, 88, 85, "已确认") studentXu = Student("许星河", "2026003", 82, 88, 85, "已确认")
@@ -81,15 +95,18 @@ studentLu = Student("陆景行", "2026006", 79, 84, 87, "已确认")
allGradeRows = Column(0, 14, gradeRow(studentLin), gradeRow(studentChen), gradeRow(studentXu), gradeRow(studentZhou), gradeRow(studentSu), gradeRow(studentLu)) allGradeRows = Column(0, 14, gradeRow(studentLin), gradeRow(studentChen), gradeRow(studentXu), gradeRow(studentZhou), gradeRow(studentSu), gradeRow(studentLu))
attentionGradeRows = Column(0, 14, gradeRow(studentLin)) attentionGradeRows = Column(0, 14, gradeRow(studentLin))
gradeTable = Column(0, 0, allGradeRows) gradeTable = Column(0, 0, allGradeRows)
// table 最初含一个稳定子槽,后续 replace(0,...) 正是切换这个槽。
gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生") gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生")
showAllGrades = Button("全部学生") showAllGrades = Button("全部学生")
showAttentionGrades = Button("查看复核记录") showAttentionGrades = Button("查看复核记录")
reviewGrade = Button("录入复核成绩") reviewGrade = Button("录入复核成绩")
resetGrades = Button("恢复示例数据") resetGrades = Button("恢复示例数据")
// 四个回调均接受 Event,绑定到同一 GradeCenter 接收者。
showAllGrades.on("click", gradeCenter.showAll) showAllGrades.on("click", gradeCenter.showAll)
showAttentionGrades.on("click", gradeCenter.showAttention) showAttentionGrades.on("click", gradeCenter.showAttention)
reviewGrade.on("click", gradeCenter.review) reviewGrade.on("click", gradeCenter.review)
resetGrades.on("click", gradeCenter.reset) resetGrades.on("click", gradeCenter.reset)
// 表头复用与数据行完全相同的列宽,摘要仅 pending 响应式,6/3是固定示例统计。
gradeHeader = Row(0, 0, gradeHeader = Row(0, 0,
gradeCell(TextBlock("学生", 1, "#7A8390"), 150), gradeCell(TextBlock("学生", 1, "#7A8390"), 150),
gradeCell(TextBlock("学号", 1, "#7A8390"), 120), gradeCell(TextBlock("学号", 1, "#7A8390"), 120),
@@ -101,6 +118,7 @@ gradeSummary = Row(0, 40,
Column(0, 6, TextBlock("学生人数", 1, "#7A8390"), TextBlock("6", 2)), Column(0, 6, TextBlock("学生人数", 1, "#7A8390"), TextBlock("6", 2)),
Column(0, 6, TextBlock("考试科目", 1, "#7A8390"), TextBlock("3", 2)), Column(0, 6, TextBlock("考试科目", 1, "#7A8390"), TextBlock("3", 2)),
Column(0, 6, TextBlock("待复核", 1, "#7A8390"), ui.textOf(gradeCenter, "pending", 2))) Column(0, 6, TextBlock("待复核", 1, "#7A8390"), ui.textOf(gradeCenter, "pending", 2)))
// 最终一次 render 组合摘要、控制区和表格面板;message 绑定显示最近业务操作结果。
gradesPage = Page() gradesPage = Page()
gradesPage.title = "学生成绩管理中心" gradesPage.title = "学生成绩管理中心"
gradesPage.render(Column(32, 20, gradesPage.render(Column(32, 20,
+10
View File
@@ -1,16 +1,23 @@
// 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。
// 当前侧栏提供选择/关闭,文案中的拖放场景也可经版本化事件 API 测试;不要把它理解成已实现的侧栏拖动手势。
// 继承 Page 后观察 moveToWindow/moveTab 的真实方法调用,保留原生迁移/重排行为。
class TrackedPage: Page { class TrackedPage: Page {
calls: Object calls: Object
// 先递增 calls,再显式调用基类执行跨窗迁移;Page 身份保留,旧 Mount 上下文会替换。
function moveToWindow(self: TrackedPage, window: Window): Page { function moveToWindow(self: TrackedPage, window: Window): Page {
self.calls.increment() self.calls.increment()
return Page.moveToWindow(self, window) return Page.moveToWindow(self, window)
} }
// position 是所属窗口内最终零基位置,重排不等于创建新的业务 Page。
function moveTab(self: TrackedPage, position: Number): Page { function moveTab(self: TrackedPage, position: Number): Page {
self.calls.increment() self.calls.increment()
return Page.moveTab(self, position) return Page.moveTab(self, position)
} }
} }
// 保存容器业务对象,按钮插入只改变其子槽顺序,已有 child 的槽与 Mount 尽量保留。
class Actions { class Actions {
list: Column list: Column
// 在索引 1 前插入新 TextBlock,返回的稳定槽可供 remove 等后续操作使用。
function insert(self: Actions, event: Event): Unit { function insert(self: Actions, event: Event): Unit {
self.list.insert(1, TextBlock("插入的新对象")) self.list.insert(1, TextBlock("插入的新对象"))
} }
@@ -20,6 +27,7 @@ list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"
actions = Actions(list) actions = Actions(list)
button = Button("在位置 1 插入 Block") button = Button("在位置 1 插入 Block")
connection = button.on("click", actions.insert) connection = button.on("click", actions.insert)
// A/C/目标页共享同一根及 list;各页激活时使用独立展示上下文,业务插入结果共享。
shared = Column(24, 16, TextBlock("拖动 Tab 重排、移入另一个窗口,或拖出独立窗口"), TextBlock("Page 方法调用次数"), ui.textOf(calls,"value"), button, list) shared = Column(24, 16, TextBlock("拖动 Tab 重排、移入另一个窗口,或拖出独立窗口"), TextBlock("Page 方法调用次数"), ui.textOf(calls,"value"), button, list)
first = TrackedPage(calls) first = TrackedPage(calls)
first.title = "A · 共享内容" first.title = "A · 共享内容"
@@ -30,6 +38,7 @@ second.render(Column(24, 16, TextBlock("这是 B,Page 身份随 Tab 移动保
third = TrackedPage(calls) third = TrackedPage(calls)
third.title = "C · 同一对象" third.title = "C · 同一对象"
third.render(shared) third.render(shared)
// 建立目标窗口并指定桌面位置,MoveTo 使用物理桌面坐标且可能受窗口管理器调整。
other = Window() other = Window()
other.resize(720,520) other.resize(720,520)
other.setTitle("Tab Target") other.setTitle("Tab Target")
@@ -38,6 +47,7 @@ target = TrackedPage(calls)
target.title = "目标窗口" target.title = "目标窗口"
target.render(shared) target.render(shared)
target.open(other) target.open(other)
// 恢复源窗口首个页面,最后排出 resize/title/move/focus,便于手动或 API 验证。
first.activate() first.activate()
ui.window().resize(800,600) ui.window().resize(800,600)
ui.window().setTitle("Tab Source") ui.window().setTitle("Tab Source")
+7 -1
View File
@@ -1,4 +1,7 @@
// Code and tab chrome operate the same Page objects. // 代码与原生侧栏都操作真实 Page 对象;activate/close/open 改变展示关系,业务对象可继续被全局引用保留。
// 三个页面共存于同一 VM,最后 first.activate 选择初始展示页。
// 当前宿主通过侧栏管理页面;Page 的代码接口保持同一业务身份。
// 创建持有计数器与按钮的页面;切到其他页不等于销毁 count。
first = Page() first = Page()
first.title = "共享计数" first.title = "共享计数"
count = ui.state(0) count = ui.state(0)
@@ -6,12 +9,15 @@ increment = Button("加一")
connection = increment.on("click", count.increment) connection = increment.on("click", count.increment)
first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment)) first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment))
// 另一页演示 Page 生命周期与快捷键;界面提示是示例文案,实际导航位于当前宿主侧栏。
second = Page() second = Page()
second.title = "另一个页面" second.title = "另一个页面"
second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开"))) second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开")))
// 独立内容页面仍用同一个运行时的原生类型和资源注册表。
third = Page() third = Page()
third.title = "窗口与布局" third.title = "窗口与布局"
third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45"))) third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45")))
// 重新激活第一页面,原生侧栏和源查询可观察相同 active 状态。
first.activate() first.activate()
ui.window().setTitle("Endor Pages") ui.window().setTitle("Endor Pages")
+8
View File
@@ -1,3 +1,6 @@
// 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。
// onMount 作为业务阶段收集结果;实际布局/绘制计算仍受只读规则约束。
// 把协议探测结果保存到普通业务字段,textOf 将其中一部分显示出来。
class VisualResults { class VisualResults {
width: Number width: Number
height: Number height: Number
@@ -5,8 +8,10 @@ class VisualResults {
childY: Number childY: Number
color: String color: String
} }
// 继承原生矩形,在自身挂载后用该 Mount 显式调用 measure/paint。
class ProbeRect: RectBlock { class ProbeRect: RectBlock {
results: VisualResults results: VisualResults
// 有限最大宽 120 约束自然宽 200;另用自定义 Bounds 查询绘制颜色,把返回结果写进共享 results。
function onMount(self: ProbeRect, mount: Mount): Unit { function onMount(self: ProbeRect, mount: Mount): Unit {
measured = self.measure(mount, Constraints(0, 0, 120, 80)) measured = self.measure(mount, Constraints(0, 0, 120, 80))
self.results.width = measured.width self.results.width = measured.width
@@ -15,14 +20,17 @@ class ProbeRect: RectBlock {
self.results.color = drawing.commands.at(0).color self.results.color = drawing.commands.at(0).color
} }
} }
// 在挂载回调中探测原生排列,复用当前结构的稳定子槽。
class ProbeColumn: Column { class ProbeColumn: Column {
results: VisualResults results: VisualResults
// 给定绝对起点(20,30)及容器范围,原生 padding=24 使首子项位于(44,54)。
function onMount(self: ProbeColumn, mount: Mount): Unit { function onMount(self: ProbeColumn, mount: Mount): Unit {
positions = self.arrange(mount, Bounds(20, 30, 500, 300)) positions = self.arrange(mount, Bounds(20, 30, 500, 300))
self.results.childX = positions.children.at(0).x self.results.childX = positions.children.at(0).x
self.results.childY = positions.children.at(0).y self.results.childY = positions.children.at(0).y
} }
} }
// 先创建结果容器,再将它作为源子类字段共享;root 的最后一个参数是该字段,不是视觉子项。
results = VisualResults(0, 0, 0, 0, "等待挂载") results = VisualResults(0, 0, 0, 0, "等待挂载")
rect = ProbeRect(200, 60, 12, "#DDEEFF", results) rect = ProbeRect(200, 60, 12, "#DDEEFF", results)
root = ProbeColumn(24, 16, TextBlock("对象的原生视觉协议", 2), rect, TextBlock("约束测量宽度"), ui.textOf(results, "width"), TextBlock("绘制颜色"), ui.textOf(results, "color"), results) root = ProbeColumn(24, 16, TextBlock("对象的原生视觉协议", 2), rect, TextBlock("约束测量宽度"), ui.textOf(results, "width"), TextBlock("绘制颜色"), ui.textOf(results, "color"), results)
+8
View File
@@ -1,17 +1,25 @@
// 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。
// 实际可用视口再返回布局约束,原生窗口尺寸不是业务对象上凭空写入的一份几何副本。
// 保存目标 Window,两个按钮对同一资源请求不同尺寸。
class WindowActions { class WindowActions {
window: Window window: Window
// 请求 900x600 逻辑尺寸,最终大小仍由窗口管理器决定。
function compact(self: WindowActions, event: Event): Unit { function compact(self: WindowActions, event: Event): Unit {
self.window.resize(900, 600) self.window.resize(900, 600)
} }
// 请求 1120x720;同次输入多次 resize 可由命令队列合并到最后值。
function wide(self: WindowActions, event: Event): Unit { function wide(self: WindowActions, event: Event): Unit {
self.window.resize(1120, 720) self.window.resize(1120, 720)
} }
} }
// 原生矩形的测量覆盖展示父约束如何随窗口变化。
class WindowBand: RectBlock { class WindowBand: RectBlock {
// 取当前出现位置 maxWidth 作为期望宽度,固定高 32;不读取其他窗口的尺寸。
function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult { function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(constraints.maxWidth, 32) return MeasureResult(constraints.maxWidth, 32)
} }
} }
// ui.window 返回当前主窗口托管资源;page.open 指定同一资源,后面的 title/resize 为原生命令。
window = ui.window() window = ui.window()
actions = WindowActions(window) actions = WindowActions(window)
band = WindowBand(80, 32, 0, "#237A45") band = WindowBand(80, 32, 0, "#237A45")
+8 -1
View File
@@ -1,18 +1,25 @@
// Both native windows use one Aster VM and one shared business object. // 两个原生窗口共用一个 Aster VM 和 count 业务对象,各自拥有独立 Page 与 Mount。
// 任一窗口点击都修改同一计数,两个 textOf 显示随之更新。关闭窗口解除 Page 归属,持有的业务对象仍可重开。
// 跨窗共享的是业务对象,不是某个窗口中的具体 Mount。
// 唯一计数来源;两个按钮直接使用同一零参数原生 increment 方法。
count = ui.state(0) count = ui.state(0)
leftButton = Button("共享计数 +1") leftButton = Button("共享计数 +1")
leftConnection = leftButton.on("click",count.increment) leftConnection = leftButton.on("click",count.increment)
// 先在主窗口建立页面,render 默认选择该页并挂载它的内容。
left = Page() left = Page()
left.title = "主窗口页面" left.title = "主窗口页面"
left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton)) left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton))
// Window() 分配同 VM 中的独立窗口资源,不创建第二个运行时。
other = Window() other = Window()
rightButton = Button("另一个窗口 +1") rightButton = Button("另一个窗口 +1")
rightConnection = rightButton.on("click",count.increment) rightConnection = rightButton.on("click",count.increment)
right = Page() right = Page()
right.title = "独立窗口页面" right.title = "独立窗口页面"
right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton)) right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton))
// 把页面放到 other,上下文更换将重建出现位置,业务 count 仍不变。
right.open(other) right.open(other)
// 窗口命令针对各自资源编号排队;成功输入后再交 GUI,另一窗口设置自己的尺寸。
ui.window().setTitle("Endor Main") ui.window().setTitle("Endor Main")
other.setTitle("Endor Shared") other.setTitle("Endor Shared")
other.resize(960,640) other.resize(960,640)
+13 -2
View File
@@ -1,6 +1,8 @@
// Paste the complete file into Console in Script mode, then Run. // 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。
// All numbers, progress values and activity entries below are demo data. // 辅助函数返回视觉子树,最后统一 Page.render;它们是普通 Endor 函数,不是宿主特殊组件语法。
// 将整个文件作为完整输入提交;这里没有实时指标提供方,全部数据由下面的常量生成。
// 把标签、大数值、说明和色条纵向组合,参数控制文字与强调颜色。
function metric(label: Text, value: Text, note: Text, color: Text): View { function metric(label: Text, value: Text, note: Text, color: Text): View {
return ui.column(0, 6, return ui.column(0, 6,
ui.text(label, 1, "#637082"), ui.text(label, 1, "#637082"),
@@ -10,11 +12,14 @@ function metric(label: Text, value: Text, note: Text, color: Text): View {
) )
} }
// 生成项目标题和状态,再用两个相邻矩形表示已完成/剩余长度;filled/remaining 是逻辑像素。
function project(name: Text, state: Text, detail: Text, filled: Number, remaining: Number, color: Text): View { function project(name: Text, state: Text, detail: Text, filled: Number, remaining: Number, color: Text): View {
// 项目名和状态横排,状态颜色与进度前景相同。
let caption = ui.row(0, 12, let caption = ui.row(0, 12,
ui.text(name, 1, "#243047"), ui.text(name, 1, "#243047"),
ui.text(state, 1, color) ui.text(state, 1, color)
) )
// 两块矩形间 gap=0 合成连续条,条宽由调用方传入的演示数值决定。
let progress = ui.row(0, 0, let progress = ui.row(0, 0,
ui.rect(filled, 6, 3, color), ui.rect(filled, 6, 3, color),
ui.rect(remaining, 6, 3, "#E5EAF2") ui.rect(remaining, 6, 3, "#E5EAF2")
@@ -26,6 +31,7 @@ function project(name: Text, state: Text, detail: Text, filled: Number, remainin
) )
} }
// 组合小圆点、时间戳、标题与说明;spacer 仅作视觉对齐,不代表真实事件时间线。
function activity(time: Text, title: Text, detail: Text, color: Text): View { function activity(time: Text, title: Text, detail: Text, color: Text): View {
let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color)) let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color))
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0")) let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
@@ -36,6 +42,7 @@ function activity(time: Text, title: Text, detail: Text, color: Text): View {
) )
} }
// 头部三个文本使用字号/颜色层级,后续 statistics 复用 metric 返回三组指标。
let heading = ui.column(0, 6, let heading = ui.column(0, 6,
ui.text("ENDOR STUDIO / WORKSPACE", 1, "#5263D6"), ui.text("ENDOR STUDIO / WORKSPACE", 1, "#5263D6"),
ui.text("项目工作台", 2, "#243047"), ui.text("项目工作台", 2, "#243047"),
@@ -48,6 +55,7 @@ let statistics = ui.row(0, 20,
metric("页面草稿", "08", "等待下一次灵感", "#D9A34A") metric("页面草稿", "08", "等待下一次灵感", "#D9A34A")
) )
// 纵向复用 project,示例名称与比例是常量,不做进度计算或持久化。
let projects = ui.column(0, 14, let projects = ui.column(0, 14,
ui.text("项目进度", 1, "#243047"), ui.text("项目进度", 1, "#243047"),
project("Aster", "进行中", "语言解析 / 编译 / 执行", 240, 60, "#6574E8"), project("Aster", "进行中", "语言解析 / 编译 / 执行", 240, 60, "#6574E8"),
@@ -55,6 +63,7 @@ let projects = ui.column(0, 14,
project("设计文档", "草稿", "Block / Page / 对象模型", 108, 192, "#D9A34A") project("设计文档", "草稿", "Block / Page / 对象模型", 108, 192, "#D9A34A")
) )
// 三条 activity 组成静态活动区,再与项目区横排成 body。
let timeline = ui.column(0, 14, let timeline = ui.column(0, 14,
ui.text("最近活动", 1, "#243047"), ui.text("最近活动", 1, "#243047"),
activity("10:24", "更新字体", "中英文混排预览", "#6574E8"), activity("10:24", "更新字体", "中英文混排预览", "#6574E8"),
@@ -64,6 +73,7 @@ let timeline = ui.column(0, 14,
let body = ui.row(0, 20, projects, timeline) let body = ui.row(0, 20, projects, timeline)
// 底部提示以细色块和文字组合,明确说明页面还未接业务交互。
let note = ui.row(0, 12, let note = ui.row(0, 12,
ui.rect(4, 46, 2, "#6574E8"), ui.rect(4, 46, 2, "#6574E8"),
ui.column(0, 6, ui.column(0, 6,
@@ -72,6 +82,7 @@ let note = ui.row(0, 12,
) )
) )
// 最终根统一设置 24px padding 与20px间距;创建辅助子树本身不会提前发布页面。
let page = ui.page() let page = ui.page()
page.render(ui.column(24, 20, page.render(ui.column(24, 20,
heading, heading,

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