24 Commits

Author SHA1 Message Date
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
codex-bot 1e57e908d4 Add a workspace dashboard script example 2026-11-05 00:53:30 +08:00
Zayden-Jung 303c823eb0 Merge pull request 'test: 修正窗口 chrome 引入后的布局测试尺寸预期' (#2) from codex/fix-layout-ci into main
Verify Tessera GUI / verify (push) Successful in 3m36s
Reviewed-on: #2
2026-10-29 20:28:36 +00:00
Zayden-Jung f787ff25e0 Merge pull request 'ci: 仅 main 分支执行 CI/CD' (#1) from codex/main-only-cicd into main
Verify Tessera GUI / verify (push) Has been cancelled
Reviewed-on: #1
2026-10-29 20:28:29 +00:00
141 changed files with 12938 additions and 2193 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
+17 -1
View File
@@ -1,3 +1,4 @@
# Cargo 包元数据与编译器最低版本;二进制入口默认取 src/main.rs。
[package]
name = "tessera"
version = "0.1.0"
@@ -6,24 +7,39 @@ rust-version = "1.91"
description = "Cross-platform desktop rendering client"
license = "MIT OR Apache-2.0"
# 跨平台运行依赖:语言执行、文字排版、序列化、窗口/像素表面与轻量 HTTP 服务。
# Aster 三个 crate 使用固定在本仓库 vendor 子模块中的本地源码,不从注册表选择同名实现。
[dependencies]
# 应用边界的错误聚合与上下文,保留底层失败原因。
anyhow = "1.0"
# Aster 字节码编译器,把前端结果交给同一套 VM 执行。
compiler = { path = "vendor/aster/crates/compiler" }
# 系统字体发现、塑形与换行,用于测量和软件文字绘制。
cosmic-text = "0.19"
# Aster 托管对象、类型系统、VM 和可恢复调用;依赖包名 core 在 Rust 中重命名为 endor_core。
endor_core = { package = "core", path = "vendor/aster/crates/core" }
# Endor 源语法解析与原始位置诊断。
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"
# 把宿主软件光栅化得到的像素缓冲呈现到原生窗口。
softbuffer = "0.4.8"
# 本地 Agent API 的 HTTP 监听与请求响应,不引入异步 Web 运行时。
tiny_http = "0.12"
# 按 Unicode 字素边界处理编辑/选择,避免把组合字符拆开。
unicode-segmentation = "1.12"
# 跨平台窗口、事件循环、DPI 与输入法事件。
winit = "0.30.13"
# 仅 macOS 编译原生标题栏适配,显式开启所用 AppKit/Foundation API,其他平台不引入这些依赖。
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5.2"
objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSApplication", "NSButton", "NSEvent", "NSView", "NSWindow"] }
objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSGeometry", "NSString", "NSUserDefaults"] }
# 发布构建:thin LTO 与单代码生成单元偏重最终优化,strip 去除可执行文件符号以控制体积。
[profile.release]
lto = "thin"
codegen-units = 1
-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"]
+34 -38
View File
@@ -37,8 +37,9 @@ Cargo compiles the required Aster libraries together with Tessera. No separate
Aster build, service or deployment is required. Local builds download dependencies
from official crates.io by default. A temporary dependency mirror may be used
when the official source is unavailable, without changing global configuration.
Docker and automated tests
are not used locally. The command above produces a native executable. To create a
Builds and validation run locally using the native Rust toolchain; no CI/CD
workflow or Docker toolchain is configured. The command above produces a native
executable. To create a
Finder-launchable development app on macOS, run:
```bash
@@ -127,7 +128,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,
and glyphs are rasterized at the actual display resolution rather than enlarged.
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
@@ -170,7 +171,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.
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
process isolation remain incomplete. Worker threads,
source budgets and VM instruction limits are not a complete untrusted-code sandbox.
@@ -234,13 +235,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.
`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.
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.
@@ -248,9 +249,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.
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).
@@ -258,7 +259,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 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).
@@ -274,25 +277,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.
The application starts with one managed New tab. The + button and Cmd/Ctrl+T
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.
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.
Console, Sources, Network and Settings are independent managed Pages with separate
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
discovery UI is intentionally deferred. Existing integrations can call
opened by default; the left sidebar provides their launchers. Existing integrations can call
`ui.developerTools("Console")`, `"Sources"`, `"Network"` or `"Settings"`.
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.
@@ -410,17 +399,24 @@ UTF-8 even when launched from Finder, and decoding errors are surfaced.
## Validation
Local native builds and window runs are allowed; do not run local automated tests
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,
failed-update and deletion-invalidation regressions, plus Clippy.
The API regressions use actual HTTP requests, including DELETE while the worker
is occupied and a queued PUT is superseded.
Linux CI does not verify macOS window presentation. Aster has no standalone CI/CD; Lyra CI
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
fallback, grapheme-safe truncation, antialiasing and alpha blending.
Run checks from the repository root after initializing the pinned Aster submodule:
```bash
cargo fmt --check
cargo test --locked
cargo clippy --all-targets --locked -- -D warnings
sh -n scripts/build-macos-app.sh
```
The existing tests cover rendering, Aster execution, page layout, source ranges,
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
@@ -599,7 +595,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.
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.
+3 -3
View File
@@ -7,7 +7,7 @@ Tessera/
├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果
├── src/
│ ├── main.rs 组合入口、事件循环和共享发布状态
│ ├── shell/ 原生窗口、标签、开发工具、管理控件
│ ├── shell/ 原生窗口、可收起侧栏、开发工具、管理控件
│ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务
│ ├── model/ 托管对象、挂载、绑定、依赖和发布数据
│ ├── render/ 布局、绘制、命中、字体与软件光栅化
@@ -16,7 +16,7 @@ Tessera/
├── docs/
│ ├── architecture/ 本套架构说明和 UML 源文件
│ └── media/ 已有建模视频与字幕
├── scripts/ 原生打包及已有远程检查入口
├── scripts/ 本地原生打包及 UML 文档渲染
└── 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-endor-worker` | 活跃 ReplSession、候选 VM、托管对象图 | 串行执行程序、回调、布局/绘制协议及任务完成交付 |
| 输入发送线程 | 有界输入客户端 | 把原生输入转为本机 HTTP 请求;不执行 VM |
+11 -1
View File
@@ -26,7 +26,17 @@
4. [建模视频](../media/endor-campus-modeling.mp4) 与 [中文字幕](../media/endor-campus-modeling.zh.srt):已有实际窗口操作记录,静音、采样帧率有说明。
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 会编译检查已有测试代码,但不代表测试已经执行。
+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 中。
+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,空闲连续采样的版本保持一致。保留应用窗口供用户直接操作。
+9 -2
View File
@@ -2,6 +2,10 @@
| 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. |
| [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. |
@@ -21,12 +25,15 @@
| [main.endor](main.endor) | The earlier function-entry form, producing the same frame as page.endor. |
| [hello.endor](hello.endor) | Richer function-entry example covering a helper, Row/Column and Spacer. |
| [fonts.endor](fonts.endor) | Mixed Chinese/Latin text and proportional widths, with rectangles following narrow and wide letters. |
| [workspace.endor](workspace.endor) | Larger static workspace dashboard with reusable functions, metrics, progress bars and an activity column. Paste the complete file into Console in Script mode; all displayed data is illustrative. |
Submit a file as the UTF-8 body of a namespace PUT request. No namespace,
scene, render block or imports belong in the source.
The live `/agent` guide embeds page.endor exactly. Remote CI executes the examples through
Aster and checks the resulting coordinates and source ranges after VM cleanup.
The live `/agent` guide embeds page.endor exactly. Local tests execute examples through
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.
Return to the [project guide](../README.md).
+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 {
output: TextBlock
problem: TextBlock
status: TextBlock
task: Object
// 仅成功时调用,result 在成功回调期间已可读;写原 TextBlock 触发显示刷新。
function apply(self: FileReader): Unit {
self.output.text = self.task.result()
}
// 以 self 为 owner、file 为 key 启动读取,清空旧输出,并把状态/错误文字绑定到当前任务。
function read(self: FileReader, path: String): Unit {
self.task = ui.readText(self, "file", path, self.apply)
self.output.text = ""
// Task.status/error 是零参数只读方法,可直接作为绑定计算;换任务后绑定也随本次调用更新。
self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error)
}
}
// 先建立界面对象,保持 reader 为可从 Console 操作的全局业务入口。
output = TextBlock("尚未读取文件")
problem = TextBlock("")
status = TextBlock("")
+7
View File
@@ -1,21 +1,28 @@
// 异步 HTTP(S) 文本 GET:Console 调用 fetcher.fetch(url),沿用 owner/key 替换、状态与成功回调协议。
// 完整响应保存为 String,界面只显示完成提示,避免把可能很大的响应放进文字控件。
// response 是结果业务存储;output/problem/status 仅呈现任务进度与提示。
class TextFetcher {
output: TextBlock
problem: TextBlock
status: TextBlock
task: Object
response: String
// 成功后取出完整 UTF-8 结果,写 response 供 Console 查询;provider 失败不会进入此方法。
function apply(self: TextFetcher): Unit {
self.response = self.task.result()
self.output.text = "请求完成;在 REPL 查看 fetcher.response"
}
// 同一 self/request 身份的新请求取消旧交付,重置旧内容并把显示绑定到新任务。
function fetch(self: TextFetcher, url: String): Unit {
self.task = ui.fetchText(self, "request", url, self.apply)
self.response = ""
self.output.text = ""
// 绑定读取当前 Task 方法,完成和失败由 worker 的任务状态变更传播。
self.status.bind("text", self.task.status)
self.problem.bind("text", self.task.error)
}
}
// 界面不自动访问网络,用户以 Console 明确指定 URL 后才创建任务。
output = TextBlock("尚未请求网络")
problem = TextBlock("")
status = TextBlock("")
+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 {
output: TextBlock
status: TextBlock
task: Object
file: Object
response: String
// await 在文件未完成时挂起并保留 self/操作数;恢复后只执行后半段一次,再把字符串作为 workflow.result。
function apply(self: AwaitReader): String {
self.response = await self.file
self.output.text = "读取完成;在 REPL 查看 reader.response 或 reader.task.result()"
return self.response
}
// 先创建读任务,再调度普通函数 apply;spawn 而非新 async 语法提供可暂停执行上下文。
function read(self: AwaitReader, path: String): Unit {
self.file = ui.readText(self, "file", path)
self.task = ui.spawn(self, "workflow", self.apply)
self.response = ""
self.output.text = "等待文件"
// 显示的是 workflow 状态,等待 provider 时它仍报告 running;结束后 result 可从 Task 或 response 读取。
self.status.bind("text", self.task.status)
}
}
output = TextBlock("在 REPL 调用 reader.read(绝对路径字符串)")
status = TextBlock("")
// 占位值只用于满足初始字段构造;在 read 内被真实 Task 替换,页面建立不自动读文件。
reader = AwaitReader(output,status,ui.state(0),ui.state(0),"")
page = Page()
page.title = "await 文件"
+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.
// Fictional student/course data. Paste the complete source into Console and Enter.
// All seven tabs are ordinary managed Pages. There is no server or Canvas login.
// 多页面校园演示:七个普通托管 Page 共享 CampusCourse/CampusWork 业务对象,所有课程与学生数据均虚构。
// 无服务器、账号登录或真实上传;提交/评分按钮只应用预设字段值,其他页通过绑定读同一状态。
// 作为一次完整输入提交;七页共用同一VM,会话重置后数据消失。
// 白色圆角卡片容器,继承 Column 的排版,只覆盖自身背景绘制。
class CampusPanel: Column {
// 按当前出现位置 geometry 创建一条背景命令,子项由展示遍历继续绘制。
function paint(self: CampusPanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
fill.kind = "roundedRect"
@@ -14,18 +16,23 @@ class CampusPanel: Column {
return DisplayList(ObjectList(fill))
}
}
// 通过额外 width 字段固定表格列的期望宽,保持成绩表头和行对齐。
class CampusCell: Column {
width: Number
// 先算子内容自然高度,再改新测量结果宽度,保留真实字体引起的高度。
function measure(self: CampusCell, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints)
result.width = self.width
return result
}
}
// 页面内容里的演示导航栏,与应用自身的原生页面侧栏是两棵不同控件树。
class CampusSidebar: Column {
// 返回固定168x620期望尺寸,外层 ScrollBlock 承担超出区域的滚动。
function measure(self: CampusSidebar, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(168, 620)
}
// 为导航区创建深红背景,位置/大小来自当前Mount,文字等子项保持独立命令。
function paint(self: CampusSidebar, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
fill.kind = "roundedRect"
@@ -38,11 +45,14 @@ class CampusSidebar: Column {
return DisplayList(ObjectList(fill))
}
}
// Button 子类增加背景字段,固定导航按钮尺寸,并保留原生事件目标能力。
class CampusNav: Button {
background: String
// 导航按钮期望144x40,确保四个入口宽高一致。
function measure(self: CampusNav, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(144, 40)
}
// 取原生按钮背景命令改色/圆角;文字由后面 replace 后的子项绘制,不在此重复生成。
function paint(self: CampusNav, mount: Mount, geometry: Bounds): DisplayList {
fill = Button.paint(self, mount, geometry).commands.at(0)
fill.color = self.background
@@ -50,6 +60,7 @@ class CampusNav: Button {
return DisplayList(ObjectList(fill))
}
}
// 课程业务记录,grade/progress 被多个页面绑定读取,其余静态字段构建时直接读取。
class CampusCourse {
title: String
code: String
@@ -57,6 +68,7 @@ class CampusCourse {
grade: String
progress: String
}
// 实验状态集中在一个对象,course 指向同一课程模型,评分因此能影响全站课程摘要。
class CampusWork {
status: String
score: String
@@ -65,12 +77,14 @@ class CampusWork {
todo: String
result: String
course: CampusCourse
// 模拟提交只改状态、文件名提示、待办和结果文字,不访问磁盘或网络。
function submit(self: CampusWork, event: Event): Unit {
self.status = "已提交 · 等待评分"
self.submission = "lab01.endor · 已提交示例内容"
self.todo = "已完成本次实验提交"
self.result = "示例提交成功,可以切到作业中心查看同一条记录。"
}
// 应用预设92分和反馈,并更新所属课程成绩/进度;绑定让仪表盘、课程、成绩页同步观察。
function assess(self: CampusWork, event: Event): Unit {
self.status = "已评分"
self.score = "92 / 100"
@@ -81,6 +95,7 @@ class CampusWork {
self.course.progress = "已完成 4 / 6 个学习单元"
self.result = "已应用预设评分;仪表盘、课程主页和成绩总览同步更新。"
}
// 恢复初始实验与课程状态,保留所有原对象和绑定,便于反复演示。
function reset(self: CampusWork, event: Event): Unit {
self.status = "未提交"
self.score = "— / 100"
@@ -92,20 +107,25 @@ class CampusWork {
self.result = "已恢复实验、课程成绩和学习进度的示例状态。"
}
}
// 用统一包装构造表格单元格;传入的content是已有Block引用。
function campusCell(content: Block, width: Number): Block {
return CampusCell(0, 0, content, width)
}
// 显式事件适配器把click的Event与Page.open可选Window参数分开,不能直接把Event误当窗口。
class CampusTarget {
page: Page
// Consume the click Event here; Page.open's optional argument is a Window.
// 事件参数在适配器这一层消化,Page.open 只接收其声明的可选 Window。
// 消费事件但不传给Page.open;无参open表示在合适窗口打开/激活目标页。
function open(self: CampusTarget, event: Event): Unit { self.page.open() }
}
// 为普通按钮创建持有目标Page的适配器,并把其绑定方法注册为click回调。
function campusLink(label: String, target: Page): Button {
button = Button(label)
action = CampusTarget(target)
button.on("click", action.open)
return button
}
// 构造带指定背景的导航按钮,替换原生文字子槽为白字,然后复用事件适配器连接目标页。
function campusNav(label: String, target: Page, background: String): Button {
button = CampusNav(label, background)
button.replace(0, TextBlock(label, 1, "#FFFFFF"))
@@ -113,6 +133,7 @@ function campusNav(label: String, target: Page, background: String): Button {
button.on("click", action.open)
return button
}
// 用目标Page和四个色调参数构建整块导航;不同页面复用目标资源但用不同高亮背景。
function campusNavigation(home: Page, course: Page, assignments: Page, grades: Page, homeTone: String, courseTone: String, workTone: String, gradeTone: String): Block {
return CampusSidebar(12, 10,
TextBlock("Endor", 2, "#FFFFFF"),
@@ -125,6 +146,7 @@ function campusNavigation(home: Page, course: Page, assignments: Page, grades: P
TextBlock("2026 秋季学期", 1, "#E7BAC0"),
TextBlock("本地演示", 1, "#E7BAC0"))
}
// 把演示导航与标题/正文横排,外层有限ScrollBlock启用双轴,容纳固定宽度演示内容。
function campusShell(navigation: Block, title: String, subtitle: String, body: Block): Block {
scroll = ScrollBlock(1120, 680,
Row(24, 28, navigation,
@@ -135,6 +157,7 @@ function campusShell(navigation: Block, title: String, subtitle: String, body: B
scroll.axis = "both"
return scroll
}
// 卡片静态显示课程代码/标题/教师,grade 用绑定随评分变化,入口按钮引用真实课程Page。
function campusCourseCard(course: CampusCourse, page: Page, color: String): Block {
return CampusPanel(0, 0,
RectBlock(248, 44, 8, color),
@@ -145,6 +168,7 @@ function campusCourseCard(course: CampusCourse, page: Page, color: String): Bloc
ui.textOf(course, "grade", 2, color),
campusLink("进入课程", page)))
}
// 成绩表一行复用固定列宽单元格,当前grade为响应式文字,详情链接指向指定课程页。
function campusGradeRow(course: CampusCourse, target: Page): Block {
return Column(0, 12,
Row(0, 0,
@@ -154,6 +178,7 @@ function campusGradeRow(course: CampusCourse, target: Page): Block {
campusLink("课程详情", target)),
RectBlock(752, 1, 0, "#EDF0F3"))
}
// 组合课程概况/进度和静态学习单元;上方两个链接复用作业/成绩Page,不创建新页面。
function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String, moduleTwo: String, moduleThree: String, assignments: Page, grades: Page): Block {
return Column(0, 20,
Row(0, 16, campusLink("作业与提交", assignments), campusLink("查看成绩", grades)),
@@ -171,11 +196,13 @@ function campusCourseBody(course: CampusCourse, intro: String, moduleOne: String
RectBlock(752, 1, 0, "#EDF0F3"),
TextBlock(moduleThree, 1, "#526372")))
}
// The shared object graph is created once; each Page projects those same objects.
// 共享对象图只构造一次,各Page的文字绑定投影同一业务字段。
// 只创建三份课程模型和一份实验模型,后续各页读取这些对象,避免页面间状态副本不一致。
campusProgramming = CampusCourse("计算机程序设计", "CS 101", "陈老师 · 周二 / 周四", "86.0%", "已完成 3 / 6 个学习单元")
campusMathematics = CampusCourse("高等数学 I", "MATH 101", "周老师 · 周一 / 周三", "91.0%", "已完成 4 / 6 个学习单元")
campusEnglish = CampusCourse("学术英语", "ENG 102", "许老师 · 周五", "89.0%", "已完成 3 / 5 个学习单元")
campusWork = CampusWork("未提交", "— / 100", "提交后可在此查看教师反馈。", "尚无提交内容", "1 项实验待提交", "这是一个本地提交与评分演示。", campusProgramming)
// 先创建全部七个Page,允许导航按钮互相引用;随后逐一render建立内容。
campusDashboard = Page()
campusDashboard.title = "Campus · 仪表盘"
campusProgrammingPage = Page()
@@ -190,10 +217,12 @@ campusGrades = Page()
campusGrades.title = "Campus · 成绩总览"
campusWorkPage = Page()
campusWorkPage.title = "实验 01 · 提交详情"
// 预建四种高亮导航树,课程三页共享course导航,Page切换时各自拥有独立Mount。
campusHomeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#A64350", "#852B38", "#852B38", "#852B38")
campusCourseNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#A64350", "#852B38", "#852B38")
campusAssignmentNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#A64350", "#852B38")
campusGradeNav = campusNavigation(campusDashboard, campusProgrammingPage, campusAssignments, campusGrades, "#852B38", "#852B38", "#852B38", "#A64350")
// 仪表盘展示课程卡和当前实验摘要,status/todo均从同一campusWork绑定读取。
campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎回来。这里是你的课程与近期学习安排。",
Column(0, 20,
Row(0, 16,
@@ -206,6 +235,7 @@ campusDashboard.render(campusShell(campusHomeNav, "仪表盘", "林沐,欢迎
ui.textOf(campusWork, "todo", 1, "#7C8793"),
campusLink("打开作业详情", campusWorkPage)),
TextBlock("虚构课程与学生数据 · Endor 本地多页面演示", 1, "#8C959F"))))
// 程序设计课程附加实验入口;数学和英语页复用相同概况结构但读取不同课程模型。
campusProgrammingPage.render(campusShell(campusCourseNav, "计算机程序设计", "课程 / CS 101 / 主页",
Column(0, 16,
campusCourseBody(campusProgramming, "通过实际编程理解对象、函数与交互。", "01 对象与字段 · 已完成", "02 函数与方法 · 已完成", "03 事件与可视界面 · 进行中", campusAssignments, campusGrades),
@@ -214,6 +244,7 @@ campusMathPage.render(campusShell(campusCourseNav, "高等数学 I", "课程 / M
campusCourseBody(campusMathematics, "学习极限、微分与积分的基本方法。", "01 函数与极限 · 已完成", "02 导数及其应用 · 已完成", "03 积分方法 · 进行中", campusAssignments, campusGrades)))
campusEnglishPage.render(campusShell(campusCourseNav, "学术英语", "课程 / ENG 102 / 主页",
campusCourseBody(campusEnglish, "阅读学术文本,练习清晰的论证与表达。", "01 Academic reading · 已完成", "02 Evidence and arguments · 已完成", "03 Research writing · 进行中", campusAssignments, campusGrades)))
// 作业中心只有程序设计实验为共享动态模型,其余两项是固定演示文字。
campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本学期 / 全部课程 / 作业与提交",
Column(0, 20,
CampusPanel(20, 12,
@@ -227,6 +258,7 @@ campusAssignments.render(campusShell(campusAssignmentNav, "作业中心", "本
CampusPanel(20, 12,
TextBlock("Reading response", 1, "#263444"),
TextBlock("ENG 102 · 已评分 · 88 / 100", 1, "#7C8793")))))
// 成绩总览复用课程grade绑定和实验score/feedback,评分无需逐页手动写UI。
campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学期 / 当前课程成绩",
Column(0, 20,
CampusPanel(20, 16,
@@ -242,12 +274,14 @@ campusGrades.render(campusShell(campusGradeNav, "成绩总览", "2026 秋季学
Row(0, 24, TextBlock("实验 01:对象与函数"), ui.textOf(campusWork, "score", 1, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987"),
campusLink("打开作业与反馈", campusWorkPage)))))
// 三个操作按钮连接同一个实验对象的submit/assess/reset,均为真实源方法回调。
campusSubmitButton = Button("模拟提交实验")
campusSubmitButton.on("click", campusWork.submit)
campusAssessButton = Button("应用示例评分")
campusAssessButton.on("click", campusWork.assess)
campusResetButton = Button("恢复演示数据")
campusResetButton.on("click", campusWork.reset)
// 详情页组合要求、提交状态、操作按钮和反馈;所有输入仍只是预设本地状态转换。
campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函数", "CS 101 / 作业 / 提交详情",
Column(0, 20,
Row(0, 16, campusLink("返回课程", campusProgrammingPage), campusLink("全部作业", campusAssignments), campusLink("成绩总览", campusGrades)),
@@ -265,4 +299,5 @@ campusWorkPage.render(campusShell(campusAssignmentNav, "实验 01:对象与函
Row(0, 24, TextBlock("成绩与反馈"), ui.textOf(campusWork, "score", 2, "#315E86")),
ui.textOf(campusWork, "feedback", 1, "#6D7987")),
ui.textOf(campusWork, "result", 1, "#7C8793"))))
// 逐页render会改变当前活动页,最后显式选择仪表盘作为打开后的首页。
campusDashboard.activate()
+9
View File
@@ -1,28 +1,37 @@
// 普通源类继承示例:GUI 点击执行继承的 activate,内部 self.change 再动态分派到 DemoCounter 覆盖。
// 两个 textOf 共享同一模型字段,修改一次业务值会更新两个展示位置。
// 定义业务状态和公共事件入口,Number/String 字段在构造与赋值时按声明契约检查。
class Counter {
value: Number
lastEvent: String
// 保存实际事件种类,再通过 self 调用可覆盖的方法;接收者保持具体子类身份。
function activate(self: Counter, event: Event): Unit {
self.lastEvent = event.kind
self.change()
}
// 基类默认行为把值置为 10,子类可复用入口并替换这部分策略。
function change(self: Counter): Unit {
self.value = 10
}
}
// 继承 Counter 的字段与 activate,仅替换 change;构造参数仍按继承字段顺序传入。
class DemoCounter: Counter {
// 覆盖把值设为 12,演示从继承方法内部发起的调用也会选中实际接收者的方法。
function change(self: DemoCounter): Unit {
self.value = 12
}
}
// 创建唯一模型,之后所有绑定和按钮回调都指向它,无需把 GUI 状态复制到另一份存储。
model = DemoCounter(9, "等待点击")
first = ui.textOf(model, "value", 2)
second = ui.textOf(model, "value", 2)
eventName = ui.textOf(model, "lastEvent")
button = Button("调用继承方法,再分派到覆盖方法")
// 注册绑定方法,运行时将 click 的 Event 传给 activate;最后 Page.render 才把对象树显示出来。
button.onClick(model.activate)
page = Page()
page.render(Column(24, 16, TextBlock("Endor class:继承、self 与动态方法分派", 2), Row(0, 16, first, second), button, eventName))
+9
View File
@@ -1,11 +1,20 @@
// 构造器分段示例:原生可选参数/可变子项与源子类新增字段分别验证。
// 省略 RectBlock 的 radius/color 不应使字符串 tag 被错当成原生颜色参数。
// 在原生矩形后增加 tag 字段;位置构造仍保留 width/height 及可选圆角/颜色规则。
class TaggedRect: RectBlock { tag: String }
// 在可变数量 Block 子项之后接收源 tag,子项列表与字段不混用。
class TaggedColumn: Column { tag: String }
// 保留原生事件 kind,再追加业务字符串字段。
class TaggedEvent: Event { tag: String }
// 原生列表成员之后追加 tag,列表元素仍可保存不同托管类型。
class TaggedList: ObjectList { tag: String }
// 将需要更新的矩形作为强业务引用保存,按钮回调只修改它的颜色。
class Actions {
rect: TaggedRect
// 写原对象的 color,挂载显示由依赖跟踪刷新,不需要重新构造整个页面。
function recolor(self: Actions): Unit { self.rect.color = "#237A45" }
}
// 这里刻意省略原生可选项,用末尾字符串初始化子类字段;后面的列表/Column 做对应可变参数演示。
rect = TaggedRect(180, 60, "省略 radius/color 后的字段")
event = TaggedEvent("click", "事件字段")
items = TaggedList(rect, event, "列表成员之后的字段")
+7
View File
@@ -1,5 +1,10 @@
// 协作任务示例:两个 ui.spawn 各自保留执行帧,worker 按指令量子轮转推进。
// 这里刻意重复增量语句,方便看到暂停点;取消不回滚已经发生的 steps 写入,单个原生调用仍是原子边界。
// 每个实例有独立 steps 状态,两个任务不会把局部帧或计数存储混在一起。
class CooperativeWork {
steps: Object
// 普通零参数方法被 spawn 调度后可跨量子继续;每条 increment 更新同一实例的计数。
// 连续语句无需手工 yield,VM 按累计预算暂停,恢复时不会从第一条重放。
function run(self: CooperativeWork): Unit {
self.steps.increment()
self.steps.increment()
@@ -67,10 +72,12 @@ class CooperativeWork {
self.steps.increment()
}
}
// 创建两个独立 owner,虽然 key 都叫 work,也不会相互替换;owner/key 组合才是任务身份。
left = CooperativeWork(ui.state(0))
right = CooperativeWork(ui.state(0))
leftTask = ui.spawn(left,"work",left.run)
rightTask = ui.spawn(right,"work",right.run)
// 状态文字绑定到 Task.status,业务进度可在 Console 查询 left/right 的 steps.value。
leftStatus = TextBlock("")
rightStatus = TextBlock("")
leftStatus.bind("text",leftTask.status)
+10
View File
@@ -1,3 +1,6 @@
// 同一托管对象系统中的事件阶段:祖先 capture → 目标 onEvent → 目标监听器 → 祖先 bubble。
// EventLog 用一个递增序列记录实际次序;改变监听器/传播标记可对比跳过与停止的语义。
// 保存各阶段序号与最近 phase;count 是共享状态,其他字段直接写在本模型中。
class EventLog {
count: Object
captureStep: Number
@@ -5,24 +8,29 @@ class EventLog {
targetStep: Number
bubbleStep: Number
phase: String
// 祖先捕获阶段先递增序列,再保存 captureStep 和 Event.phase。
function capture(self: EventLog, event: Event): Unit {
self.count.increment()
self.captureStep = self.count.value
self.phase = event.phase
}
// 目标监听器的执行点;它排在目标类型 onEvent 方法之后。
function target(self: EventLog, event: Event): Unit {
self.count.increment()
self.targetStep = self.count.value
self.phase = event.phase
}
// 祖先冒泡阶段记录最后序号,未被 stopPropagation 时才到达。
function bubble(self: EventLog, event: Event): Unit {
self.count.increment()
self.bubbleStep = self.count.value
self.phase = event.phase
}
}
// 覆盖目标方法,比额外 on(click,...) 监听器更早处理目标阶段事件。
class EventButton: Button {
model: EventLog
// 把模型序列递增后写 methodStep,供断言与 capture/target/bubble 比较。
function onEvent(self: EventButton, event: Event): Unit {
self.model.count.increment()
self.model.methodStep = self.model.count.value
@@ -32,6 +40,8 @@ class EventButton: Button {
model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击")
button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model)
root = Column(24, 16, TextBlock("同一对象系统中的事件分派", 2), button, ui.textOf(model.count, "value"), ui.textOf(model, "phase"))
// capture 选项显式选择捕获;不带选项的祖先监听器默认在冒泡阶段执行。
// 返回的令牌可 disconnect,同次派发中断开尚未执行的监听器也应生效。
capture = root.on("click", model.capture, "capture")
target = button.on("click", model.target)
bubble = root.on("click", model.bubble)
+7
View File
@@ -1,17 +1,24 @@
// 正常退出与多窗口卸载示例:同一 ExitText 出现在两窗,onUnmount 按出现位置分别调用。
// 退出先取消任务、关闭窗口,再进行允许的卸载回调;已停止 VM 的清理不能重新进入源代码。
// 在原生文字类型上增加共享日志,用来观察每个 Mount 的卸载通知。
class ExitText: TextBlock {
log: Object
// 每次真实卸载递增同一 log;普通重绘不调用,重复 shutdown 也不应重复调用。
function onUnmount(self: ExitText, mount: Mount): Unit {
self.log.increment()
}
}
log = ui.state(0)
// 文字业务对象只创建一次,Page 与另一窗都引用它,但两边有各自的 Mount 生命周期。
watched = ExitText("关闭副窗口可观察卸载计数;退出应用也会卸载剩余位置",2,log)
page = Page()
page.title = "退出生命周期"
page.render(Column(24,16,watched,ui.textOf(log,"value")))
// 创建同 VM 内的另一原生窗口;迁移 other 后仍保留 watched 的业务身份。
second = Window()
second.setTitle("Exit lifecycle secondary")
other = Page()
other.render(watched)
other.moveToWindow(second)
// 应用级长定时任务用于观察退出会取消尚未完成的工作,不让退出过程等满 30 秒。
pending = ui.sleep(ui,"pending",30000)
+6
View File
@@ -1,12 +1,18 @@
// 字段类型契约示例:CounterText 同时有原生文本载荷和源 Number 字段。
// 绑定显示读取该字段,GUI 回调与 Console 赋值受同一契约约束。
// 增加 value:Number;构造时先传原生 text,再传源字段值。
class CounterText: TextBlock {
value: Number
// 收到 click 后把已有数值改为 10,依赖文字随之更新,不修改原生 text 内容。
function change(self: CounterText, event: Event): Unit {
self.value = 10
}
}
// 声明 target 必须是 Block,可接收 CounterText,因为它是 TextBlock 的子类。
class Model {
target: Block
}
// 构造唯一受检对象,Model、事件回调和 textOf 均引用它。
counter = CounterText("共享的原生子类对象", 9)
model = Model(counter)
button = Button("把 Number 字段改成 10")
+9
View File
@@ -1,11 +1,17 @@
// flexBasis/flexMin/flexMax/flexShrink 约束同一主轴分配。
// 增长达到上限或收缩达到下限后,剩余空间/缺额重新分给其他尚可调整的项,固定项保持 60。
// 给 Row 一个有限 340x60 的期望尺寸,便于精确观察分配数值。
class Strip: Row {
// 源测量返回固定尺寸;后续排列仍使用原生 Row 的 padding/gap 与 flex 规则。
function measure(self: Strip, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(340, 60)
}
}
// 分别持有两个可调整矩形,按钮切换增长/收缩策略,而不是手动写最终 bounds。
class Actions {
first: RectBlock
second: RectBlock
// 两项 basis=100,按1:3分增长;首项 max=105 触顶后,多余增长转给第二项。
function grow(self: Actions): Unit {
self.first.flexMin = 0
self.first.flexMax = 105
@@ -14,6 +20,7 @@ class Actions {
self.first.flex = 1
self.second.flex = 3
}
// 两项 basis=200 造成缺额,以 flexShrink*basis 分摊;首项 min=150 后第二项继续收缩到90。
function shrink(self: Actions): Unit {
self.first.flexMax = 300
self.first.flexMin = 150
@@ -25,6 +32,7 @@ class Actions {
self.second.flexShrink = 1
}
}
// 自然宽20/40与basis可不同;fixed不设置权重,保持原始60宽。
first = RectBlock(20, 20, 4, "#A4C8F0")
second = RectBlock(40, 20, 4, "#A6D8B2")
fixed = RectBlock(60, 20, 4, "#E8C990")
@@ -33,6 +41,7 @@ grow = Button("增长:上限后重新分配")
grow.onClick(actions.grow)
shrink = Button("收缩:最小尺寸后重新分配")
shrink.onClick(actions.shrink)
// 先初始化增长策略再挂载,首次发布即采用105/135/60,不需等待按钮点击。
actions.grow()
strip = Strip(10, 10, first, second, fixed)
page = Page()
+7
View File
@@ -1,8 +1,14 @@
// 按 flex 权重分配有限主轴空间:固定项先占自然尺寸,剩余空间按正权重划分。
// paragraph 只有一份业务内容,但在两个 Row 的 Mount 中各自按真实分配宽度测量/换行。
// 只持有首个 Row 的 filler,修改它的权重来间接改变同排文字分配。
class Actions {
filler: RectBlock
// 将 filler 权重恢复为 1,与 paragraph=1 近似等分可用空间。
function balanced(self: Actions): Unit { self.filler.flex = 1 }
// filler 权重改为 3,paragraph 在首排获得剩余空间的四分之一,换行高度随之增加。
function narrow(self: Actions): Unit { self.filler.flex = 3 }
}
// 共享文字的 flex=1 是业务布局策略,具体几何与自然尺寸缓存仍属于各 Mount。
paragraph = TextBlock("GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。GUI 和代码操控同一套 Aster 内存对象系统。对象只有一份,但每次挂载都有自己的约束和布局。这里用同一个 TextBlock 在两个容器中显示不同的换行。修改权重会重新测量文字高度,随后更新相邻布局。")
paragraph.flex = 1
filler = RectBlock(20, 20)
@@ -12,6 +18,7 @@ balanced = Button("等比例分配")
balanced.onClick(actions.balanced)
narrow = Button("文字只占四分之一")
narrow.onClick(actions.narrow)
// 首排两个可伸缩子项,次排只有文字 flex 与固定 300px 兄弟,因此首排按钮不会改次排宽度。
first = Row(10, 10, paragraph, filler)
second = Row(10, 10, paragraph, RectBlock(300, 20))
page = Page()
+6
View File
@@ -1,5 +1,11 @@
// 字体测量示例:中英文混排、重音字符和不同字宽均走系统字体塑形与回退。
// 布局使用实际字形宽度,iiii 与 WWWW 后的色块位置应不同,不能用字符数量估算宽度。
// 字号2与独立颜色用于标题,中文是否可见取决于运行环境可用CJK字体。
let title = ui.text("Hello Endor · 你好,Tessera", 2, "#242422")
// 同一文字控件包含中英文和重音字符,用于观察字体回退与基线。
let detail = ui.text("中文 English 123 — café", 1, "#606060")
// 两组相同尺寸色块跟在窄/宽字符后,12px gap 保持一致,差异来自真实文字测量。
let row = ui.row(0, 12, ui.text("iiii"), ui.rect(24, 20, 4, "#DCE8FF"), ui.text("WWWW"), ui.rect(24, 20, 4, "#DCE8FF"))
// 根Column统一padding和间距,render后这些对象才有具体Mount与绘图命令。
let page = ui.page()
page.render(ui.column(24, 12, title, detail, row))
+9
View File
@@ -1,13 +1,22 @@
// Function 与 Type 的契约示例:源函数、原生函数与绑定方法都可作为函数值,类型构造器保持 Type 身份。
// 参数和返回注解在真实调用边界校验,事件注册同样使用这些签名。
// Number 输入原样返回,用于说明参数类型在进入函数体前检查。
function identity(value: Number): Number { return value }
// 接受 Function 值并零参数调用,结果仍需符合本函数的 Number 返回契约。
function callNumber(callback: Function): Number { return callback() }
// 接受声明 Visual 能力的对象,返回 Block;调用时两侧契约都要成立。
function visual(value: Visual): Block { return value }
// 保留共享 state,read 作为普通绑定函数使用,click 作为接收 Event 的事件回调。
class Actions {
state: Object
// 读取模型值而不修改业务状态,保存 actions.read 时接收者已绑定。
function read(self: Actions): Number { return self.state.value }
// 事件入口可忽略 Event 内容,但参数契约仍要求它是 Event,再修改同一 state。
function click(self: Actions, event: Event): Unit { self.state.increment() }
}
state = ui.state(0)
actions = Actions(state)
// 保存绑定方法对象,之后无参调用仍使用 actions 作为 self;不是重新按字符串查找对象。
savedFunction = actions.read
initial = identity(callNumber(savedFunction))
button = Button("调用带类型检查的对象方法")
+10 -4
View File
@@ -1,12 +1,13 @@
// Record ui.stats().objects, call mountCycle(), then inspect phase.value and
// pending.value.status(). Close the tab: the referenced Block still waits.
// releaseCycle() removes both external references; the next objects query
// should return to the same baseline before the five-minute wait finishes.
// 托管对象循环与等待帧回收:先记 ui.stats().objects,再调用 mountCycle,观察 phase 与 pending.value.status()。
// 关闭临时页仍可因业务引用保留等待任务;releaseCycle 清掉外部引用后,缓存不应让整个引用环常驻。
// 比较操作前后对象数时,关闭页面只解除展示根;holder/pending 外部引用也释放后才应回到基线。
holder = ui.state(0)
pending = ui.state(0)
phase = ui.state(0)
// 通过 peer 指回包含自己的 Page,配合等待协程形成包含视觉缓存读依赖的引用环。
class CycleRect: RectBlock {
peer: Object
// sleep 归属该 rect,await 保留协程帧;先写 phase=1,只有实际恢复后才写 2 并改 Page 标题。
function wait(self: CycleRect): Unit {
gate = ui.sleep(self,"sleep",300000)
phase.set(1)
@@ -14,15 +15,18 @@ class CycleRect: RectBlock {
phase.set(2)
self.peer.title = "等待已结束"
}
// 故意读取 peer.title,让布局依赖包含循环中的 Page;缓存应借用观察,不增加长期 GC 根。
function measure(self: CycleRect, mount: Mount, constraints: Constraints): MeasureResult {
observed = self.peer.title
return MeasureResult(180,40)
}
// 绘制也读取 peer.title,检查绘制缓存与等待帧不会共同意外保活循环。
function paint(self: CycleRect, mount: Mount, geometry: Bounds): DisplayList {
observed = self.peer.title
return RectBlock.paint(self,mount,geometry)
}
}
// 创建临时 Page 和 CycleRect,相互引用后挂载,再把 Page/Task 分别放入两个外部 holder。
function mountCycle(): Unit {
transient = Page()
transient.title = "临时引用环"
@@ -32,11 +36,13 @@ function mountCycle(): Unit {
phase.set(0)
pending.set(ui.spawn(rect,"workflow",rect.wait))
}
// 先关闭临时页,随后去掉两个外部引用;等待依赖无需等满五分钟,失去可达性后应整体回收。
function releaseCycle(): Unit {
holder.value.close()
holder.set(0)
pending.set(0)
}
// 基线页面始终存在,用它比较操作前后的托管对象数,不把临时页面当作基线。
base = Page()
base.title = "GC 基线"
base.render(TextBlock("关闭临时 Page 后应回收引用环",2))
+5
View File
@@ -1,11 +1,16 @@
// 全局名称依赖示例:paint 读取 color 这个具体名字,而非依赖整个 globals 字典版本。
// Console 求值 42 仅改变最后结果 _,不应重绘;给 color 赋新值才使绘制失效。
color = "#ED6A5A"
// 保持原生几何,覆盖颜色策略,使示例只观察全局读取的依赖粒度。
class GlobalPaint: RectBlock {
// 调用原生基类产出新命令,读取当前 color 并写到新命令;不在只读阶段修改全局。
function paint(self: GlobalPaint, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self,mount,geometry)
drawing.commands.at(0).color = color
return drawing
}
}
// 显示固定大小矩形和操作提示,后续通过 Console 改同一个会话的 color。
rect = GlobalPaint(480,120,12,"#DDEEFF")
page = Page()
page.title = "全局名字依赖"
+7 -7
View File
@@ -1,10 +1,10 @@
function heading(value: Text): View {
return ui.text(value, 2, "#242422")
function heading(value: Text): View { // 辅助函数把字符串包装成大字号 TextBlock;返回值仍是托管视觉引用。
return ui.text(value, 2, "#242422") // 这里只创建文字对象,具体宽高由实际字体和出现位置约束测量。
}
function main(): View {
let title = heading("Hello Endor")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI"))
return ui.column(24, 12, title, rectangle, detail)
function main(): View { // 兼容模块入口 main 返回视觉根,宿主负责把它显示到页面,不需要源代码显式 render。
let title = heading("Hello Endor") // 调用上述辅助函数,局部变量持有同一托管文字对象。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 矩形使用逻辑尺寸,圆角与颜色由原生载荷保存。
let detail = ui.row(0, 12, ui.text("Aster VM"), ui.spacer(8), ui.text("Tessera UI")) // Row 依次摆放文字、固定间隔和文字;共享几何仍由每次出现位置承担。
return ui.column(24, 12, title, rectangle, detail) // Column 的 24px 内边距和 12px 间隔决定最终命令位置;返回后发布快照拥有所需数据。
}
+12
View File
@@ -1,32 +1,44 @@
// 测量与绘制依赖分离:改颜色应复用尺寸缓存,改模型宽度才需要重新测量。
// ViewportBand 还显式读取实际页面视口,使原生 resize 可通过依赖触发重测。
// 只负责测量需要的宽度,和绘制颜色分成两个业务对象以便观察独立失效。
class WidthModel {
width: Number
// 点击把模型宽度置为 260;源 measure 读取过该字段,缓存因此失效。
function expand(self: WidthModel, event: Event): Unit {
self.width = 260
}
}
// 保存 paint 使用的颜色,不参与 CachedRect 的测量。
class Tint {
color: String
// 只改颜色;正常情况下需重绘而不重新执行无关源测量。
function blue(self: Tint, event: Event): Unit {
self.color = "#526AC7"
}
}
// 原生矩形的尺寸/颜色策略分别由源协议覆盖,业务模型通过字段持有。
class CachedRect: RectBlock {
dimensions: WidthModel
tint: Tint
// 读取 dimensions.width 返回固定高 60;实际读观察由运行时缓存记录,不手工声明依赖表。
function measure(self: CachedRect, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(self.dimensions.width, 60)
}
// 复用原生矩形命令,再改新命令的颜色;修改新结果被允许,但 paint 不能修改已有业务对象。
function paint(self: CachedRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint.color
return drawing
}
}
// 宽度来自 ui.viewport 的当前逻辑页面区域,显示窗口变化也能成为测量依赖。
class ViewportBand: RectBlock {
// 返回可用视口宽度,最终仍服从父容器约束;外层 padding 会减少分配宽度。
function measure(self: ViewportBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(ui.viewport().width, 24)
}
}
// 创建相互独立的宽度/颜色模型并绑定两个按钮,用相同对象观察缓存与可见结果。
dimensions = WidthModel(200)
tint = Tint("#ED6A5A")
rect = CachedRect(80, 20, 0, "#ED6A5A", dimensions, tint)
+12
View File
@@ -1,24 +1,34 @@
// 测量/排列协商示例:arrange 指定子宽后需重新换行测量,高度变化继续影响父容器和后继位置,直到整树稳定。
// paragraph 被两个容器共享;每个 Mount 使用独立约束,不能把新宽度写回共享文字状态。
// 保存第一个段落出现位置的分配宽度,按钮只改策略值。
class Policy {
width: Number
// 选择 280 逻辑像素宽度,段落变高,后继文字下移。
function narrow(self: Policy, event: Event): Unit { self.width = 280 }
// 选择 560 逻辑像素宽度,段落变矮;另一个容器宽度不跟着改变。
function wide(self: Policy, event: Event): Unit { self.width = 560 }
}
// 在原生 Column 排列基础上覆盖第一个稳定子槽的宽度。
class ArrangedColumn: Column {
policy: Policy
// 先取得包含全部子槽的 Arrangement,仅改第一子项分配;保留其他槽使排列验证完整。
function arrange(self: ArrangedColumn, mount: Mount, geometry: Bounds): Arrangement {
result = Column.arrange(self, mount, geometry)
result.children.at(0).width = self.policy.width
return result
}
}
// 通过源 measure 改整个容器的期望宽度,以对比 arrange 分配路径。
class MeasuredColumn: Column {
width: Number
// 调用原生测量后修改新结果 width;这个新结果不属于已有业务对象,允许在只读计算中填写。
function measure(self: MeasuredColumn, mount: Mount, constraints: Constraints): MeasureResult {
result = Column.measure(self, mount, constraints)
result.width = self.width
return result
}
}
// 两个容器使用同一个 paragraph,分别由 Policy=280 与 measure.width=360 约束。
policy = Policy(280)
paragraph = TextBlock("源码 arrange 改变分配宽度后,文字会按实际宽度重新测量。增加的高度继续影响容器和相邻对象,直到整棵布局稳定后才发布。这里与下面使用的是同一个 TextBlock;每个 Mount 都有自己的约束与几何。同一套内存对象不需要复制业务状态,也不通过丢弃传播来结束更新。")
tail = TextBlock("这一行跟随上方段落。")
@@ -26,10 +36,12 @@ arranged = ArrangedColumn(0, 12, paragraph, tail, policy)
measured = MeasuredColumn(0, 12, paragraph, TextBlock("这一段的宽度来自源码 measure。"), 360)
narrow = Button("arrange 为 280")
wide = Button("arrange 为 560")
// 保留事件订阅引用,两个按钮调用同一 Policy 的不同方法。
narrowConnection = narrow.on("click", policy.narrow)
wideConnection = wide.on("click", policy.wide)
page = Page()
page.title = "约束协商"
page.render(Column(24, 16, Row(0, 12, narrow, wide), arranged, measured))
// 成功输入结束后宿主才消费原生窗口命令;窗口实际尺寸再回传为下一次布局约束。
ui.window().resize(850, 700)
ui.window().setTitle("测量与排列直到稳定")
+4
View File
@@ -1,5 +1,9 @@
// 与 page.endor 等价的 main 返回根入口:用于对比顶层显式 render 和兼容模块入口的绘图结果。
// 返回 Column 视觉对象,宿主选择/挂载页面;这里的 title 和 rectangle 只在 VM 内建立业务引用。
function main(): View {
// 文字内容与矩形参数和 page 示例一致,最终绘图命令应等价。
let heading = ui.text("Hello Endor", 2, "#242422")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
// 布局边距与间隔保持一致;不同 VM 的对象/Mount 身份独立,命令相同不表示共享对象。
return ui.column(24, 12, heading, rectangle)
}
+5
View File
@@ -1,9 +1,14 @@
// 托管名义类型入门:ui 构造器与 TextBlock/Button/Page 返回同一套 VM 托管对象。
// 两个文字绑定读同一 counter;原生点击和 Console 都能操作该对象并传播显示变化。
// state 提供可写 value 和 increment 方法,textOf 建立显式字段到文本的依赖。
counter = ui.state(9)
first = ui.textOf(counter, "value", 2)
second = ui.textOf(counter, "value", 2)
title = TextBlock("Endor:同一份对象,代码与 GUI 共同操作", 2)
button = Button("增加计数")
// 零参数源回调委托原生 state.increment;事件分发器根据签名调用,不强塞 Event 参数。
function increment(): Unit { counter.increment() }
// 把实际函数作为回调而非函数名文本;render 之前只创建业务对象,render 后才有出现位置。
button.onClick(increment)
page = Page()
page.render(Column(24, 16, title, Row(0, 16, first, second, button)))
+10
View File
@@ -1,23 +1,33 @@
// 方法覆盖遵守参数可更宽、返回可更具体的规则。
// WideRect 复用基类原生测量后返回 Sized 子类结果;事件改变基础 width 后按同一路径重测。
// MeasureResult 的名义子类,不增加字段,用于演示返回值协变。
class Sized: MeasureResult {}
// 用源方法显式委托原生测量,保留 Mount/Constraints 的基类契约。
class BaseRect: RectBlock {
// 原生方法按给定出现位置与约束测量,不把几何存进共享对象。
function measure(self: BaseRect, mount: Mount, constraints: Constraints): MeasureResult {
return RectBlock.measure(self, mount, constraints)
}
}
// 覆盖参数放宽为 Object,返回缩窄为 Sized;不能反过来缩窄参数或放宽返回。
class WideRect: BaseRect {
// 调用基类后复制测量数值到新的 Sized;新结果仍满足 MeasureResult 协议。
function measure(self: WideRect, mount: Object, constraints: Object): Sized {
measured = BaseRect.measure(self, mount, constraints)
return Sized(measured.width, measured.height)
}
}
// 保存具体 WideRect 和计数器,使宽度修改和点击次数同时作为业务变化传播。
class Actions {
rect: WideRect
count: Object
// 先改 width 再递增计数,新的布局刷新会使用上述动态测量覆盖。
function widen(self: Actions): Unit {
self.rect.width = 260
self.count.increment()
}
}
// 初始矩形宽 180;按钮事件完成后应变为 260,同时文字显示计数。
rect = WideRect(180, 60, 8, "#ED6A5A")
count = ui.state(0)
actions = Actions(rect, count)
+10
View File
@@ -1,3 +1,6 @@
// 业务对象与出现位置生命周期分离:同一 child 在根中出现两次,每次都获得独立 Mount 和通知。
// 普通数据变化不会重新挂载;页面关闭时子项先卸载,父项随后卸载,旧 Mount 不因重开而复活。
// order 是统一时序,mounted/unmounted 是子项次数;latest 保存最近通知的实际 Mount。
class LifecycleLog {
order: Object
mounted: Object
@@ -8,8 +11,10 @@ class LifecycleLog {
childUnmounted: Number
latest: Object
}
// 共享文字用 log 记录两个出现位置,源钩子接收的是当前具体 Mount。
class WatchedText: TextBlock {
log: LifecycleLog
// 记录顺序和次数,保存 Mount 并修改原文字;运行时在回调后重新收敛才发布。
function onMount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment()
self.log.mounted.increment()
@@ -17,6 +22,7 @@ class WatchedText: TextBlock {
self.log.latest.set(mount)
self.setText("同一个对象,两个独立挂载位置")
}
// 记录已经脱离的出现位置;保留 Mount 可查询 mounted,但不能再当有效 bounds 使用。
function onUnmount(self: WatchedText, mount: Mount): Unit {
self.log.order.increment()
self.log.unmounted.increment()
@@ -24,17 +30,21 @@ class WatchedText: TextBlock {
self.log.latest.set(mount)
}
}
// 父容器也记录钩子序号,便于对比挂载与子先父后的卸载顺序。
class WatchedColumn: Column {
log: LifecycleLog
// 记录父挂载顺序,不修改容器结构;子项随后各自执行通知。
function onMount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment()
self.log.parentMounted = self.log.order.value
}
// 父卸载时子项卸载已完成,序列号应位于所有子项通知之后。
function onUnmount(self: WatchedColumn, mount: Mount): Unit {
self.log.order.increment()
self.log.parentUnmounted = self.log.order.value
}
}
// 多个 state 容器分别保存计数/最后 Mount,root 最后参数是源 log 字段,前面才是可变视觉子项。
log = LifecycleLog(ui.state(0), ui.state(0), ui.state(0), 0, 0, 0, 0, ui.state(0))
child = WatchedText("等待挂载", 2, log)
root = WatchedColumn(24, 16, child, child, TextBlock("挂载次数"), ui.textOf(log.mounted, "value"), TextBlock("卸载次数"), ui.textOf(log.unmounted, "value"), log)
+7
View File
@@ -1,12 +1,19 @@
// 原生回调也发布 Function 签名:state.set 接收一个 Event,increment 接收零参数。
// 同一事件注册机制按实际签名调用;label 绑定再把事件 kind 转成可显示的字符串。
// 保留最后一次收到的 Event 对象,初值 kind=waiting;不是只存一份格式化事件文本。
received = ui.state(Event("waiting"))
count = ui.state(0)
// 只读计算提取当前事件种类,作为 label.text 绑定的依赖。
function eventKind(): String { return received.value.kind }
label = TextBlock("waiting")
subscription = label.bind("text", eventKind)
withEvent = Button("传递 Event 给原生 set")
// received.set 是一参数原生方法,分发器传入实际 click Event。
firstConnection = withEvent.on("click", received.set)
withoutEvent = Button("调用零参数原生 increment")
// count.increment 的签名为零参数,分发器调用时不传多余 Event。
secondConnection = withoutEvent.on("click", count.increment)
// 把已绑定状态与两种回调放到同一页面,便于对比输入效果。
page = Page()
page.title = "原生方法签名"
page.render(Column(24, 16, TextBlock("源码和原生回调共用参数契约"), label, ui.textOf(count, "value"), withEvent, withoutEvent))
+10
View File
@@ -1,33 +1,43 @@
// 源类继承原生视觉类型:原生载荷负责基础绘制,新增字段和覆盖方法由源类提供。
// label 在树中出现多次但仍是同一对象,原生方法和源字段写入共同更新各出现位置。
// 扩展 TextBlock 的业务字段;其文本/颜色仍由原生父类存储与绘制。
class StatusText: TextBlock {
activeColor: String
// 用继承的 setText/setColor 改原对象;activeColor 是构造时追加的源字段。
function activate(self: StatusText, event: Event): Unit {
self.setText("继承的原生文本已更新")
self.setColor(self.activeColor)
}
}
// 继续继承源子类,添加 message 并覆盖事件入口,保留 TextBlock 的原生能力。
class CustomStatus: StatusText {
message: String
// 显式调用基类 setText 以示范原生未绑定方法的接收者传递,再用继承方法改颜色。
function activate(self: CustomStatus, event: Event): Unit {
TextBlock.setText(self, self.message)
self.setColor(self.activeColor)
}
}
// 按钮保存目标 Block,事件到来时把同一 Event 转交给目标的动态 activate。
class ActionButton: Button {
target: Block
// 不复制目标或创建新展示对象,直接调用目标业务方法,选择实际类型的覆盖实现。
function activate(self: ActionButton, event: Event): Unit {
self.target.activate(event)
}
}
// Page 也可增加源字段;caption 用于构建页内标题,不替代原生窗口标题。
class CustomPage: Page {
caption: String
}
// 前面位置参数供原生 TextBlock 构造,后面的颜色/消息供继承字段;所有引用都保留托管类型。
label = CustomStatus("点击按钮更新同一个自定义文本对象", 2, "#242422", "#237A45", "源码子类与原生载荷共同工作")
button = ActionButton("调用源码子类方法", label)
button.onClick(button.activate)
+8
View File
@@ -1,15 +1,22 @@
// owner 作用域决定取消时机:Page 关闭会取消其任务,隐藏 Block 和 ui 的任务可继续。
// 每个 owner 下同 key 的重复点击替换旧定时器;任务生命周期不由画面是否可见推断。
// 把 owner 与要修改的 state 分开持有,使同一个调度方法可用于三个作用域。
class Actions {
owner: Object
state: Object
// 定时任务成功交付后修改共享计数,零参数签名符合 timer 回调要求。
function complete(self: Actions): Unit { self.state.increment() }
// 每次启动 1500ms 的 load 定时器;相同 owner/key 只有最新任务继续等待。
function schedule(self: Actions, event: Event): Unit {
ui.after(self.owner, "load", 1500, self.complete)
}
}
state = ui.state(0)
// 该 Block 从未进入页面树,但全局引用仍持有它,因此它拥有的任务可执行。
hidden = TextBlock("这个 Block 没有挂载,也能拥有任务")
page = Page()
page.title = "任务生命周期"
// 三个 Action 共享一个 state,却分别归属 Page、Block、应用,便于比较关闭页面的取消边界。
pageActions = Actions(page, state)
blockActions = Actions(hidden, state)
appActions = Actions(ui, state)
@@ -20,5 +27,6 @@ pageConnection = pageButton.on("click", pageActions.schedule)
blockConnection = blockButton.on("click", blockActions.schedule)
appConnection = appButton.on("click", appActions.schedule)
page.render(Column(24, 16, TextBlock("1.5 秒后更新同一个状态对象;重复点击只保留最新任务"), ui.textOf(state, "value"), pageButton, blockButton, appButton))
// 额外应用级任务延迟 500ms,用于展示没有点击也可按 worker 空闲轮询交付。
initial = ui.after(ui, "initial", 500, appActions.complete)
ui.window().setTitle("Owned Tasks")
+4 -4
View File
@@ -1,4 +1,4 @@
let heading = ui.text("Hello Endor", 2, "#242422")
let rectangle = ui.rect(240, 80, 12, "#DCE8FF")
let myui = ui.page()
myui.render(ui.column(24, 12, heading, rectangle))
let heading = ui.text("Hello Endor", 2, "#242422") // 顶层脚本不需要 main:先创建文字对象;字号 2 和十六进制颜色传给原生文字构造器。
let rectangle = ui.rect(240, 80, 12, "#DCE8FF") // 创建固定逻辑尺寸矩形;12 为圆角半径,业务对象尚未出现在任何页面中。
let myui = ui.page() // 创建托管 Page 资源;它负责持有展示根和页面生命周期。
myui.render(ui.column(24, 12, heading, rectangle)) // 把文字和矩形放入 Column,padding=24、gap=12;render 选择此页并生成出现位置及发布帧。
+11
View File
@@ -1,27 +1,38 @@
// 普通业务对象显式实现 Presentable 后,ui.present 可调用其 present(window) 生成视觉对象。
// 生成的视图保存 source 指向业务模型,绑定和按钮继续使用原对象,展示不是业务数据副本。
// name/color 是业务字段,零参读取方法提供只读绑定,update 提供事件修改。
class Product {
name: String
color: String
// 返回名称并记录实际属性读取,后续 name 修改会使文本绑定重算。
function label(self: Product): String { return self.name }
// 只读返回颜色,绑定到新建色块的 color。
function shade(self: Product): String { return self.color }
// 点击更新原业务对象,现有绑定会同步文字和矩形,不重新调用整个 present 构造树。
function update(self: Product, event: Event): Unit {
self.name = "原业务对象已更新"
self.color = "#237A45"
}
// 在指定 Window 上下文创建文字、色块和按钮,连接原 self 的方法后返回组合根。
function present(self: Product, window: Window): Block {
title = TextBlock(self.name)
swatch = RectBlock(180,48,8,self.color)
// 绑定令牌本身不承载另一份业务状态,绑定计算仍调用同一 Product。
titleBinding = title.bind("text",self.label)
colorBinding = swatch.bind("color",self.shade)
button = Button("调用业务对象方法")
connection = button.on("click",self.update)
result = Column(24,16,title,swatch,button)
// 保留原始业务身份,便于代码从展示根回到来源;contextWindow 仅记录创建时的窗口编号。
result.source = self
result.contextWindow = window.id()
return result
}
}
// 显式声明能力并验证方法契约;只有同名 present 不会自动成为 Presentable。
ui.implement(Product,Presentable)
product = Product("普通对象的显式展示能力", "#526AC7")
// 先获得视觉根,再由 ui.open 把它挂载到页面;最后设置 Page 标题。
view = ui.present(product)
page = ui.open(view)
page.title = "Presentable"
+9
View File
@@ -1,10 +1,17 @@
// 通用属性绑定:业务 Model 的三个零参只读方法分别计算 rect.width、rect.color 和 label.text。
// 绑定在目标隐藏时也会更新;两个页面共享同一组 Block,不需要两份模型。
// width/color/label 是唯一业务来源,绑定计算只读,expand 是显式事件写入入口。
class Model {
width: Number
color: String
label: String
// 返回 Number,目标 width 属性同时做类型/尺寸验证。
function getWidth(self: Model): Number { return self.width }
// 返回颜色文本;依赖只包含实际读取的模型属性。
function getColor(self: Model): String { return self.color }
// 返回当前说明文字,供 TextBlock.text 绑定使用。
function getLabel(self: Model): String { return self.label }
// 一个事件同时改三个属性,随后绑定传播,布局与绘制在完整刷新后发布。
function expand(self: Model, event: Event): Unit {
self.width = 260
self.color = "#237A45"
@@ -14,6 +21,7 @@ class Model {
model = Model(120, "#526AC7", "点击更新共享对象")
rect = RectBlock(120, 40, 8, "#526AC7")
label = TextBlock("初始化")
// 保存三个可断开的绑定令牌;令牌不强持有隐藏目标,目标仍需由业务对象图保留。
widthBinding = rect.bind("width", model.getWidth)
colorBinding = rect.bind("color", model.getColor)
labelBinding = label.bind("text", model.getLabel)
@@ -23,6 +31,7 @@ root = Column(24, 16, label, rect, button)
page = Page()
page.title = "通用属性绑定"
page.render(root)
// 第二页面使用同一根容器,切页只更换 Mount 展示上下文;最后 activate 回第一页面。
second = Page()
second.title = "同一组 Block"
second.render(root)
+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 {
vertical: ScrollBlock
horizontal: ScrollBlock
// 只对 vertical 第一个 Mount 累加纵偏移,运行时按当前范围夹取。
function down(self: Actions): Unit { self.vertical.mounts().at(0).scrollBy(0, 60) }
// 只对 horizontal 第一个 Mount 累加横偏移,禁止轴仍保持零。
function right(self: Actions): Unit { self.horizontal.mounts().at(0).scrollBy(60, 0) }
// 分别把两个出现位置移回原点,不修改共享段落文字。
function reset(self: Actions): Unit {
self.vertical.mounts().at(0).scrollTo(0, 0)
self.horizontal.mounts().at(0).scrollTo(0, 0)
}
// 把左对象策略改为双轴,下一次求解重新计算约束和范围。
function both(self: Actions): Unit { self.vertical.axis = "both" }
}
paragraph = TextBlock("这里展示的是同一个 TextBlock。左侧只允许纵向滚动,文字会按照这个 Mount 的实际视口宽度换行;右侧只允许横向滚动,文字保留横向展开。对象的文字和业务状态只有一份,每次展示的约束、几何和滚动位置分别保存。修改滚动轴也会重新测量受影响内容,并将已经不再允许的偏移夹回零。布局传播持续到受影响部分稳定,不会按固定跳数丢弃后续更新。这里展示的是同一个 TextBlock,两个视图可以拥有不同的换行结果。")
// 纵向区域padding后内容宽为244,长文字按此有限宽换行。
vertical = ScrollBlock(260, 140, paragraph)
vertical.padding = 8
vertical.axis = "vertical"
// 横向区域沿滚动轴允许内容自然展开,所以同一文字可以比左侧更宽、更矮。
horizontal = ScrollBlock(260, 140, paragraph)
horizontal.padding = 8
horizontal.axis = "horizontal"
// 绑定四个零参数按钮回调;最终把两视图与控制区放在普通 Column/Row 中。
actions = Actions(vertical, horizontal)
down = Button("向下滚动")
down.onClick(actions.down)
+8
View File
@@ -1,10 +1,16 @@
// 同一 ScrollBlock 在两个原生窗口中展示:内容业务对象共享,滚动偏移按 Mount 独立保存。
// 滚动只改变投影/裁剪,不复制业务计数;被裁剪内容不能通过原始矩形接受点击。
// 保留一个源 paint 委托,检验源命令与原生命令应用相同滚动裁剪。
class Painted: RectBlock {
// 使用当前 geometry 交给原生基类;外层滚动变换/clip 由展示流程统一组合。
function paint(self: Painted, mount: Mount, geometry: Bounds): DisplayList {
return RectBlock.paint(self,mount,geometry)
}
}
// 三个按钮复用同一个 Action,任意可见按钮点击都递增同一 count。
class Actions {
count: Object
// 签名接受 Event,业务逻辑只需修改计数,不直接处理滚动位置。
function click(self: Actions, event: Event): Unit { self.count.increment() }
}
count = ui.state(0)
@@ -15,12 +21,14 @@ last = Button("底部按钮")
firstConnection = first.on("click",actions.click)
middleConnection = middle.on("click",actions.click)
lastConnection = last.on("click",actions.click)
// 高内容夹有三个按钮与矩形,使固定320x180 scroller产生可观察的横纵滚动范围。
body = Column(12,12,first,Painted(520,180,8,"#DCE8FF"),middle,RectBlock(520,180,8,"#E3EFE5"),last)
scroller = ScrollBlock(320,180,body)
root = Column(24,16,TextBlock("同一 Block,两处 Mount 独立滚动"),ui.textOf(count,"value"),scroller)
page = Page()
page.title = "滚动示例"
page.render(root)
// 另一窗口复用 root/scroller;它们的第二个 Mount 偏移与主窗口第一个 Mount 无关。
other = Window()
other.setTitle("Scroll Secondary")
second = Page()
+8
View File
@@ -1,23 +1,31 @@
// 自定义形状命中:对象原始矩形宽 160,但 paint 只画左侧直径 80 的圆,hitTest 也只接受圆内。
// 圆外部分可落到父容器监听器;按下与释放须经相同合法目标复核。
// 扩展矩形的绘制、命中和事件方法,用同一个 hits 状态观察交互结果。
class CircleTarget: RectBlock {
hits: Object
// point 已转换到该出现位置局部坐标;使用圆心(40,40)、半径40,而不是整个 160x80 外框。
function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool {
return point.inCircle(40, 40, 40)
}
// 把原生命令宽度缩为 80、圆角半径设为 40,得到与 hitTest 对应的圆形区域。
function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).width = 80
drawing.commands.at(0).radius = 40
return drawing
}
// 圆内点击递增自身计数并停止向祖先传播;父容器不会为同一次点击再递增。
function onEvent(self: CircleTarget, event: Event): Unit {
self.hits.increment()
event.stopPropagation()
}
}
hits = ui.state(0)
// 父监听器单独计数;它和圆形目标显示在同页,便于比较穿过透明区的输入。
background = ui.state(0)
circle = CircleTarget(160, 80, 0, "#237A45", hits)
root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value"))
// 订阅在父 Column 上,圆外有效区域可回退到父目标;源命中只读检查不执行这些业务回调。
connection = root.on("click", background.increment)
page = Page()
page.render(root)
+10
View File
@@ -1,25 +1,35 @@
// 源视觉协议的最小组合:measure 决定尺寸,arrange 决定子项位置,Metrics 是被两者读取的业务模型。
// 运行时在只读求解中跟踪这些读取,事件修改模型后重新布局;布局方法不能直接改业务字段。
// 保存宽度与左偏移;两字段只在事件回调中修改。
class Metrics {
width: Number
left: Number
// 同时改变测量和排列依赖,整帧需收敛后一起发布新尺寸/位置。
function expand(self: Metrics, event: Event): Unit {
self.width = 260
self.left = 68
}
}
// 复用原生矩形载荷,仅覆盖尺寸策略;同一 rect 放在不同父容器可得到不同测量。
class MeasuredRect: RectBlock {
// 首次测量前结构 Mount 链接已建立,因此可从 parent.block 读取当前出现位置的父策略。
// 此阶段还不能查询最终 bounds,返回值使用 MeasureResult 名义类型。
function measure(self: MeasuredRect, mount: Mount, constraints: Constraints): MeasureResult {
parent = mount.parent()
return MeasureResult(parent.block.metrics.width, 60)
}
}
// 在原生排列结果上改第一个子槽 x,其他子项仍用 Column 的堆叠规则。
class OffsetColumn: Column {
metrics: Metrics
// Arrangement 里的 x 是当前排列使用的坐标;读取 metrics.left 成为排列依赖。
function arrange(self: OffsetColumn, mount: Mount, geometry: Bounds): Arrangement {
positions = Column.arrange(self, mount, geometry)
positions.children.at(0).x = self.metrics.left
return positions
}
}
// 将原生参数与源字段传给具体子类,button 事件更新模型,Page.render 建立完整展示关系。
metrics = Metrics(200, 44)
rect = MeasuredRect(80, 20, 0, "#526AC7")
button = Button("更新布局")
+11
View File
@@ -1,15 +1,22 @@
// 源 paint 产出真正进入窗口的 DisplayList;允许构造并修改本次计算的新命令,禁止改已有业务对象。
// 同一 rect 出现两次,两份命令随同一 tint 更新;业务身份共享,几何按 Mount 分开。
// 原生矩形测量不变,仅将绘制颜色切换为源字段 tint。
class TintRect: RectBlock {
tint: String
// 从原生基类取得当前出现位置的矩形命令,再修改本次返回列表中的颜色并交回。
function paint(self: TintRect, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).color = self.tint
return drawing
}
}
// 在原生文字前增加圆角底色,背景大小使用实际分配 geometry 而不是固定猜测值。
class Badge: TextBlock {
background: String
// 先完整填写 roundedRect 命令,再取原生文字命令改成白色;列表顺序决定先画背景后画文字。
function paint(self: Badge, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
// 命令 kind 决定宿主解码所需字段,坐标/宽高来自本次 Mount,radius/color 由业务策略提供。
fill.kind = "roundedRect"
fill.x = geometry.x
fill.y = geometry.y
@@ -17,17 +24,21 @@ class Badge: TextBlock {
fill.height = geometry.height
fill.radius = 6
fill.color = self.background
// 复用原生文字塑形和换行所需参数,避免手工文字命令漏掉边界。
text = TextBlock.paint(self, mount, geometry).commands.at(0)
text.color = "#FFFFFF"
return DisplayList(ObjectList(fill, text))
}
}
// 保存目标 TintRect,onEvent 是托管事件目标方法覆盖,不额外创建另一份状态。
class RecolorButton: Button {
target: TintRect
// 事件阶段可修改已有业务字段;依赖跟踪检测 tint 变化后重绘两个出现位置。
function onEvent(self: RecolorButton, event: Event): Unit {
self.target.tint = "#526AC7"
}
}
// 两次使用 rect 是同一对象,Badge 与按钮独立;根容器只组合它们,不复制业务数据。
rect = TintRect(200, 60, 12, "#DDEEFF", "#ED6A5A")
badge = Badge("Endor 源码正在绘制这个背景", 2, "#237A45")
button = RecolorButton("通过对象方法改变绘制颜色", rect)
+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 {
width: Number
// 先保留 Column 的自然高度,再把新测量结果宽度设为字段值,最终仍由父约束协商。
function measure(self: GradeCell, mount: Mount, constraints: Constraints): MeasureResult {
size = Column.measure(self, mount, constraints)
size.width = self.width
return size
}
}
// 背景绘制由源覆盖,子项仍按普通 Column 排列与绘制。
class GradePanel: Column {
// 按本次 geometry 生成白色圆角底板;不重绘或复制子项命令,后代由统一遍历处理。
function paint(self: GradePanel, mount: Mount, geometry: Bounds): DisplayList {
fill = PaintCommand()
fill.kind = "roundedRect"
@@ -21,6 +26,7 @@ class GradePanel: Column {
return DisplayList(ObjectList(fill))
}
}
// 姓名/学号和各科成绩/状态是业务字段;数值修改受 Number 声明检查。
class Student {
name: String
number: String
@@ -29,9 +35,11 @@ class Student {
english: Number
status: String
}
// 用统一无 padding/gap 的单格容器包装任意 Block,末尾 width 是源字段构造参数。
function gradeCell(content: Block, width: Number): Block {
return GradeCell(0, 0, content, width)
}
// 姓名/学号直接取初值,成绩和状态用响应式 textOf;行末分隔线与固定列宽保持表格结构。
function gradeRow(student: Student): Block {
return Column(0, 12,
Row(0, 0,
@@ -43,6 +51,7 @@ function gradeRow(student: Student): Block {
gradeCell(ui.textOf(student, "status", 1, "#526C83"), 160)),
RectBlock(730, 1, 0, "#EDF0F3"))
}
// 保存全部/复核两棵现成子树与可替换 table,同时持有要复核的真实 Student。
class GradeCenter {
table: Column
all: Column
@@ -50,20 +59,24 @@ class GradeCenter {
student: Student
pending: Number
message: String
// 替换稳定槽0的内容为全部行,复用已有学生,不重新请求/重建数据。
function showAll(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.all)
self.message = "正在查看全部 6 位学生"
}
// 切换到预先构造的复核行;这是特定示例子树,不是动态过滤算法。
function showAttention(self: GradeCenter, event: Event): Unit {
self.table.replace(0, self.attention)
self.message = "林沐的数学成绩有一条复核记录"
}
// 写预设数学成绩68及状态,清待复核数;相关绑定在两个视图都读同一 studentLin。
function review(self: GradeCenter, event: Event): Unit {
self.student.math = 68
self.student.status = "已复核"
self.pending = 0
self.message = "已录入复核结果:林沐 · 数学 58 → 68"
}
// 恢复初始学生值/计数并显示全部列表,便于重复观察同一操作。
function reset(self: GradeCenter, event: Event): Unit {
self.student.math = 58
self.student.status = "待复核"
@@ -72,6 +85,7 @@ class GradeCenter {
self.message = "已恢复示例数据"
}
}
// 下面六条数据均本地构造;allGradeRows 与 attentionGradeRows 对 studentLin 建立不同视觉行但共享其业务值。
studentLin = Student("林沐", "2026001", 86, 58, 91, "待复核")
studentChen = Student("陈知夏", "2026002", 94, 96, 93, "已确认")
studentXu = Student("许星河", "2026003", 82, 88, 85, "已确认")
@@ -81,15 +95,18 @@ studentLu = Student("陆景行", "2026006", 79, 84, 87, "已确认")
allGradeRows = Column(0, 14, gradeRow(studentLin), gradeRow(studentChen), gradeRow(studentXu), gradeRow(studentZhou), gradeRow(studentSu), gradeRow(studentLu))
attentionGradeRows = Column(0, 14, gradeRow(studentLin))
gradeTable = Column(0, 0, allGradeRows)
// table 最初含一个稳定子槽,后续 replace(0,...) 正是切换这个槽。
gradeCenter = GradeCenter(gradeTable, allGradeRows, attentionGradeRows, studentLin, 1, "正在查看全部 6 位学生")
showAllGrades = Button("全部学生")
showAttentionGrades = Button("查看复核记录")
reviewGrade = Button("录入复核成绩")
resetGrades = Button("恢复示例数据")
// 四个回调均接受 Event,绑定到同一 GradeCenter 接收者。
showAllGrades.on("click", gradeCenter.showAll)
showAttentionGrades.on("click", gradeCenter.showAttention)
reviewGrade.on("click", gradeCenter.review)
resetGrades.on("click", gradeCenter.reset)
// 表头复用与数据行完全相同的列宽,摘要仅 pending 响应式,6/3是固定示例统计。
gradeHeader = Row(0, 0,
gradeCell(TextBlock("学生", 1, "#7A8390"), 150),
gradeCell(TextBlock("学号", 1, "#7A8390"), 120),
@@ -101,6 +118,7 @@ gradeSummary = Row(0, 40,
Column(0, 6, TextBlock("学生人数", 1, "#7A8390"), TextBlock("6", 2)),
Column(0, 6, TextBlock("考试科目", 1, "#7A8390"), TextBlock("3", 2)),
Column(0, 6, TextBlock("待复核", 1, "#7A8390"), ui.textOf(gradeCenter, "pending", 2)))
// 最终一次 render 组合摘要、控制区和表格面板;message 绑定显示最近业务操作结果。
gradesPage = Page()
gradesPage.title = "学生成绩管理中心"
gradesPage.render(Column(32, 20,
+10
View File
@@ -1,16 +1,23 @@
// 页面位置与子项插入的代码示例:Page 方法覆盖计数,Container.insert 在指定位置创建稳定子槽。
// 当前侧栏提供选择/关闭,文案中的拖放场景也可经版本化事件 API 测试;不要把它理解成已实现的侧栏拖动手势。
// 继承 Page 后观察 moveToWindow/moveTab 的真实方法调用,保留原生迁移/重排行为。
class TrackedPage: Page {
calls: Object
// 先递增 calls,再显式调用基类执行跨窗迁移;Page 身份保留,旧 Mount 上下文会替换。
function moveToWindow(self: TrackedPage, window: Window): Page {
self.calls.increment()
return Page.moveToWindow(self, window)
}
// position 是所属窗口内最终零基位置,重排不等于创建新的业务 Page。
function moveTab(self: TrackedPage, position: Number): Page {
self.calls.increment()
return Page.moveTab(self, position)
}
}
// 保存容器业务对象,按钮插入只改变其子槽顺序,已有 child 的槽与 Mount 尽量保留。
class Actions {
list: Column
// 在索引 1 前插入新 TextBlock,返回的稳定槽可供 remove 等后续操作使用。
function insert(self: Actions, event: Event): Unit {
self.list.insert(1, TextBlock("插入的新对象"))
}
@@ -20,6 +27,7 @@ list = Column(0, 8, TextBlock("第一个子项"), TextBlock("最后一个子项"
actions = Actions(list)
button = Button("在位置 1 插入 Block")
connection = button.on("click", actions.insert)
// A/C/目标页共享同一根及 list;各页激活时使用独立展示上下文,业务插入结果共享。
shared = Column(24, 16, TextBlock("拖动 Tab 重排、移入另一个窗口,或拖出独立窗口"), TextBlock("Page 方法调用次数"), ui.textOf(calls,"value"), button, list)
first = TrackedPage(calls)
first.title = "A · 共享内容"
@@ -30,6 +38,7 @@ second.render(Column(24, 16, TextBlock("这是 B,Page 身份随 Tab 移动保
third = TrackedPage(calls)
third.title = "C · 同一对象"
third.render(shared)
// 建立目标窗口并指定桌面位置,MoveTo 使用物理桌面坐标且可能受窗口管理器调整。
other = Window()
other.resize(720,520)
other.setTitle("Tab Target")
@@ -38,6 +47,7 @@ target = TrackedPage(calls)
target.title = "目标窗口"
target.render(shared)
target.open(other)
// 恢复源窗口首个页面,最后排出 resize/title/move/focus,便于手动或 API 验证。
first.activate()
ui.window().resize(800,600)
ui.window().setTitle("Tab Source")
+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.title = "共享计数"
count = ui.state(0)
@@ -6,12 +9,15 @@ increment = Button("加一")
connection = increment.on("click", count.increment)
first.render(Column(24,16,TextBlock("切换页面后,计数对象继续存在"),ui.textOf(count,"value"),increment))
// 另一页演示 Page 生命周期与快捷键;界面提示是示例文案,实际导航位于当前宿主侧栏。
second = Page()
second.title = "另一个页面"
second.render(Column(24,16,TextBlock("点击上方 Tab 切换;× 关闭页面"),TextBlock("Ctrl+Tab 切换,Cmd/Ctrl+W 关闭;代码可调用 first.open() 重开")))
// 独立内容页面仍用同一个运行时的原生类型和资源注册表。
third = Page()
third.title = "窗口与布局"
third.render(Column(24,16,TextBlock("每个 Tab 都是同一个 VM 中的 Page 对象"),RectBlock(240,80,8,"#237A45")))
// 重新激活第一页面,原生侧栏和源查询可观察相同 active 状态。
first.activate()
ui.window().setTitle("Endor Pages")
+8
View File
@@ -1,3 +1,6 @@
// 原生视觉协议直接返回托管数据:Constraints/MeasureResult、Bounds/Arrangement 和 DisplayList 都可在源码中查询。
// onMount 作为业务阶段收集结果;实际布局/绘制计算仍受只读规则约束。
// 把协议探测结果保存到普通业务字段,textOf 将其中一部分显示出来。
class VisualResults {
width: Number
height: Number
@@ -5,8 +8,10 @@ class VisualResults {
childY: Number
color: String
}
// 继承原生矩形,在自身挂载后用该 Mount 显式调用 measure/paint。
class ProbeRect: RectBlock {
results: VisualResults
// 有限最大宽 120 约束自然宽 200;另用自定义 Bounds 查询绘制颜色,把返回结果写进共享 results。
function onMount(self: ProbeRect, mount: Mount): Unit {
measured = self.measure(mount, Constraints(0, 0, 120, 80))
self.results.width = measured.width
@@ -15,14 +20,17 @@ class ProbeRect: RectBlock {
self.results.color = drawing.commands.at(0).color
}
}
// 在挂载回调中探测原生排列,复用当前结构的稳定子槽。
class ProbeColumn: Column {
results: VisualResults
// 给定绝对起点(20,30)及容器范围,原生 padding=24 使首子项位于(44,54)。
function onMount(self: ProbeColumn, mount: Mount): Unit {
positions = self.arrange(mount, Bounds(20, 30, 500, 300))
self.results.childX = positions.children.at(0).x
self.results.childY = positions.children.at(0).y
}
}
// 先创建结果容器,再将它作为源子类字段共享;root 的最后一个参数是该字段,不是视觉子项。
results = VisualResults(0, 0, 0, 0, "等待挂载")
rect = ProbeRect(200, 60, 12, "#DDEEFF", results)
root = ProbeColumn(24, 16, TextBlock("对象的原生视觉协议", 2), rect, TextBlock("约束测量宽度"), ui.textOf(results, "width"), TextBlock("绘制颜色"), ui.textOf(results, "color"), results)
+8
View File
@@ -1,17 +1,25 @@
// 托管 Window 资源与原生窗口相连:源码排出 resize/title 命令,成功刷新后由 GUI 线程执行。
// 实际可用视口再返回布局约束,原生窗口尺寸不是业务对象上凭空写入的一份几何副本。
// 保存目标 Window,两个按钮对同一资源请求不同尺寸。
class WindowActions {
window: Window
// 请求 900x600 逻辑尺寸,最终大小仍由窗口管理器决定。
function compact(self: WindowActions, event: Event): Unit {
self.window.resize(900, 600)
}
// 请求 1120x720;同次输入多次 resize 可由命令队列合并到最后值。
function wide(self: WindowActions, event: Event): Unit {
self.window.resize(1120, 720)
}
}
// 原生矩形的测量覆盖展示父约束如何随窗口变化。
class WindowBand: RectBlock {
// 取当前出现位置 maxWidth 作为期望宽度,固定高 32;不读取其他窗口的尺寸。
function measure(self: WindowBand, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(constraints.maxWidth, 32)
}
}
// ui.window 返回当前主窗口托管资源;page.open 指定同一资源,后面的 title/resize 为原生命令。
window = ui.window()
actions = WindowActions(window)
band = WindowBand(80, 32, 0, "#237A45")
+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)
leftButton = Button("共享计数 +1")
leftConnection = leftButton.on("click",count.increment)
// 先在主窗口建立页面,render 默认选择该页并挂载它的内容。
left = Page()
left.title = "主窗口页面"
left.render(Column(24,16,TextBlock("两个窗口操作同一个计数对象"),ui.textOf(count,"value"),leftButton))
// Window() 分配同 VM 中的独立窗口资源,不创建第二个运行时。
other = Window()
rightButton = Button("另一个窗口 +1")
rightConnection = rightButton.on("click",count.increment)
right = Page()
right.title = "独立窗口页面"
right.render(Column(24,16,TextBlock("关闭窗口会解除 Page 归属,保留的对象仍可重开"),ui.textOf(count,"value"),rightButton))
// 把页面放到 other,上下文更换将重建出现位置,业务 count 仍不变。
right.open(other)
// 窗口命令针对各自资源编号排队;成功输入后再交 GUI,另一窗口设置自己的尺寸。
ui.window().setTitle("Endor Main")
other.setTitle("Endor Shared")
other.resize(960,640)
+94
View File
@@ -0,0 +1,94 @@
// 静态工作台组合示例:所有指标、进度与活动记录均为演示数据,不读取任务系统或网络。
// 辅助函数返回视觉子树,最后统一 Page.render;它们是普通 Endor 函数,不是宿主特殊组件语法。
// 将整个文件作为完整输入提交;这里没有实时指标提供方,全部数据由下面的常量生成。
// 把标签、大数值、说明和色条纵向组合,参数控制文字与强调颜色。
function metric(label: Text, value: Text, note: Text, color: Text): View {
return ui.column(0, 6,
ui.text(label, 1, "#637082"),
ui.text(value, 2, "#243047"),
ui.text(note, 1, "#637082"),
ui.rect(160, 4, 2, color)
)
}
// 生成项目标题和状态,再用两个相邻矩形表示已完成/剩余长度;filled/remaining 是逻辑像素。
function project(name: Text, state: Text, detail: Text, filled: Number, remaining: Number, color: Text): View {
// 项目名和状态横排,状态颜色与进度前景相同。
let caption = ui.row(0, 12,
ui.text(name, 1, "#243047"),
ui.text(state, 1, color)
)
// 两块矩形间 gap=0 合成连续条,条宽由调用方传入的演示数值决定。
let progress = ui.row(0, 0,
ui.rect(filled, 6, 3, color),
ui.rect(remaining, 6, 3, "#E5EAF2")
)
return ui.column(0, 6,
caption,
ui.text(detail, 1, "#637082"),
progress
)
}
// 组合小圆点、时间戳、标题与说明;spacer 仅作视觉对齐,不代表真实事件时间线。
function activity(time: Text, title: Text, detail: Text, color: Text): View {
let dot = ui.column(0, 0, ui.spacer(7), ui.rect(6, 6, 3, color))
let stamp = ui.row(0, 8, dot, ui.text(time, 1, "#8490A0"))
return ui.column(0, 4,
stamp,
ui.text(title, 1, "#243047"),
ui.text(detail, 1, "#637082")
)
}
// 头部三个文本使用字号/颜色层级,后续 statistics 复用 metric 返回三组指标。
let heading = ui.column(0, 6,
ui.text("ENDOR STUDIO / WORKSPACE", 1, "#5263D6"),
ui.text("项目工作台", 2, "#243047"),
ui.text("把想法组织成对象,把对象组合成界面。", 1, "#637082")
)
let statistics = ui.row(0, 20,
metric("活跃任务", "24", "本周新增 6 项", "#6574E8"),
metric("已完成", "18", "持续推进中", "#299C82"),
metric("页面草稿", "08", "等待下一次灵感", "#D9A34A")
)
// 纵向复用 project,示例名称与比例是常量,不做进度计算或持久化。
let projects = ui.column(0, 14,
ui.text("项目进度", 1, "#243047"),
project("Aster", "进行中", "语言解析 / 编译 / 执行", 240, 60, "#6574E8"),
project("Tessera", "构建中", "桌面界面 / 布局 / 开发工具", 186, 114, "#299C82"),
project("设计文档", "草稿", "Block / Page / 对象模型", 108, 192, "#D9A34A")
)
// 三条 activity 组成静态活动区,再与项目区横排成 body。
let timeline = ui.column(0, 14,
ui.text("最近活动", 1, "#243047"),
activity("10:24", "更新字体", "中英文混排预览", "#6574E8"),
activity("09:48", "组织界面", "用函数组合不同区块", "#299C82"),
activity("09:12", "记录想法", "准备下一版页面设计", "#D9A34A")
)
let body = ui.row(0, 20, projects, timeline)
// 底部提示以细色块和文字组合,明确说明页面还未接业务交互。
let note = ui.row(0, 12,
ui.rect(4, 46, 2, "#6574E8"),
ui.column(0, 6,
ui.text("下一步:让对象参与交互", 1, "#243047"),
ui.text("当前展示为静态示例,进度条和活动记录均为演示数据。", 1, "#637082")
)
)
// 最终根统一设置 24px padding 与20px间距;创建辅助子树本身不会提前发布页面。
let page = ui.page()
page.render(ui.column(24, 20,
heading,
statistics,
ui.rect(520, 1, 0, "#DFE5EE"),
body,
note,
ui.text("DEMO DATA / 函数组合 / 中文字体 / 嵌套布局", 1, "#8490A0")
))
+68
View File
@@ -0,0 +1,68 @@
// 世界空间 UI:普通 Endor 树先在独立 UI 分辨率中布局/绘制,再作为 Panel3D 平面的纹理参与三维深度。
// 面板内还有 CanvasViewport,输入需经外层射线、面板 UV 和内层二维相机逐层转换。
// 面板内容仍是完整 Endor UI,可共享对象、绑定、源视觉协议与嵌套视口。
// 分别持有外层三维视口、面板变换和面板内二维画布,避免一次动作同时驱动多层相机。
class WorldActions {
view: Viewport3D
panel: Panel3D
canvas: CanvasViewport
// 重置三维相机位置与观察目标,不改变面板或内层画布业务状态。
function reset(self: WorldActions): Unit {
self.view.camera.x = 0
self.view.camera.y = 0.5
self.view.camera.z = 7
self.view.camera.targetX = 0
self.view.camera.targetY = 0
self.view.camera.targetZ = 0
}
// 只旋转 Panel3D 平面,面板内 UI 树仍用自己的逻辑宽高。
function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) }
// 找到画布当前 Mount,按该出现位置的有限尺寸 fitView,而非用外层三维视口大小。
function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() }
}
count = ui.state(0)
increment = Button("点击:世界里的真实按钮")
increment.onClick(count.increment)
// 二维场景卡片使用带符号世界坐标;canvas 再提供有限可见区域和独立 camera。
card = Column(10, 6, TextBlock("CanvasViewport", 1), RectBlock(180, 26, 6, "#9CB8A8"))
card.boxWidth = 240
card.x = 10
card.y = 4
canvas = CanvasViewport(280, 80, Scene2D(card))
// 普通 Column、文字、按钮和二维画布组成 UI 树,按钮仍修改同一个 count。
ui_root = Column(8, 8, TextBlock("世界空间 UI", 2), ui.textOf(count, "value", 2), increment, canvas)
ui_root.crossAlign = "stretch"
// 4x3 是三维世界尺寸,320x240 是 UI 布局/纹理分辨率;两者独立控制显示比例与清晰度。
panel = Panel3D(4, 3, ui_root)
panel.uiWidth = 320
panel.uiHeight = 240
panel.x = -1.2
panel.rotationY = 15
cube = Box3D(1.4, 1.4, 1.4, "#7B9CB7")
cube.x = 2
cube.rotationY = 25
floor = Box3D(10, 0.08, 5, "#D8DEE6")
floor.y = -1.8
// 面板与普通网格/地面共处一个深度场景,实体可遮挡面板,也阻挡被遮住的 UI 命中。
scene = Scene3D(panel, cube, floor, DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9))
scene.ambient = 0.35
// 外层 Viewport3D 作为二维布局里的 flex 子项;相机控制外部观察,而非 UI 内部滚动。
view = Viewport3D(960, 520, scene)
view.flex = 1
view.camera.y = 0.5
view.camera.z = 7
actions = WorldActions(view, panel, canvas)
reset = Button("重置摄像机")
reset.onClick(actions.reset)
turn = Button("转动 UI 面板")
turn.onClick(actions.turn)
fit = Button("适配面板内的画布")
fit.onClick(actions.fit)
// 控制按钮保持在二维页面空间,外层三维内容作为一个区域随布局伸展。
body = Column(24, 16, TextBlock("UI → 3D 世界 → UI → Canvas", 2), view, Row(0, 12, reset, turn, fit), TextBlock("空白处左键拖动旋转 3D · 中键/空格平移 · 滚轮缩放\n面板里的按钮、文本、布局和画布都由同一份 Endor 对象驱动。"))
body.flex = 1
body.crossAlign = "stretch"
page = Page()
page.title = "世界空间 UI"
page.crossAlign = "stretch"
page.render(body)
+12
View File
@@ -1,18 +1,28 @@
// 多窗口共享长段落:同一文字对象在每个 Mount 按当前宽度独立换行,后继位置跟随实际高度。
// 源子类显式委托原生 measure/paint,保留系统字体塑形、组合字符和 emoji 的行为。
// 没有独立宽度副本;约束与换行结果属于出现位置,文本内容属于共享对象。
class Paragraph: TextBlock {
// 将当前 Mount 与 Constraints 交给原生文字测量,宽度变化触发重测高度。
function measure(self: Paragraph, mount: Mount, constraints: Constraints): MeasureResult {
return TextBlock.measure(self, mount, constraints)
}
// 使用本次 geometry 画文字,和原生测量采用相同换行区域。
function paint(self: Paragraph, mount: Mount, geometry: Bounds): DisplayList {
return TextBlock.paint(self, mount, geometry)
}
}
// 保存主窗口、共享文字和原全文,四个按钮分别改窗口尺寸或改文字内容。
class Actions {
window: Window
paragraph: TextBlock
full: String
// 请求主窗口变窄;真正的 viewport 由原生尺寸回传后影响换行。
function narrow(self: Actions, event: Event): Unit { self.window.resize(800, 650) }
// 请求主窗口变宽,文字高度随之减小,不改另一窗口尺寸。
function wide(self: Actions, event: Event): Unit { self.window.resize(1200, 650) }
// 用短文本替换同一对象内容,两个窗口的文字都重新测量。
function short(self: Actions, event: Event): Unit { self.paragraph.setText("短文本:同一个对象、同一个 Mount。") }
// 恢复保存的 full 字符串,不创建新的 Paragraph。
function restore(self: Actions, event: Event): Unit { self.paragraph.setText(self.full) }
}
full = "GUI 和代码操控同一套 Aster 内存对象。这个段落同时出现在两个窗口:对象内容相同,每个 Mount 根据自己的可用宽度换行。窗口变窄,段落高度增加,后面的对象向下移动;窗口变宽,段落高度减小,后面的对象向上移动。对象身份和已有 Mount 身份保持稳定。English words wrap at word boundaries; a_long_unbroken_identifier_also_wraps_when_it_cannot_fit_on_one_line. 中文、组合字符 é 与 emoji 👩‍💻 使用同一套字体整形。再次修改文字会重新计算高度,并更新两个窗口,不需要复制业务对象。"
@@ -30,6 +40,7 @@ restoreConnection = restore.on("click", actions.restore)
page = Page()
page.title = "约束下的文字换行"
page.render(Column(24, 16, Row(0, 12, narrow, wide, short, restore), paragraph, tail))
// 新窗口有独立可用宽度,但 second.render 仍用同一 paragraph,所以两个 Mount 可有不同高度。
other = Window()
other.resize(720, 500)
other.setTitle("同一个段落 · 另一组宽度")
@@ -38,6 +49,7 @@ second = Page()
second.title = "共享 Paragraph"
second.render(Column(24, 16, paragraph))
second.open(other)
// 恢复主页面活动状态并设置原生窗口尺寸/标题/桌面位置,focus 命令在成功输入后交宿主执行。
page.activate()
ui.window().resize(800, 650)
ui.window().setTitle("文字换行与相邻布局")
+6 -9
View File
@@ -9,19 +9,16 @@ when official crates.io is unavailable. Defaults remain the official source.
The local bundle has identifier `com.entroia.tessera` and can be opened from Finder.
It is a development app, without a distribution signature or notarization.
The CI workflow only checks this script's shell syntax on Linux; native bundle
construction and window presentation are verified on macOS.
Check shell syntax with `sh -n scripts/build-macos-app.sh`; native bundle
construction and window presentation are verified locally on macOS.
Return to the [project guide](../README.md).
## Remote verification
## Local verification
[verify-ci.sh](verify-ci.sh) runs only on the Linux ces-control host. It builds the
source-independent [toolchain image](../Dockerfile.ci), mounts the checkout
read-only and retains Cargo downloads/build output in project-specific Docker
volumes. CI_CACHE_SCOPE selects main/manual or a PR cache namespace. It acquires
a host lock and removes its own container on interruption; cached volumes survive.
See [workflow details](../.gitea/workflows/README.md) for invalidation and triggers.
Build and validate with the native Rust toolchain. The project has no CI/CD
workflow or Docker build image. See the [validation commands](../README.md#validation)
for formatting, tests and Clippy; window behavior is checked in the running app.
## UML 文档渲染
+12 -1
View File
@@ -1,18 +1,27 @@
#!/bin/sh
# Native macOS build and app bundle. No Docker, mirror configuration, or tests.
# macOS 应用打包入口:构建 release 可执行文件并组装最小 Tessera.app;脚本本身不运行测试或部署。
# 执行会产生构建产物,应在已确认的 macOS 构建环境中调用。
# 任一命令失败或使用未定义变量即退出,避免在失败编译后继续打包旧文件。
set -eu
# 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。
if [ "$(uname -s)" != Darwin ]; then
echo "Build this app bundle on macOS." >&2
exit 1
fi
# 以脚本所在目录的父目录作为仓库根;清空这一次 cd 的 CDPATH,防止用户 shell 配置改变目录解析。
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
# 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。
cargo build --release --locked "$@"
# 从 Cargo 包元数据读取首个 version 值,空值由 test 拒绝,不生成缺版本的 bundle。
version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml)
test -n "$version"
# macOS bundle 约定:Contents/MacOS 保存入口二进制,Contents/Info.plist 保存系统识别信息。
bundle="$(pwd)/target/release/Tessera.app"
mkdir -p "$bundle/Contents/MacOS"
# 先写临时文件再重命名,避免复制途中将半个二进制留作 bundle 的主程序。
cp target/release/tessera "$bundle/Contents/MacOS/tessera.new"
mv -f "$bundle/Contents/MacOS/tessera.new" "$bundle/Contents/MacOS/tessera"
# 生成最小 macOS bundle 元数据;应用名、入口与高分辨率标志供系统启动/显示使用。
cat > "$bundle/Contents/Info.plist.new" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -27,5 +36,7 @@ cat > "$bundle/Contents/Info.plist.new" <<PLIST
<key>NSHighResolutionCapable</key><true/>
</dict></plist>
PLIST
# 元数据完整写入后再替换正式文件;这只是单文件替换,不表示整个 bundle 的构建具有事务回滚。
mv -f "$bundle/Contents/Info.plist.new" "$bundle/Contents/Info.plist"
# 标准输出返回最终 app 路径,便于构建调用方定位产物。
printf '%s\n' "$bundle"
+21 -8
View File
@@ -1,20 +1,22 @@
#!/usr/bin/env node
// Documentation-only tool. Install pinned packages in a separate temporary directory;
// UML_RENDER_TOOLS points there. No app dependencies or remote rendering service.
// 文档 UML 批量渲染入口:读取仓库内 .puml,在 rendered 中写入 SVG/PNG。
// 固定版本的工具包安装到独立临时目录,由 UML_RENDER_TOOLS 指定;不会修改应用依赖。
import { createRequire } from 'node:module';
import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const tools = process.env.UML_RENDER_TOOLS;
// 工具依赖放在独立目录,避免给应用运行依赖增加文档渲染包。
if (!tools) throw new Error('Set UML_RENDER_TOOLS to the documentation tool directory; see docs/architecture/uml/README.md');
// 从工具目录的 package.json 创建专用 require,所有渲染依赖均从该目录解析,不依赖运行时 cwd。
const require = createRequire(path.join(path.resolve(tools), 'package.json'));
const { createCanvas } = require('@napi-rs/canvas');
const { DOMImplementation, XMLSerializer, DOMParser } = require('@xmldom/xmldom');
const sharp = require('sharp');
// PlantUML's official JS engine uses DOM construction and Canvas text metrics.
// Supply a real XML DOM and native Canvas metrics, not approximate text widths.
// 官方 PlantUML JS 引擎依赖 DOM、Graphviz 和 Canvas 文本度量。
// 在导入引擎前补齐全局环境,让 Node 进程可执行原本面向浏览器的布局过程。
globalThis.PLANTUML_VIZ = require('@viz-js/viz');
globalThis.window = globalThis;
globalThis.XMLSerializer = XMLSerializer;
@@ -22,14 +24,17 @@ globalThis.DOMParser = DOMParser;
const document = new DOMImplementation().createDocument(null, 'root', null);
const element = document.createElement.bind(document);
const elementNS = document.createElementNS.bind(document);
// 创建只用于文本度量的 1x1 Canvas。此对象不承载最终图片像素,字体实际尺寸由原生引擎返回。
function measurementCanvas() {
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
const canvas = createCanvas(1, 1);
const context = canvas.getContext('2d');
const measure = context.measureText.bind(context);
// 包装测量结果,修正 PlantUML 用于行距的 ascent/descent;保留其他真实 metrics 字段。
context.measureText = text => {
const metrics = measure(text);
// PlantUML uses this height for line spacing. Font-wide ascent/descent
// keeps adjacent class fields from colliding when glyphs lack descenders.
// 优先使用整套字体的上下界,而非当前字符串字形界;
// 否则不带下伸部的文字可能使相邻类字段的行距过小而重叠。
return {
...metrics,
width: metrics.width,
@@ -39,10 +44,13 @@ function measurementCanvas() {
};
return canvas;
}
// 普通标签交给 XML DOM,只有 canvas 替换为可实际 measureText 的原生对象。
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
document.createElementNS = (namespace, tag) => {
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
const node = elementNS(namespace, tag);
node.style = {};
// 无浏览器布局树时只模拟当前文字节点的包围盒:字体来自 SVG 属性,y 从基线向上偏移。
node.getBBox = () => {
const context = measurementCanvas().getContext('2d');
context.font = `${node.getAttribute('font-size') || 14}px ${node.getAttribute('font-family') || 'Arial'}`;
@@ -60,26 +68,31 @@ document.body = element('body');
document.documentElement.appendChild(document.body);
globalThis.document = document;
const { renderToString } = await import(pathToFileURL(require.resolve('@plantuml/core')));
// 仓库位置由脚本 URL 确定,与调用者工作目录无关;输出目录不存在时创建。
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const source = path.join(root, 'docs/architecture/uml');
const output = path.join(source, 'rendered');
await mkdir(output, { recursive: true });
// 按文件名排序使处理顺序/日志稳定,只处理本目录的 .puml,不递归扫描产物目录。
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
const text = await readFile(path.join(source, filename), 'utf8');
// 将引擎 resolve/reject 回调适配为 Promise,逐图等待;渲染失败直接终止,不覆盖该图产物。
const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject));
const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml');
const labels = Array.from(xml.getElementsByTagName('text'), node => node.textContent).join('\n');
if (xml.documentElement.tagName !== 'svg' || /syntax error|error line|assumed diagram type|an error has occurr/i.test(labels)) {
throw new Error(`${filename}: renderer returned an error diagram\n${labels}`);
}
// Explicit opaque paper keeps arrows/labels legible in dark Markdown viewers.
// 在最前方加入不透明白纸背景,保证深色 Markdown 查看器中的箭头与文字仍可辨认。
const paper = xml.createElementNS('http://www.w3.org/2000/svg', 'rect');
for (const [key, value] of Object.entries({ x: '0', y: '0', width: '100%', height: '100%', fill: '#FFFFFF' })) paper.setAttribute(key, value);
xml.documentElement.insertBefore(paper, xml.documentElement.firstChild);
const svg = new XMLSerializer().serializeToString(xml);
// 同时保存可缩放 SVG 和通用 PNG;写完读取 PNG 元数据确认生成尺寸。
const stem = filename.slice(0, -5);
await writeFile(path.join(output, `${stem}.svg`), svg);
// High-resolution PNG is the universally visible Markdown preview; SVG keeps zoomable text.
// 以 192 DPI 光栅化为 PNG 供通用预览;SVG 同时保留矢量文字和缩放能力。
const png = sharp(Buffer.from(svg), { density: 192 });
await png.png().toFile(path.join(output, `${stem}.png`));
const metadata = await sharp(path.join(output, `${stem}.png`)).metadata();
-27
View File
@@ -1,27 +0,0 @@
#!/bin/sh
# Run only on the Linux CI host with Docker; never bake source into cache images.
set -eu
scope=${CI_CACHE_SCOPE:-manual}
case "$scope" in *[!a-zA-Z0-9_-]*|'') echo "Invalid cache scope" >&2; exit 1;; esac
root=$(pwd -P)
image=endorphin-tessera-gui-ci:rust-1.91.1
prefix=tessera-ci-rust-1-91-1-$scope
# Serialize access to each target directory, including manually started checks.
exec 9>"/tmp/$prefix.lock"
flock 9
sh -n scripts/build-macos-app.sh
# Empty context: only the toolchain and OS dependencies become image layers.
docker build -t "$image" - < Dockerfile.ci
name="$prefix-${GITHUB_RUN_ID:-$$}"
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
docker run --name "$name" --rm --init \
--mount "type=bind,src=$root,dst=/build,readonly" \
--mount "type=volume,src=$prefix-target,dst=/cargo-target" \
--mount "type=volume,src=$prefix-registry,dst=/usr/local/cargo/registry" \
--mount "type=volume,src=$prefix-git,dst=/usr/local/cargo/git" \
"$image" &
run_pid=$!
wait "$run_pid"
+8 -3
View File
@@ -1,5 +1,7 @@
# Tessera 源码导航
本体代码已补充中文模块、类型、函数与关键流程注释;建议按[注释阅读索引和逐文件说明](../docs/code-comments.md)从启动、运行时发布和视觉协议依次阅读。
整体设计和当前进展从 [架构导览](../docs/architecture/README.md) 开始;本轮每个文件的迁移对应见 [逐文件清单](../docs/architecture/file-map.md)。
`main.rs` 只负责组合启动,并保留 crate 私有模块路径别名。五组目录仍在同一个 binary crate 中,不是严格单向分层。
@@ -23,7 +25,8 @@
| [native_chrome](shell/native_chrome.rs) | macOS 原生按钮对齐和工具栏双击系统动作 |
| [sources](shell/sources.rs) | 默认断开的云文件 provider 接口及工作区 |
| [system_ui](shell/system_ui/mod.rs) | 独立宿主管理控件、交互和主题 |
| [tabs](shell/tabs.rs) | 共享标签绘制/命中、密集布局、滚动、拖放及动画 |
| [tabs](shell/tabs.rs) | 页面注册表的不可变 ID、标题和当前选择投影 |
| [sidebar](shell/sidebar.rs) | 页面/开发工具入口、收起状态与最小宿主控制区 |
## runtime
@@ -60,7 +63,8 @@
| [geometry](render/geometry.rs) | 点和矩形值 |
| [layout](render/layout.rs) | 原生测量、排列、出现几何及预算 |
| [native_visual](render/native_visual.rs) | 托管原生视觉协议、值转换和签名检查 |
| [scene](render/scene.rs) | 页面、标签、插入标记和开发工具画面合成 |
| [scene](render/scene.rs) | 页面视角、可见裁剪、侧栏和开发工具画面合成 |
| [page](render/page.rs) | 四种页面空间、约束、平移缩放及正逆坐标变换 |
| [scroll](render/scroll.rs) | 逐 Mount 偏移、嵌套滚动和剪裁投影 |
| [software_renderer](render/software_renderer.rs) | 软件光栅化、抗锯齿覆盖和嵌套剪裁 |
| [source_hit](render/source_hit.rs) | 源/原生命中协议与匹配目标 |
@@ -79,8 +83,9 @@
- HTTP 路由:`runtime/api.rs`;worker:`runtime/api/worker.rs`;在线指南:`runtime/api/guide.rs`。
- Bridge 安装:`bridge/ui_bridge/bootstrap.rs`;Block 调用:`calls.rs`;签名:`signatures.rs`;不可变树:`tree.rs`。
- 默认页:`bridge/ui_bridge/start_page.endor`;Console/Sources/Network/Settings:`shell/developer_tools/`。
- 页面模式与宿主边界见 [页面空间说明](../docs/page-surfaces.md);相机输入:`bridge/ui_bridge/page_input.rs`。
- 现有 API 和宿主测试:`runtime/api/tests.rs`、`runtime/endor_host/tests.rs`;其他模块中也保留原单元测试。
VM 和托管根只由执行 worker 操作。原生窗口收到已发布值,不收到 ObjectRef。完整 PUT 成功才接替活跃程序;Console 保留实际 VM;失败和清理语义见架构文档。
本机遵循既有检查约定:格式、离线 Clippy、原生 release 构建和人工运行检查;不执行 cargo test、自动 smoke 脚本或 Docker。已有远程检查脚本保留原位置,此次不运行或追逐 CI。
当前开发与验证在本机使用原生 Rust 工具链完成:格式、现有测试、Clippy、原生构建和人工运行检查。CI/CD 工作流与 Docker 检查入口已移除;具体命令见 [项目验证说明](../README.md#validation)。历史验收记录只代表当时实际执行的检查。
+4 -1
View File
@@ -1,2 +1,5 @@
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 注册托管类型/宿主方法,验证参数并连接业务对象、Mount、视觉协议与窗口/任务资源。
pub(crate) mod ui_bridge;
+303 -20
View File
File diff suppressed because it is too large Load Diff
+75 -2
View File
@@ -1,7 +1,12 @@
//! 安装一次 VM 域需要的宿主类型、对象和方法。类型注册表是源代码调用原生能力的入口。
//! 先建立类型与桌面基础设施,再绑定构造器、视觉协议和开发工具工厂;返回的根负责保持这些对象存活。
//!
//! Allocate and connect the managed desktop, type tables and host globals.
use super::*;
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
// 先建立 desktop/ui/tabs 并加入显式 roots,后面初始化失败时由上层运行时统一释放,避免依赖 Rust 句柄保活。
let desktop = vm.new_object()?;
roots.push(desktop);
vm.set_attr(desktop, "viewport", Value::Nil)?;
@@ -10,6 +15,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(desktop, "tabs", Value::Object(tabs))?;
let ui = vm.new_object()?;
roots.push(ui);
// 原生 Session 借用托管身份,主窗口先拥有独立 Presentation;业务对象仍全部位于传入的单一 VM。
let session = Arc::new(Mutex::new(Session {
nodes: Vec::new(),
presentations: std::collections::BTreeMap::from([(
@@ -29,6 +35,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
binding_evaluations: 0,
exiting: false,
}));
// 先分配函数占位再补签名,因为签名会引用尚未建立的名义类型;保存列表用于第二阶段替换 payload。
let mut public_functions = Vec::new();
let mut factories = Vec::new();
for (name, operation) in [
@@ -43,6 +50,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("spacer", Operation::Spacer),
("row", Operation::Row),
("column", Operation::Column),
("overlay", Operation::Overlay),
("scene2D", Operation::Scene2D),
("canvasViewport", Operation::CanvasViewport),
("camera2D", Operation::Camera2D),
("button", Operation::Button),
("page", Operation::Page),
("present", Operation::Present),
@@ -67,6 +78,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(ui, name, Value::Object(function))?;
public_functions.push((name, function, operation));
}
// Object/Type 来自 VM 类型引导,不能用普通类代替;之后所有宿主名义类型从此根体系建立。
let root_types = vm.bootstrap_types()?;
roots.extend([root_types.object, root_types.type_type]);
vm.set_attr(ui, "Object", Value::Object(root_types.object))?;
@@ -75,6 +87,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Object", root_types.object),
("Type", root_types.type_type),
];
// 类型按基类先于子类的顺序创建。None 表示抽象能力/宿主结果类型,不开放普通源构造器。
for (name, base_name, constructor_name) in [
("Trait", "Object", None),
("Function", "Object", None),
@@ -84,8 +97,27 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("RectBlock", "Block", Some("rect")),
("Spacer", "Block", Some("spacer")),
("ScrollBlock", "ContainerBlock", Some("scroll")),
("ScrollView", "ScrollBlock", Some("scroll")),
("Row", "ContainerBlock", Some("row")),
("Column", "ContainerBlock", Some("column")),
("Overlay", "ContainerBlock", Some("overlay")),
("Scene2D", "ContainerBlock", Some("scene2D")),
("CanvasViewport", "ContainerBlock", Some("canvasViewport")),
("Camera2D", "Object", Some("camera2D")),
("Vec3", "Object", Some("vec3")),
("Triangle3D", "Object", Some("triangle3D")),
("Geometry3D", "Object", Some("geometry3D")),
("Mesh3D", "Object", Some("mesh3D")),
("Box3D", "Mesh3D", Some("box3D")),
("Group3D", "Object", Some("group3D")),
("Scene3D", "Object", Some("scene3D")),
("DirectionalLight", "Object", Some("directionalLight")),
("PerspectiveCamera", "Object", Some("perspectiveCamera")),
("OrthographicCamera", "Object", Some("orthographicCamera")),
("OrbitController", "Object", Some("orbitController")),
("Viewport3D", "Block", Some("viewport3D")),
("Panel3D", "Object", Some("panel3D")),
("RayHit3D", "Object", None),
("Button", "ContainerBlock", Some("button")),
("Page", "ContainerBlock", Some("page")),
("Window", "Object", Some("window-create")),
@@ -97,6 +129,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
] {
let base = object(Some(&vm.get_attr(ui, base_name)?))?;
let constructor = match constructor_name {
// ObjectList 与 Event 有特殊的“类型首参+子类字段”构造规则,单独原生适配;其他类型共用 NativeFactory。
Some("object-list") => {
let factory = vm.allocate(ContextNativeFunction::new(
"object-list",
@@ -123,6 +156,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
TypeConstructor::NativeTyped(factory)
}
Some(constructor_name) => {
// 把构造标签映射到宿主 Operation;3D 族委托其元数据,未知标签立即失败,不静默创建错误类型。
let operation = match constructor_name {
"text" => Operation::Text,
"rect" => Operation::Rect,
@@ -130,10 +164,17 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"scroll" => Operation::Scroll,
"row" => Operation::Row,
"column" => Operation::Column,
"overlay" => Operation::Overlay,
"scene2D" => Operation::Scene2D,
"canvasViewport" => Operation::CanvasViewport,
"camera2D" => Operation::Camera2D,
"viewport3D" => Operation::Viewport3D,
"button" => Operation::Button,
"page" => Operation::Page,
"window-create" => Operation::NewWindow,
_ => return Err(invalid("unknown native type allocator").into()),
name => crate::scene3d_model::Constructor::from_function(name)
.map(Operation::SceneObject)
.ok_or(invalid("unknown native type allocator"))?,
};
let factory = vm.allocate(ContextNativeFunction::new(
constructor_name,
@@ -152,6 +193,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
}
None => TypeConstructor::Abstract,
};
// 记住实际工厂身份,类型构建完成后原地补签名,避免替换类型内指向的构造函数对象。
if let (Some(tag), TypeConstructor::NativeTyped(factory)) = (constructor_name, constructor)
{
factories.push((tag, factory));
@@ -161,6 +203,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.set_attr(ui, name, Value::Object(definition))?;
definitions.push((name, definition));
}
// 协议值采用普通托管类:Constraints/Bounds/Point 等字段有类型注解,PaintCommand/CameraGesture 后续再初始化属性。
for (name, fields) in [
(
"Constraints",
@@ -172,11 +215,13 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
("Arrangement", vec!["children"]),
("DisplayList", vec!["commands"]),
("PaintCommand", vec![]),
("CameraGesture", vec![]),
] {
let fields = fields
.into_iter()
.map(|name| endor_core::ClassField {
name: name.into(),
// children/commands 先声明 Object 接口,后续各协议解码器再要求受控列表;几何字段统一为 Number。
type_name: if matches!(name, "children" | "commands") {
"Object"
} else {
@@ -197,6 +242,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
}
// The desktop owns the registry through ordinary traced attributes. The
// Rust session only borrows handles and adds no hidden lifetime roots.
// 桌面通过普通属性拥有类型字典;后续 nominal 查询必须使用同一 desktop 中注册的精确类型身份。
let types = vm.new_dict()?;
roots.push(types);
for (name, definition) in definitions {
@@ -206,8 +252,10 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
vm.attach_type(desktop, root_types.object)?;
vm.attach_type(ui, root_types.object)?;
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
// 让后续 VM 创建的函数使用宿主 Function 名义类型,并打开源类型检查,再安装完整本机签名。
vm.set_function_type(function_type)?;
vm.set_source_type_checks(true)?;
// 以同一函数身份补完 operation_signature,不改变先前 ui 属性引用的函数对象。
for (name, function, operation) in public_functions {
vm.replace_payload(
function,
@@ -232,14 +280,24 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
"scroll" => Some(Operation::Scroll),
"row" => Some(Operation::Row),
"column" => Some(Operation::Column),
"overlay" => Some(Operation::Overlay),
"scene2D" => Some(Operation::Scene2D),
"canvasViewport" => Some(Operation::CanvasViewport),
"camera2D" => Some(Operation::Camera2D),
"viewport3D" => Some(Operation::Viewport3D),
"button" => Some(Operation::Button),
"page" => Some(Operation::Page),
"window-create" => Some(Operation::NewWindow),
"event" | "object-list" => None,
_ => return Err(invalid("unknown deferred factory").into()),
name => Some(
crate::scene3d_model::Constructor::from_function(name)
.map(Operation::SceneObject)
.ok_or(invalid("unknown deferred factory"))?,
),
};
if let Some(operation) = operation {
let options = operation_signature(vm.object_store(), desktop, operation)?;
// 外层 NativeTyped 工厂接收 Type 首参和任意候选尾参,允许子类字段;NativeFactory 用 options 再核对基构造参数。
let envelope = CallSignature {
arity: endor_core::CallArity::range(options.arity.required + 1, usize::MAX),
parameters: vec![AnnotationType::Type.into()],
@@ -248,6 +306,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
effect: options.effect,
};
let mut captures = vec![desktop];
// 参数和返回注解中的名义类型也是托管引用,必须纳入 captures,不能只把它们存进 Rust 签名结构。
captures.extend(options.references());
vm.replace_payload(
function,
@@ -263,6 +322,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
),
)?;
} else {
// 特殊构造器保留宽变参外壳,再由对应 Constructor 按类型字段数拆实参,返回准确 Event/ObjectList 名义类型。
let kind = if name == "event" {
"Event"
} else {
@@ -333,8 +393,18 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let function = object(Some(&vm.get_attr(ui, name)?))?;
vm.attach_type(function, function_type)?;
}
// 实例方法安装到对应名义类型;继承复用基类方法,TypeMethod 会验证实际接收者及参数契约。
for (type_name, name, operation) in [
("Block", "bind", Operation::Bind),
("OrbitController", "update", Operation::CameraControl),
("Mesh3D", "translate", Operation::Translate3D),
("Group3D", "translate", Operation::Translate3D),
("Panel3D", "translate", Operation::Translate3D),
("PerspectiveCamera", "translate", Operation::Translate3D),
("OrthographicCamera", "translate", Operation::Translate3D),
("Mesh3D", "rotate", Operation::Rotate3D),
("Group3D", "rotate", Operation::Rotate3D),
("Panel3D", "rotate", Operation::Rotate3D),
("Block", "mounts", Operation::Mounts),
("Block", "invalidate", Operation::Invalidate),
("Block", "onClick", Operation::OnClick),
@@ -376,12 +446,14 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
expected,
session: session.clone(),
},
// 方法 wrapper 捕获所属类型与桌面,保证分发和签名引用在方法仍存活时有效。
vec![expected, desktop],
))?;
roots.push(method);
vm.attach_type(method, function_type)?;
vm.define_method(expected, name, method)?;
}
// 类型与基础方法就绪后再装窗口、默认视觉、事件方法和能力契约,顺序满足各模块查类型/方法的前提。
crate::window::install(vm, roots, desktop, tabs)?;
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
@@ -389,6 +461,7 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?;
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
crate::protocols::install(vm, roots, desktop, ui)?;
// 每个桥接域配套自己的任务 Scheduler,再接工具入口及托管起始页工厂,最后返回可使用的 UiBridge。
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
developer_pages::install(vm, ui, &session)?;
let bridge = UiBridge { ui, session, tasks };
+142 -1
View File
@@ -1,21 +1,66 @@
//! 把 Endor 方法调用分派到对应宿主操作。这里处理构造、树修改、绑定、状态与页面能力。
//! 先检查参数和只读阶段限制,再修改托管状态;对象引用的生命周期仍交给 NativeContext 和 Aster 管理。
//!
//! Native Block operations executed on the owning VM worker.
use super::*;
impl NativeCallable for Function {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<CallSignature> {
self.signature.clone()
}
/// 接收已经剥离 receiver 的显式参数,数量沿用 Operation 的范围。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(self.operation.arity())
}
/// 统一校验数量与只读限制,再按 Operation 分派构造、树修改、页面、绑定、状态与能力调用,返回托管结果或 VM 续调用。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
use Operation::*;
let arity = self.operation.arity();
let (minimum, maximum) = (arity.required, arity.maximum);
// Function 接收已剥离 receiver 的显式参数;先校验数量,后面索引 args 才安全。
if !(minimum..=maximum).contains(&args.len()) {
return Err(invalid("incorrect UI method argument count"));
}
if matches!(self.operation, Camera2D) {
let camera = new_camera2d(cx, &self.session, self.requested_type, args)?;
return Ok(NativeResult::Return(Value::Object(camera)));
}
// 3D 构造与普通 Block 分开,requested_type 保留源子类名义身份,否则从本 desktop 取默认类型。
if let SceneObject(kind) = self.operation {
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
let definition =
self.requested_type
.unwrap_or(desktop_type(cx.store(), kind.name(), desktop)?);
let object = crate::scene3d_model::allocate(cx, desktop, definition, kind, args)?;
return Ok(NativeResult::Return(Value::Object(object)));
}
if matches!(self.operation, CameraControl) {
crate::scene3d_model::control(
cx,
self.owner
.ok_or(invalid("controller requires a receiver"))?,
object(args.first())?,
object(args.get(1))?,
)?;
return Ok(NativeResult::Return(Value::Nil));
}
if matches!(self.operation, Translate3D | Rotate3D) {
crate::scene3d_model::transform(
cx,
self.owner
.ok_or(invalid("3D transform requires a receiver"))?,
args,
matches!(self.operation, Rotate3D),
)?;
return Ok(NativeResult::Return(Value::Nil));
}
// implement 修改显式能力关系;implements 查类型,supports 查对象实际能力,不靠同名字段鸭子类型推断。
if matches!(self.operation, Implement | Implements | Supports) {
let capability = object(args.get(1))?;
let value = match self.operation {
@@ -50,6 +95,7 @@ impl NativeCallable for Function {
};
return Ok(NativeResult::Return(Value::Bool(value)));
}
// 显式失效属于模型变更,只读阶段拒绝;paint/layout/content 精度不同,后续分别清理对应缓存。
if matches!(self.operation, Invalidate) {
if cx.store().is_read_only() {
return Err(invalid(
@@ -73,6 +119,7 @@ impl NativeCallable for Function {
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
let mut affected = std::collections::HashSet::new();
// 找出解析到同一底层 Data 的所有别名,失效共享数据时不能只刷新调用者包装身份。
for weak in &session.nodes {
if let Some(block) = cx.upgrade(*weak)?
&& block::resolve(cx.store(), block)?.0 == data
@@ -105,6 +152,7 @@ impl NativeCallable for Function {
}
}
}
// 跨窗口 Mount 查询按窗口/出现 ID 稳定排序,不依赖 HashMap 遍历顺序;返回每个具体出现而非几何副本。
positions.sort_unstable_by_key(|(window, id, _)| (*window, *id));
let set = crate::mount::collection(
cx,
@@ -137,6 +185,7 @@ impl NativeCallable for Function {
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?;
// 关闭托管窗口后为 Presentation 排空 pending 并标 closing,后续刷新才安排 Mount 退役与空帧。
if let Some(view) = state.presentations.get_mut(&id) {
cx.set_attr(view.desktop, "pending", Value::Nil)?;
view.pending = Some(None);
@@ -175,6 +224,7 @@ impl NativeCallable for Function {
)?,
)));
}
// 诊断会读对象总数与宿主变化统计,禁止在只读渲染中观察并形成反馈依赖。
if matches!(self.operation, Stats) {
if cx.store().is_read_only() {
return Err(invalid(
@@ -245,6 +295,7 @@ impl NativeCallable for Function {
)?;
return Ok(NativeResult::Return(Value::Object(stats)));
}
// 状态是普通名义 Object,value 属性经 VM 追踪,set/increment 方法捕获同一对象,不额外建立原生状态副本。
if matches!(self.operation, State) {
let state = cx.new_object()?;
attach_named_type(cx, state, "Object", &self.session)?;
@@ -261,6 +312,7 @@ impl NativeCallable for Function {
let Value::Number(value) = cx.store().get_attr(owner, "value")? else {
return Err(invalid("increment requires an integer state"));
};
// increment 要求可表示为 i64 的整数且加一不溢出,不能把任意浮点状态强转整数。
let next = value
.as_i64()
.and_then(|n| n.checked_add(1))
@@ -273,6 +325,7 @@ impl NativeCallable for Function {
if matches!(self.operation, TextOf) {
let source = object(args.first())?;
let field = text(args.get(1))?;
// textOf 建立前确认字段可读取,具体字符串化与依赖记录发生在快照提取时。
cx.store().get_attr(source, &field)?;
let size = args
.get(2)
@@ -292,6 +345,7 @@ impl NativeCallable for Function {
0.0,
0.0,
);
// 保留源对象强边与字段名,不在创建时复制一个静态字符串;后续属性变化会让文字快照重算。
data.text_binding = Some((source, field));
return Ok(NativeResult::Return(Value::Object(new_block(
cx,
@@ -299,6 +353,7 @@ impl NativeCallable for Function {
&self.session,
)?)));
}
// 普通组件先组装 Data,再统一由 new_typed_block 安装 Cell、属性和名义类型;不直接写原生窗口。
let constructor = match self.operation {
Scroll => Some(Data::new(
Kind::Scroll {
@@ -346,12 +401,15 @@ impl NativeCallable for Function {
0.0,
0.0,
)),
Row | Column => Some(Data::new(
Row | Column | Overlay => Some(Data::new(
if matches!(self.operation, Row) {
Kind::Row
} else if matches!(self.operation, Overlay) {
Kind::Overlay
} else {
Kind::Column
},
// 线性容器固定前两参为 padding/gap,后续都是孩子对象;组成合法性在 Block 分配前统一检查。
args[2..]
.iter()
.map(|v| object(Some(v)))
@@ -360,6 +418,65 @@ impl NativeCallable for Function {
dimension(args.get(1))?,
)),
Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)),
Scene2D => Some(Data::new(
Kind::Scene2D,
args.iter()
.map(|value| object(Some(value)))
.collect::<Result<_, _>>()?,
0.0,
0.0,
)),
CanvasViewport => {
let mut data = Data::new(
Kind::CanvasViewport {
width: dimension(args.first())?,
height: dimension(args.get(1))?,
},
vec![object(args.get(2))?],
0.0,
0.0,
);
// 每个新二维视口默认新建独立 Camera2D;显式属性共享相机才会联动其他视口。
data.camera = Some(new_camera2d(cx, &self.session, None, &[])?);
Some(data)
}
Viewport3D => {
let desktop = self
.session
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
let definition = desktop_type(cx.store(), "PerspectiveCamera", desktop)?;
let camera = crate::scene3d_model::allocate(
cx,
desktop,
definition,
crate::scene3d_model::Constructor::Perspective,
&[],
)?;
let mut data = Data::new(
Kind::Viewport3D {
width: dimension(args.first())?,
height: dimension(args.get(1))?,
},
vec![],
0.,
0.,
);
data.camera = Some(camera);
data.scene3d = Some(object(args.get(2))?);
let definition = desktop_type(cx.store(), "OrbitController", desktop)?;
// 三维视口默认独立透视相机及 OrbitController,Scene3D 引用则来自调用者,可显式共享世界。
data.controller = Some(crate::scene3d_model::allocate(
cx,
desktop,
definition,
crate::scene3d_model::Constructor::Orbit,
&[],
)?);
Some(data)
}
// 按钮标签也是普通 TextBlock 孩子,布局和生命周期沿同一组成树,不是原生按钮硬编码文字。
Button => {
let label = new_block(
cx,
@@ -379,6 +496,7 @@ impl NativeCallable for Function {
}
_ => None,
};
// 统一检查文本等级和 UTF-8 字节量,再按 requested_type 生成实际对象,保留子类工厂身份。
if let Some(data) = constructor {
if let Kind::Text { size, value, .. } = &data.kind
&& (!(1..=8).contains(size) || value.len() > 16_384)
@@ -402,6 +520,7 @@ impl NativeCallable for Function {
.map_err(|_| invalid("UI session poisoned"))?;
(state.master, state.desktop)
};
// 非 Block 对象若显式支持 Presentable,调用其 present;已有 Block 直接进入下面的展示转换。
let capability = desktop_type(cx.store(), "Presentable", desktop)?;
if !cx
.store()
@@ -428,6 +547,7 @@ impl NativeCallable for Function {
},
vec![desktop],
))?;
// 小驱动先调用 present(value, window),再把结果交 PresentResult 验证/可选打开,所有委托共用当前 VM 预算。
let instructions = [
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0),
endor_core::Instruction::simple(endor_core::Opcode::PushNull),
@@ -460,6 +580,7 @@ impl NativeCallable for Function {
});
}
}
// ui.open 把普通结果转换为根组件,再在必要时包一层 Page 并展示;ui.present 仅转换,不自动打开页面。
if matches!(self.operation, OpenValue) {
let root = present_value(cx, &args[0], &self.session)?;
let page = if cx.store().payload::<block::Block>(root, "Block")?.page {
@@ -481,6 +602,7 @@ impl NativeCallable for Function {
&self.session,
)?)));
}
// 从这里起都是需要接收者的 Block/Page 方法,未绑定 owner 时不能继续修改任意全局对象。
let owner = self.owner.ok_or(invalid("method has no owner"))?;
if matches!(self.operation, Bind) {
let field = text(args.first())?;
@@ -498,6 +620,7 @@ impl NativeCallable for Function {
)?,
)));
}
// moveTab 重排同窗口页面索引并强制下一帧更新标签;移动的是原 Page,非新页面副本。
if matches!(self.operation, MoveTab) {
let position = usize::try_from(integer(args.first())?)
.map_err(|_| invalid("tab position out of range"))?;
@@ -515,6 +638,7 @@ impl NativeCallable for Function {
view.force_frame = true;
return Ok(NativeResult::Return(Value::Object(owner)));
}
// 接收者可能是共享转发别名,解析到最终 Data 后修改副本,最后用一次 replace_payload 写入结构边。
let (data_ref, data) = block::resolve(cx.store(), owner)?;
let mut data = data.clone();
let mut result = Value::Nil;
@@ -526,6 +650,7 @@ impl NativeCallable for Function {
return Err(invalid("text exceeds 16 KiB"));
}
*value = next;
// 直接 setText 取代旧 textOf 推导;同时更新普通 text 属性,使快照属性读取与 payload 描述保持一致。
data.text_binding = None;
cx.set_attr(owner, "text", args[0].clone())?;
}
@@ -554,8 +679,10 @@ impl NativeCallable for Function {
(data.children.len(), args.first())
};
let child = object(argument)?;
// 插入前禁止孩子图回到当前 Data,避免循环组成;position 是插入位置,返回值却是稳定 slot。
block::validate(cx.store(), child, Some(data_ref))?;
let slot = data.next_slot;
// slot 单调增加且限制到 i64 正范围,不复用已删除身份;前后插入/重排不会把其他孩子 slot 改号。
data.next_slot = slot
.checked_add(1)
.filter(|next| *next <= i64::MAX as u64)
@@ -563,6 +690,7 @@ impl NativeCallable for Function {
data.children.insert(position, Child { slot, block: child });
result = Value::Number((slot as i64).into());
} else {
// remove/replace/move 首参为稳定 slot 而非当前数组下标;只有 move 的第二参才是目标位置。
let slot = integer(args.first())?;
let index = data
.children
@@ -576,6 +704,7 @@ impl NativeCallable for Function {
Replace => {
let child = object(args.get(1))?;
block::validate(cx.store(), child, Some(data_ref))?;
// replace 保留该组成边 slot,但换实际 Block;出现键还含 Block 身份,因此可能产生新的 Mount 上下文。
data.children[index].block = child;
}
Move => {
@@ -591,6 +720,7 @@ impl NativeCallable for Function {
}
}
}
// children 查询返回 slot文本->Block 的托管字典,不把当前顺序假装成稳定 slot 连号数组。
Children => {
let result = cx.new_dict()?;
for child in data.children {
@@ -609,6 +739,7 @@ impl NativeCallable for Function {
let root = object(args.first())?;
block::validate(cx.store(), root, Some(data_ref))?;
if matches!(self.operation, Navigate) {
// 首次 navigate 把当前根纳入历史,之后新导航先截断前进分支,再追加新根,保持浏览历史语义。
if data.history.is_empty() {
if let Some(old) = data.children.first() {
data.history.push(old.block);
@@ -619,6 +750,7 @@ impl NativeCallable for Function {
data.history.push(root);
data.history_index = data.history.len() - 1;
}
// 页面内容统一保留根 slot0,render/navigate 改的是根对象;show 只记录待展示请求,后续刷新再布局。
data.children = vec![Child {
slot: 0,
block: root,
@@ -630,6 +762,7 @@ impl NativeCallable for Function {
if data.history.is_empty() {
return Ok(NativeResult::Return(Value::Nil));
}
// 后退/前进钳到历史首尾,复用已保留根对象而非重新执行旧源码。
let next = if matches!(self.operation, Back) {
data.history_index.saturating_sub(1)
} else {
@@ -642,6 +775,7 @@ impl NativeCallable for Function {
}];
show(cx, owner, &self.session)?;
}
// 分离先创建新 Window,再把同一 Page 展示到它;跨窗口出现上下文由后续展示同步重建。
Detach => {
let window = {
let mut state = self
@@ -680,10 +814,12 @@ impl NativeCallable for Function {
show_in(cx, owner, &self.session, target)?;
return Ok(NativeResult::Return(Value::Object(owner)));
}
// unmount 清内容但不关闭 Page 标签,也不直接等同于销毁整个业务对象。
Unmount => {
data.children.clear();
}
Close => {
// 关闭 Page 当场取消页面和所属 Mount 任务,即使同一输入稍后又打开它,也不能复活旧异步工作。
crate::tasks::cancel_owner(cx, owner)?;
let mut state = self
.session
@@ -728,6 +864,7 @@ impl NativeCallable for Function {
}
let handler = object(args.get(1))?;
crate::event_dispatch::validate_handler(cx.store(), handler)?;
// 订阅默认非捕获;可用 Bool 或 capture/bubble 文本明确阶段,其他值拒绝而不做真值转换。
let capture = match args.get(2) {
None | Some(Value::Bool(false)) => false,
Some(Value::Bool(true)) => true,
@@ -735,6 +872,7 @@ impl NativeCallable for Function {
Some(Value::Text(phase)) if phase == "bubble" => false,
_ => return Err(invalid("phase must be capture or bubble")),
};
// 清掉已断开连接后再计算容量,保留仍有效订阅顺序;新 Connection 拥有 handler,返回 token 仅弱引用连接。
let mut active = Vec::new();
for token in &data.subscriptions {
if cx
@@ -762,6 +900,7 @@ impl NativeCallable for Function {
data.subscriptions = active;
result = Value::Object(token);
}
// 基类事件/挂载回调默认空实现,子类可覆盖;不在这条默认路径生成额外状态。
OnEvent | OnMount | OnUnmount => return Ok(NativeResult::Return(Value::Nil)),
Click | Dispatch => {
let desktop = self
@@ -769,6 +908,7 @@ impl NativeCallable for Function {
.lock()
.map_err(|_| invalid("UI session poisoned"))?
.desktop;
// click 新建种类为 click 的 Event,dispatch 使用调用者提供事件;两者都将可选具体 Mount 交传播模块验证。
let (event, mount) = if matches!(self.operation, Click) {
let definition = desktop_type(cx.store(), "Event", desktop)?;
(
@@ -783,6 +923,7 @@ impl NativeCallable for Function {
}
_ => return Err(invalid("unsupported Block operation")),
}
// 非提前返回的结构编辑/订阅操作在末尾统一经过写屏障,触发拥有边更新和变更日志。
cx.replace_payload(data_ref, data)?;
Ok(NativeResult::Return(result))
}
+18
View File
@@ -1,22 +1,29 @@
//! 把内置开发工具和起始页接入托管 Page。工厂在所属 VM 中编译和执行,而不是在窗口线程另建业务状态。
//!
//! Developer workspaces are managed Pages, using the same roots and placement API.
use super::*;
#[derive(Debug)]
/// 工具打开能力,保存所属会话和 ui 对象;调用时校验工具名,再在该 VM 中展示页面。
struct OpenTool {
session: Shared,
ui: ObjectRef,
}
impl NativeCallable for OpenTool {
/// 仅接受一个工具名称字符串,工具类型与白名单在 invoke 检查。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 把白名单工具名转换成对应托管 Page;New tab 转交预编译工厂,其余管理页复用已有实例或创建新页。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Text(name)] = args else {
return Err(invalid("developerTools requires a tool name"));
};
// 工具名白名单包含四个管理页和特殊新标签动作,拒绝任意字符串进入工厂/工具标记路径。
if !["Console", "Sources", "Network", "Settings", "New tab"].contains(&name.as_str()) {
return Err(invalid("unknown developer tool"));
}
// 新标签交给预编译 ui.newTab 托管工厂,经 NativeResult::Invoke 回到同一 VM 执行。
if name == "New tab" {
let factory = object(Some(&cx.store().get_attr(self.ui, "newTab")?))?;
return Ok(NativeResult::Invoke {
@@ -24,6 +31,7 @@ impl NativeCallable for OpenTool {
arguments: vec![],
});
}
// 锁内只复制弱节点索引,之后逐个升级并查工具标记,不持 session 锁运行 show 的流程。
let nodes = self
.session
.lock()
@@ -34,6 +42,7 @@ impl NativeCallable for OpenTool {
if let Some(page) = cx.upgrade(weak)?
&& cx.store().get_attr(page, "_tesseraTool").ok() == Some(Value::Text(name.clone()))
{
// 已有同类工具页则激活并聚焦其所属原生窗口,不每次打开都创建重复 Page。
show(cx, page, &self.session)?;
let window = {
let state = self
@@ -51,6 +60,7 @@ impl NativeCallable for OpenTool {
return Ok(NativeResult::Return(Value::Object(page)));
}
}
// 没有现存工具页才创建空托管 Page,_tesseraTool 标记让宿主在该页面位置绘制对应管理工具。
let page = new_typed_block(
cx,
Data::new(Kind::Page, vec![], 0.0, 0.0),
@@ -63,6 +73,7 @@ impl NativeCallable for OpenTool {
Ok(NativeResult::Return(Value::Object(page)))
}
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()> {
let function = vm.allocate(ContextNativeFunction::new(
"developerTools",
@@ -72,6 +83,7 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
},
vec![ui],
))?;
// 函数 captures 保活 ui;安装属性建立拥有边后释放局部分配根,避免额外常驻根。
vm.set_attr(ui, "developerTools", Value::Object(function))?;
vm.release(function)?;
Ok(())
@@ -79,7 +91,9 @@ pub(super) fn install(vm: &mut Vm, ui: ObjectRef, session: &Shared) -> Result<()
/// Compile the bundled Endor page once per VM, in a private module namespace.
/// Its functions/classes and callbacks remain ordinary managed VM objects.
/// 编译并安装 VM 私有的起始页工厂,供新页面动作重复调用。
pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
// 源码随 binary 嵌入,每个 VM 安装时解析/编译一次;函数工厂之后重复执行不再重复编译模块。
let source = frontend::EndorReader::from_text(
"tessera:start-page.endor",
include_str!("start_page.endor"),
@@ -89,6 +103,7 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
&ast,
&source,
&compiler::CompileOptions {
// 显式声明模块可见的 ui 与宿主类型名;私有 globals 不污染用户主会话的命名空间。
globals: std::iter::once("ui")
.chain(TYPE_NAMES.iter().copied())
.map(str::to_owned)
@@ -100,6 +115,7 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
let result = (|| -> Result<()> {
bridge.install_globals(vm, globals)?;
let code = compiled.load(vm)?;
// 执行模块定义类和工厂,使用单次预算;这里不主动调用 newTab,不在安装时打开页面。
let value = vm.execute_with_limits(
code,
globals,
@@ -110,12 +126,14 @@ pub(super) fn install_start_page(vm: &mut Vm, bridge: &UiBridge) -> Result<()> {
);
vm.release(code)?;
vm.release_value(value?)?;
// 要求模块导出 newTab,将函数保存到 ui 上;该函数通过托管引用维持自己的私有全局环境。
let factory = vm
.dict_get(globals, &Value::Text("newTab".into()))?
.ok_or_else(|| anyhow::anyhow!("start page module requires newTab"))?;
vm.set_attr(bridge.ui, "newTab", factory)?;
Ok(())
})();
// 无论编译代码执行或导出查找成功与否,释放私有字典的局部根,再返回保存的处理结果。
vm.release(globals)?;
result
}
+289
View File
@@ -0,0 +1,289 @@
//! 把页面、2D 视口和 3D 视口的相机输入转换成托管对象更新。
//! 输入携带具体出现的 Mount;嵌套世界 UI 先做逆投影,避免把屏幕位移直接当成场景坐标。
//!
//! Camera mutations run on the owning VM worker, just like other page input.
use super::*;
impl UiBridge {
/// 解码指定 3D 视口的旋转/平移/缩放输入,再调用其托管相机控制器。
pub(super) fn dispatch_scene_view(
&self,
vm: &mut Vm,
input: &crate::events::Input,
) -> Result<()> {
// 锁内只读取当前发布版本与弱 Mount 身份,离开锁后才进入 VM 操作,避免持 session 锁调用用户代码。
let (weak, desktop) = {
let state = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
anyhow::ensure!(
state.published_version == input.layout_version,
"obsolete 3D camera view"
);
(
*state
.mounts
.active
.get(&input.mount)
.ok_or_else(|| anyhow::anyhow!("3D viewport unavailable"))?,
state.desktop,
)
};
let mount = vm
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("3D viewport Mount expired"))?;
let result = (|| {
// 除 session 发布版本外,再检查目标自身仍挂接、版本匹配且确为 3D 视口,拒绝旧出现操作。
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
anyhow::ensure!(
occurrence.mounted
&& occurrence.version == input.layout_version
&& occurrence.geometry.scene3d_viewport,
"obsolete 3D viewport"
);
let (_, data) = block::resolve(vm.object_store(), occurrence.block)?;
let data = block::visual_data(vm.object_store(), data)?;
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("missing 3D camera"))?;
// 显式配置 controller 才能接收 3D 手势;没有控制器的场景仍可显示,但不能自动修改相机。
let controller = data
.controller
.ok_or_else(|| anyhow::anyhow!("3D controller disabled"))?;
let node = block::snapshot(vm.object_store(), occurrence.block)?;
let inset = crate::layout::insets(&node);
let geometry = &occurrence.geometry;
let outer = geometry.view.unwrap_or_default();
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("3D camera delta required"))?;
// 缩放使用倍率而非二维位移;旋转/平移通过 logical_delta 处理嵌套二维/面板投影。
// 不可逆映射时忽略本次连续增量,不在无效坐标上继续更新相机。
let local_delta = if input.kind == "scene-zoom" {
[0., 0.]
} else {
let Some(delta) = geometry.logical_delta(input.point, delta) else {
return Ok(());
};
delta
};
// 可用锚点先映回所在视口空间;无法取得时使用内容左上角,传给控制器的是扣除 inset 的局部点。
let point = input
.point
.and_then(|p| geometry.view_point(crate::geometry::Point::new(p[0], p[1]), false))
.map(|p| outer.inverse(p))
.unwrap_or(crate::geometry::Point::new(
geometry.x + inset.left,
geometry.y + inset.top,
));
// 将输入封装为名义 CameraGesture 临时对象;不把原生 Input 或窗口指针直接交给源控制器。
let gesture = vm.new_object()?;
let result = (|| {
vm.attach_type(
gesture,
desktop_type(vm.object_store(), "CameraGesture", desktop)?,
)?;
vm.set_attr(gesture, "kind", Value::Text(input.kind.clone()))?;
for (name, value) in [
("dx", local_delta[0]),
("dy", local_delta[1]),
// factor 复用原 delta 第一项,仅缩放种类按倍率解释;dx/dy 已经转换为逻辑位移。
("factor", delta[0]),
("width", (geometry.width - inset.horizontal()).max(0.)),
("height", (geometry.height - inset.vertical()).max(0.)),
("pointX", point.x - geometry.x - inset.left),
("pointY", point.y - geometry.y - inset.top),
] {
vm.set_attr(gesture, name, crate::native_visual::value(value))?;
}
// 绑定 controller.update 保留接收者语义,再带 camera/gesture 两个显式参数进行有界调用。
let function = vm.bind_attr(controller, "update")?;
let result = match function {
Value::Object(function) => vm.call_with_limits(
function,
&[Value::Object(camera), Value::Object(gesture)],
endor_core::ExecutionLimits {
max_frames: 128,
max_instructions: 1_000_000,
},
),
_ => Err(invalid("controller.update is not callable")),
};
// 先保存调用结果再释放绑定方法,错误路径同样清理;成功返回值也不保留为业务状态。
vm.release_value(function)?;
vm.release_value(result?)?;
Ok(())
})();
// 临时手势无论控制器成功或失败均释放,然后返回先前处理结果。
vm.release(gesture)?;
result
})();
// upgrade 得到的拥有根在闭包执行后统一释放,内部任一步失败也不会遗留该临时根。
vm.release(mount)?;
result
}
/// 把具体 CanvasViewport 的位移或缩放锚点映射到其显式 Camera2D。
pub(super) fn dispatch_viewport_view(
&self,
vm: &mut Vm,
input: &crate::events::Input,
) -> Result<()> {
// 二维视口同样先校验发布版本,再从活动表捕获原 Mount,不能因目标消失自动换成另一个视口。
let weak = {
let state = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
anyhow::ensure!(
state.published_version == input.layout_version,
"obsolete viewport view"
);
*state
.mounts
.active
.get(&input.mount)
.ok_or_else(|| anyhow::anyhow!("viewport is no longer active"))?
};
let mount = vm
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("viewport Mount expired"))?;
let result = (|| {
let occurrence = vm.payload::<crate::mount::Mount>(mount, "Mount")?;
anyhow::ensure!(
occurrence.mounted
&& occurrence.version == input.layout_version
&& occurrence.geometry.canvas_viewport,
"obsolete viewport occurrence"
);
let geometry = occurrence.geometry.clone();
let block = occurrence.block;
let (_, data) = block::resolve(vm.object_store(), block)?;
let data = block::visual_data(vm.object_store(), data)?;
let camera = data
.camera
.ok_or_else(|| anyhow::anyhow!("viewport has no camera"))?;
// 读取已校验的相机快照作为本次基准;共享相机用户会通过普通属性变更观察到相同更新。
let old = crate::viewport::camera(vm.object_store(), camera)?;
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("viewport input has no delta"))?;
let (mut x, mut y, mut zoom) = (old.x, old.y, old.zoom);
if input.kind == "viewport-zoom" {
let pointer = input
.point
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
anyhow::ensure!(
geometry.contains(pointer),
"zoom anchor is outside viewport"
);
let pointer = geometry
.logical_point(pointer)
.ok_or_else(|| anyhow::anyhow!("zoom anchor cannot be projected"))?;
let node = block::snapshot(vm.object_store(), block)?;
let inset = crate::layout::insets(&node);
// 已验证指针落在出现外框还不够;扣除 inset 后必须落入实际内容盒,禁止用边框作为缩放锚点。
let local = [
pointer.x - geometry.x - inset.left,
pointer.y - geometry.y - inset.top,
];
anyhow::ensure!(
local[0] >= 0.0
&& local[1] >= 0.0
&& local[0] < geometry.width - inset.horizontal()
&& local[1] < geometry.height - inset.vertical(),
"zoom anchor is outside content viewport"
);
// 以实际钳制后的新 zoom 反求 pan,保持锚点下世界位置:old_pan + local/old_zoom = new_pan + local/new_zoom。
zoom = (old.zoom * delta[0]).clamp(0.01, 100.0);
x += local[0] / old.zoom - local[0] / zoom;
y += local[1] / old.zoom - local[1] / zoom;
} else {
let Some(delta) = geometry.logical_delta(input.point, delta) else {
return Ok(());
};
// 屏幕/父层逻辑位移先撤销外层投影,再除本视口 zoom 转为相机世界偏移,不能直接加原像素增量。
x += delta[0] / zoom;
y += delta[1] / zoom;
}
// 把世界偏移限制回模型坐标预算,再走 set_attr 变更追踪,不直接改原生渲染快照。
x = x.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32);
y = y.clamp(-MAX_DIMENSION as f32, MAX_DIMENSION as f32);
vm.set_attr(camera, "x", crate::native_visual::value(x))?;
vm.set_attr(camera, "y", crate::native_visual::value(y))?;
vm.set_attr(camera, "zoom", crate::native_visual::value(zoom))?;
Ok(())
})();
vm.release(mount)?;
result
}
/// 处理旧 Page 模式的兼容相机事件;独立视口使用各自的输入路径。
pub(super) fn dispatch_page_view(
&self,
vm: &mut Vm,
input: &crate::events::Input,
) -> Result<()> {
// 兼容 Page 相机不通过 Mount 定位,而是读取当前活动页面及其 Surface 副本;发布版本仍必须一致。
let (page, mut surface, viewport) = {
let state = self
.session
.lock()
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
anyhow::ensure!(
state.published_version == input.layout_version,
"obsolete page view"
);
(
state
.active
.ok_or_else(|| anyhow::anyhow!("no active Page"))?,
state.surface.clone(),
state.layout.viewport,
)
};
let delta = input
.delta
.ok_or_else(|| anyhow::anyhow!("page view requires delta"))?;
if input.kind == "page-zoom" {
anyhow::ensure!(
surface.mode == crate::page::Mode::Canvas,
"zoom requires canvas mode"
);
let pointer = input
.point
.ok_or_else(|| anyhow::anyhow!("zoom requires anchor"))?;
let pointer = crate::geometry::Point::new(pointer[0], pointer[1]);
anyhow::ensure!(
pointer.x >= 0.0
&& pointer.y >= 0.0
&& viewport.is_none_or(|v| pointer.x < v.width && pointer.y < v.height),
"zoom anchor is outside viewport"
);
// 先保留旧缩放下锚点对应的世界点,再调整 zoom 并解新 pan,避免缩放时内容从光标下漂移。
let world = surface.to_world(pointer);
surface.zoom = (surface.zoom * delta[0]).clamp(0.1, 4.0);
surface.pan = crate::geometry::Point::new(
world.x - pointer.x / surface.zoom,
world.y - pointer.y / surface.zoom,
);
} else {
surface.pan.x += delta[0] / surface.zoom;
surface.pan.y += delta[1] / surface.zoom;
}
// 有限页面按当前内容/视口钳制;Window 会重置导航,Canvas 则保留自由 pan/zoom。
if let Some(viewport) = viewport {
surface.clamp(viewport);
}
let limit = MAX_DIMENSION as f32;
surface.pan.x = surface.pan.x.clamp(-limit, limit);
surface.pan.y = surface.pan.y.clamp(-limit, limit);
// 最终回写活动 Page 的普通托管属性,后续刷新重新提取 Surface;此函数不直接发布原生帧。
vm.set_attr(page, "panX", crate::native_visual::value(surface.pan.x))?;
vm.set_attr(page, "panY", crate::native_visual::value(surface.pan.y))?;
vm.set_attr(page, "zoom", crate::native_visual::value(surface.zoom))?;
Ok(())
}
}
+75 -1
View File
@@ -1,6 +1,10 @@
//! 声明宿主方法在 Endor 类型系统中的参数和返回契约。
//! 签名用于构造/调用检查,应与 calls 和各 NativeCallable 实现保持一致;这里不执行实际业务操作。
//!
//! Canonical contracts for native Block methods and constructors.
use super::*;
/// 根据宿主方法类别提供 Endor 方法的签名,供类型与调用检查使用。
pub(super) fn method_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -10,7 +14,16 @@ pub(super) fn method_signature(
use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit};
use Operation::*;
let nominal = |name| desktop_type(store, name, desktop).map(Nominal);
// 先描述显式实参,不含隐式 receiver;后面统一在 parameters 首部补接收者。
// ReadOnly/Mutating/External 是 VM 调用效果契约,不是线程调度策略。
let (arguments, returns, effect) = match operation {
Translate3D | Rotate3D => (vec![Number, Number, Number], Unit, CallEffect::Mutating),
CameraControl => (
vec![nominal("Object")?, nominal("CameraGesture")?],
Unit,
CallEffect::Mutating,
),
// 绑定创建可取消 Subscription,并改写目标连接关系,因此属于 Mutating。
Bind => (
vec![TextType, Function],
nominal("Subscription")?,
@@ -20,6 +33,7 @@ pub(super) fn method_signature(
Children => (vec![], Any, CallEffect::ReadOnly),
Invalidate => (vec![TextType], Unit, CallEffect::Mutating),
SetText | SetColor => (vec![TextType], Unit, CallEffect::Mutating),
// append/insert 返回稳定 slot 数值;remove/replace/move 返回 Unit,调用方不能把它们当返回孩子对象的集合方法。
Append => (vec![nominal("Block")?], Number, CallEffect::Mutating),
Insert => (
vec![Number, nominal("Block")?],
@@ -30,7 +44,9 @@ pub(super) fn method_signature(
Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating),
Move => (vec![Number, Number], Unit, CallEffect::Mutating),
Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating),
// 链式 API 返回接收者名义类型,保留子类返回契约,而不是统一降为 Block。
OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating),
// 事件和生命周期方法可能执行任意用户回调,使用 External,不能冒充只读视觉查询。
OnEvent => (vec![nominal("Event")?], Unit, CallEffect::External),
OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::External),
Click => (
@@ -61,6 +77,7 @@ pub(super) fn method_signature(
),
_ => return Err(invalid("unsupported native method signature")),
};
// Operation 维护显式参数的必需/最大个数;方法签名整体加一,将 receiver 纳入调用约定。
let arity = operation.arity();
let mut parameters = vec![Nominal(receiver)];
parameters.extend(arguments);
@@ -71,6 +88,8 @@ pub(super) fn method_signature(
returns: returns.into(),
effect,
};
// 少数参数/返回需要联合或可空类型,先用占位 Any 建基础签名,再按下标细化。
// On 第三个显式选项位于 parameters[3],可接受 Bool 或字符串。
match operation {
Open => signature.parameters[1] = CallType::nullable(nominal("Window")?),
PageWindow => signature.returns = CallType::nullable(nominal("Window")?),
@@ -80,6 +99,7 @@ pub(super) fn method_signature(
Ok(signature)
}
/// 将具体宿主 Operation 映射到参数/返回类型契约。
pub(super) fn operation_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -88,6 +108,7 @@ pub(super) fn operation_signature(
use AnnotationType::{Any, Bool, Number, String as TextType, Trait, Type, Unit};
use Operation::*;
let nominal = |name| desktop_type(store, name, desktop).map(AnnotationType::Nominal);
// 自由 UI 操作没有隐式 receiver;同时声明固定参数、可变尾参、返回名义类型和调用效果。
let (parameters, variadic, returns, effect) = match operation {
IsInstance => (vec![Any, Type], None, Bool, CallEffect::ReadOnly),
IsSubtype => (vec![Type, Type], None, Bool, CallEffect::ReadOnly),
@@ -95,6 +116,7 @@ pub(super) fn operation_signature(
Implements => (vec![Type, Trait], None, Bool, CallEffect::ReadOnly),
Supports => (vec![Any, Trait], None, Bool, CallEffect::ReadOnly),
OpenValue => (vec![Any], None, nominal("Page")?, CallEffect::Mutating),
// present 可能转交用户展示协议,效果交由被委托调用确定,不能声明成固定 ReadOnly。
Present => (vec![Any], None, nominal("Block")?, CallEffect::Delegated),
State => (vec![Any], None, Any, CallEffect::ReadOnly),
StateSet => (vec![Any], None, Unit, CallEffect::Mutating),
@@ -108,6 +130,7 @@ pub(super) fn operation_signature(
),
Window => (vec![], None, nominal("Window")?, CallEffect::ReadOnly),
NewWindow => (vec![], None, nominal("Window")?, CallEffect::Mutating),
// 这些构造操作返回新对象,ReadOnly 允许分配并初始化新值,但不允许因此修改已有业务对象。
TextOf => (
vec![Any, TextType, Number, TextType],
None,
@@ -133,11 +156,14 @@ pub(super) fn operation_signature(
nominal("ScrollBlock")?,
CallEffect::ReadOnly,
),
Row | Column => (
// 布局容器前两个固定参数是 Number,其后每个尾参都必须是 Block,返回类型按具体构造器选取。
Row | Column | Overlay => (
vec![Number, Number],
Some(nominal("Block")?),
nominal(if matches!(operation, Row) {
"Row"
} else if matches!(operation, Overlay) {
"Overlay"
} else {
"Column"
})?,
@@ -150,8 +176,56 @@ pub(super) fn operation_signature(
CallEffect::ReadOnly,
),
Page => (vec![], None, nominal("Page")?, CallEffect::ReadOnly),
Viewport3D => (
vec![Number, Number, nominal("Scene3D")?],
None,
nominal("Viewport3D")?,
CallEffect::ReadOnly,
),
// 3D 构造器参数来自 Kind 元数据:Number/String 映成基础注解,其余名称从本 desktop 注册表查名义类型。
SceneObject(kind) => {
let parameters = kind
.parameters()
.iter()
.map(|name| match *name {
"Number" => Ok(Number),
"String" => Ok(TextType),
name => nominal(name),
})
.collect::<Result<Vec<_>, VmError>>()?;
(
parameters,
// 3D 集合尾参使用 Object 名义约束;各具体构造操作仍需在实现中检查允许的场景对象类别。
if kind.variadic() {
Some(nominal("Object")?)
} else {
None
},
nominal(kind.name())?,
CallEffect::ReadOnly,
)
}
Scene2D => (
vec![],
Some(nominal("Block")?),
nominal("Scene2D")?,
CallEffect::ReadOnly,
),
CanvasViewport => (
vec![Number, Number, nominal("Scene2D")?],
None,
nominal("CanvasViewport")?,
CallEffect::ReadOnly,
),
Camera2D => (
vec![Number, Number, Number],
None,
nominal("Camera2D")?,
CallEffect::ReadOnly,
),
_ => return Err(invalid("unsupported UI function signature")),
};
// 最终 arity 直接沿用 Operation,参数注解与可变尾参分开保存;不在此处执行操作或分配业务对象。
Ok(CallSignature {
arity: operation.arity(),
parameters: parameters.into_iter().map(CallType::from).collect(),
+122 -57
View File
@@ -1,80 +1,145 @@
// Tessera's start page is an ordinary Endor Page, including its layout and actions.
// This module has its own globals; creating tabs does not replace user bindings.
class StartPage: Page {
function measure(self: StartPage, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(constraints.maxWidth, constraints.maxHeight)
// The page, cards and actions belong to Endor. The host only owns its sidebar.
// 默认页由真实 Endor 对象组成:宿主负责窗口和侧栏,卡片布局、绘制和按钮回调在源代码中定义。
// Note 复用 Column 的内容测量,但固定卡片宽度;paint 只提供背景,孩子仍沿正常树绘制。
class Note: Column {
// 卡片构造的最后一个子类字段保存背景色;前面的变参仍按 Column 的孩子参数处理。
tint: String
function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult {
// 沿用 Column 内容测量得到高度,但卡片宽固定为 224;不把背景圆角绘制误当布局尺寸。
measured = Column.measure(self, mount, constraints)
return MeasureResult(224, measured.height)
}
function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList {
// 只生成当前卡片背景,命令使用宿主给定 Bounds 的绝对布局位置,孩子仍由各自 paint 绘制。
fill = PaintCommand()
fill.kind = "roundedRect"
fill.x = geometry.x
fill.y = geometry.y
fill.width = geometry.width
fill.height = geometry.height
fill.radius = 14
fill.color = self.tint
// DisplayList 中的 commands 必须是受检查 ObjectList,且成员为 PaintCommand,不能返回普通字符串。
return DisplayList(ObjectList(fill))
}
}
class StartRow: Row {
function measure(self: StartRow, mount: Mount, constraints: Constraints): MeasureResult {
measured = Row.measure(self, mount, constraints)
return MeasureResult(constraints.maxWidth, measured.height)
}
}
// Quiet action rows retain native hit targets and keyboard focus without card chrome.
class StartButton: Button {
function measure(self: StartButton, mount: Mount, constraints: Constraints): MeasureResult {
return MeasureResult(360, 40)
}
function paint(self: StartButton, mount: Mount, geometry: Bounds): DisplayList {
// 继承 Button 的事件能力与内容布局,仅覆盖自身背景 paint。
class QuietButton: Button {
// 返回空绘制列表保留 Button 的交互能力,同时让这个入口只显示孩子文字。
function paint(self: QuietButton, mount: Mount, geometry: Bounds): DisplayList {
return DisplayList(ObjectList())
}
}
class StartLine: Row {
function measure(self: StartLine, mount: Mount, constraints: Constraints): MeasureResult {
measured = Row.measure(self, mount, constraints)
return MeasureResult(360, measured.height)
}
}
// 动作对象集中保存回调和 home 引用;按钮绑定方法会按托管引用关系保留动作对象。
class StartActions {
// 保存起始页面身份,子页面中的返回按钮激活同一 Page,而不是复制一份页面。
home: Page
// 窗口页面是普通有限 Column;可用区域来自宿主发布的逻辑视口。
function blank(self: StartActions, event: Event): Unit {
page = Page()
page.title = "未命名页面"
back = Button("返回开始页")
page.title = "窗口页面"
back = Button("返回画布")
// 绑定的是原 home 的 activate 方法;此回调恢复已有页身份,不重新构造主画布。
back.onClick(self.home.activate)
page.render(Column(32, 16, TextBlock("未命名页面", 2), TextBlock("一个新的 Page 对象,已加入当前窗口。"), TextBlock("用 Endor 的 page.render(...) 组织内容。"), back))
page.render(Column(56, 20, TextBlock("恰好一个窗口", 3), TextBlock("页面跟随可用区域调整。内容和工具栏由 Endor 自己组织。"), back))
}
// 延伸方向属于容器与滚动组合:Column 安排内容,有限 ScrollBlock 控制可见范围。
function vertical(self: StartActions, event: Event): Unit {
page = Page()
page.title = "纵向页面"
content = Column(48, 28, Column(0, 12, TextBlock("向下,继续生长", 3), TextBlock("纵向滚动和 Column 排版可以独立组合。")))
content.append(Note(24, 16, TextBlock("01 / 收集想法", 2), RectBlock(176, 130, 10, "#E2EADB"), TextBlock("从一个小小的念头开始。"), "#F0F3E9"))
content.append(Note(24, 16, TextBlock("02 / 连接片段", 2), RectBlock(176, 130, 10, "#E6DBEF"), TextBlock("内容保留在同一条连续的页面里。"), "#F4EEF7"))
content.append(Note(24, 16, TextBlock("03 / 慢慢展开", 2), RectBlock(176, 130, 10, "#EDDCC5"), TextBlock("追加一个子组件,内容自然延伸。"), "#FAF2E7"))
content.append(Note(24, 16, TextBlock("04 / 留一点空白", 2), TextBlock("不必把所有想法都放在第一屏。"), "#EDF1F6"))
// 固定初始视口内装完整内容;下一行限制滚动轴,flex 和页面 stretch 再让视口服从窗口分配。
view = ScrollBlock(1024, 800, content)
view.axis = "vertical"
view.flex = 1
page.crossAlign = "stretch"
// 最后提交有限视口作为 Page 内容,触发展示请求;真正的测量、绘制与原生发布由宿主刷新流程完成。
page.render(view)
}
// Row 内可继续放 Column 卡片;横向滚动不要求改变 Page 的身份或生命周期。
function horizontal(self: StartActions, event: Event): Unit {
page = Page()
page.title = "横向页面"
content = Row(48, 32, Note(24, 16, TextBlock("从左,走向右", 2), RectBlock(176, 180, 10, "#DDE8F1"), TextBlock("Row 内仍然可以包含 Column。"), "#EDF3F8"))
content.append(Note(24, 16, TextBlock("收集", 2), RectBlock(176, 210, 10, "#D7E5DA"), TextBlock("一张卡片,一个想法。"), "#EFF4EC"))
content.append(Note(24, 16, TextBlock("整理", 2), RectBlock(176, 170, 10, "#E5D9EB"), TextBlock("横向滚动,保留思考的顺序。"), "#F4EFF7"))
content.append(Note(24, 16, TextBlock("继续", 2), RectBlock(176, 230, 10, "#EFDFC7"), TextBlock("Shift + 滚轮浏览更多内容。"), "#FAF3E8"))
content.append(Note(24, 16, TextBlock("不止一屏", 2), TextBlock("右侧仍有空间。"), "#EEF1F5"))
view = ScrollBlock(1024, 800, content)
// Row 自然宽度可超出视口,只有横向滚动;卡片内部仍是 Column 的纵向排版。
view.axis = "horizontal"
view.flex = 1
page.crossAlign = "stretch"
page.render(view)
}
// 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。
function canvas(self: StartActions, event: Event): Unit {
// 每次动作都调用工厂,得到新 Page、世界树和独立相机,不复用当前页的视角状态。
page = newTab()
}
// 两个文字绑定观察同一个状态对象,示范共享业务状态与多个展示位置的区别。
function counter(self: StartActions, event: Event): Unit {
// 共享的是同一个托管状态对象;textOf 两次读取该对象 value,increment 更新后两个展示一起刷新。
count = ui.state(0)
add = Button("增加计数")
add.onClick(count.increment)
page = Page()
page.title = "共享对象"
page.render(Column(32, 16, TextBlock("一个对象,两个视图", 2), TextBlock("点击一次,两处展示同时更新。切换标签后,状态仍然保留。"), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add))
}
function compose(self: StartActions, event: Event): Unit {
label = TextBlock("对象可以被多处引用")
page = Page()
page.title = "组合页面"
page.render(Column(32, 20, TextBlock("把对象组合成界面", 2), TextBlock("Column 组织纵向内容,Row 组织横向内容。"), Row(0, 16, RectBlock(100, 64, 12, "#DCE8FF"), RectBlock(100, 64, 12, "#E3ECDC"), RectBlock(100, 64, 12, "#F3E4DB")), label, Row(0, 24, label, label)))
page.render(Column(48, 24, TextBlock("一个对象,两个视图", 2), Row(0, 32, ui.textOf(count, "value", 3), ui.textOf(count, "value", 3)), add))
}
}
// 页面工厂:先建立业务对象/动作,再建立世界组成,最后用一个有限视口显示它。
function newTab(): Page {
page = StartPage()
page.title = "新标签页"
page = Page()
page.title = "自由画布"
// 先建立引用当前 Page 的动作对象,再绑定按钮;回调中的 home 指向这一轮工厂创建的页面。
actions = StartActions(page)
blank = StartButton("+ 创建页面")
counter = StartButton("↗ 共享对象示例")
compose = StartButton("◇ 界面组合示例")
blank.on("click", actions.blank)
counter.on("click", actions.counter)
compose.on("click", actions.compose)
heading = StartLine(0, 0, TextBlock("Endor", 2, "#30343B"))
heading.mainAlign = "center"
subtitle = StartLine(0, 0, TextBlock("创建页面,或从一个示例开始。", 1, "#80858D"))
subtitle.mainAlign = "center"
newHint = StartLine(0, 0, TextBlock("新建标签页", 1, "#8A8F97"), TextBlock("⌘ T / Ctrl + T", 1, "#747B85"))
newHint.mainAlign = "space-between"
closeHint = StartLine(0, 0, TextBlock("关闭标签页", 1, "#8A8F97"), TextBlock("⌘ W / Ctrl + W", 1, "#747B85"))
closeHint.mainAlign = "space-between"
content = Column(0, 8,
heading, subtitle, Spacer(20),
blank, counter, compose, Spacer(20),
newHint, closeHint)
row = StartRow(24, 0, content)
row.mainAlign = "center"
page.mainAlign = "center"
page.render(row)
fixed = Button("窗口页面")
vertical = Button("纵向延伸")
horizontal = Button("横向延伸")
canvas = Button("新的画布")
shared = QuietButton("试试:一个对象,两个视图 ↗")
// 订阅绑定方法而非提前调用;事件触发后在原 VM 中创建/展示相应示例页。
fixed.on("click", actions.blank)
vertical.on("click", actions.vertical)
horizontal.on("click", actions.horizontal)
canvas.on("click", actions.canvas)
shared.on("click", actions.counter)
hero = Column(0, 16, TextBlock("给想法,一点空间。", 3, "#303941"), TextBlock("页面可以是一扇窗口,也可以一直延伸。", 1, "#7D858C"), Spacer(8), Row(0, 10, fixed, vertical), Row(0, 10, horizontal, canvas), shared)
// x/y 属于 Scene2D 子组件的世界位置;卡片内部依旧由普通 Row/Column 排版。
hero.x = 316
hero.y = 254
// Scene2D 只规定孩子的世界位置;加入后仍可追加普通卡片,位置不受窗口宽高限制。
world = Scene2D(hero)
left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9")
left.x = 36
left.y = 92
// 将卡片以稳定 slot 加入场景;其 x/y 在前面单独设置,不依赖追加次序推算位置。
world.append(left)
top = Note(24, 14, TextBlock("四种页面,同一个世界"), TextBlock("窗口 · 纵向 · 横向 · 自由画布", 1, "#7D858C"), "#EFF1F7")
top.x = 466
top.y = 58
world.append(top)
right = Note(24, 14, RectBlock(176, 138, 12, "#DDDAEA"), TextBlock("把片段放在一起", 2), TextBlock("布局属于组件,视角属于摄像机。"), "#F1EDF5")
right.x = 826
right.y = 190
world.append(right)
bottom = Note(24, 14, TextBlock("不止眼前这一屏", 2), TextBlock("触控板或滚轮平移\n空格拖动 / 鼠标中键拖动\n双指捏合 / ⌘ Ctrl + 滚轮缩放"), "#F7F0E5")
bottom.x = 104
bottom.y = 524
world.append(bottom)
end = Note(24, 14, TextBlock("继续向外探索", 2), RectBlock(176, 100, 10, "#CEDFE7"), TextBlock("画布上的对象仍是普通 Endor 组件。"), "#EAF1F5")
end.x = 716
end.y = 614
world.append(end)
// 视口参加有限布局并占用剩余空间;移动它的 Camera2D 不改写卡片世界位置。
view = CanvasViewport(1024, 800, world)
view.flex = 1
page.crossAlign = "stretch"
page.render(view)
return page
}
+131 -2
View File
@@ -1,22 +1,46 @@
//! 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 Mount 出现的身份不是同一概念。
//! 节点保存排版、绘制和协议投影信息;浅比较用于决定是否复用已有快照。
//!
//! Owned immutable visual derivation; no managed VM handles.
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
/// 不可变视觉节点。block_id 关联业务身份,slots 保留子项出现的稳定组成;kind 和盒模型字段用于派生排版与绘制。
pub struct UiNode {
// 只有页面根带兼容 Page 相机;其余字段是普通有限盒子的布局策略。
pub surface: Option<crate::page::Surface>,
pub flex: f32,
pub flex_policy: crate::layout::FlexPolicy,
pub main_align: crate::layout::MainAlign,
pub cross_align: crate::layout::CrossAlign,
pub align_self: Option<crate::layout::CrossAlign>,
pub margin: crate::layout::Insets,
pub sizing: crate::box_model::Sizing,
// 单边可选覆盖值;None 表示沿用 kind 的统一 padding,不代表边距为零。
pub padding_edges: [Option<f32>; 4],
pub border: crate::layout::Insets,
pub border_color: String,
pub clip_children: bool,
// 这些字符串只是派生身份/协议版本键,不是可以操作托管堆的 ObjectRef。
pub block_id: String,
pub protocol_key: String,
// slot 保持组成身份;数组次序确定当前绘制/布局顺序,二者不是同一概念。
pub slots: Vec<u64>,
// 从按钮、点击连接或自定义事件等能力推导的交互资格,供命中宽阶段使用,不代表最终 hitTest 必然成功。
pub interactive: bool,
pub kind: UiKind,
}
impl UiNode {
/// 按节点种类返回其视觉子项;叶节点使用空切片,避免为遍历另建组成。
pub fn children(&self) -> &[Arc<UiNode>] {
match &self.kind {
UiKind::Column { children, .. }
| UiKind::Overlay { children, .. }
| UiKind::Canvas { children, .. }
| UiKind::Scene2D { children, .. }
| UiKind::CanvasViewport { children, .. }
| UiKind::Viewport3D { children, .. }
| UiKind::Row { children, .. }
| UiKind::Button { children, .. }
| UiKind::Scroll { children, .. } => children,
@@ -24,14 +48,40 @@ impl UiNode {
}
}
/// 判断是否需要对子项执行弹性主轴分配。
pub fn has_flex(&self) -> bool {
self.flex > 0.0 || self.flex_policy != crate::layout::FlexPolicy::default()
// 不仅旧 flex 数值,非默认 FlexPolicy 或任一轴 Fill 也要求父节点执行分配,不能仅按自然尺寸排布。
self.flex > 0.0
|| self.flex_policy != crate::layout::FlexPolicy::default()
|| self.sizing.width == crate::box_model::Dimension::Fill
|| self.sizing.height == crate::box_model::Dimension::Fill
}
/// 判断当前节点是否会为子项指定实际分配范围,供源布局选择安排路径。
pub fn allocates_children(&self) -> bool {
// 任何孩子需要弹性或拉伸即进入分配路径;align_self 优先于父 cross_align。
self.children().iter().any(|child| {
child.has_flex()
|| child.align_self.unwrap_or(self.cross_align)
== crate::layout::CrossAlign::Stretch
})
}
/// 比较节点自身配置和直接子项身份,决定能否复用旧 Arc;不是深拷贝业务图。
pub fn shallow_eq(&self, other: &Self) -> bool {
if self.flex_policy != other.flex_policy
// 先比较所有公共视觉/协议配置,任一变化都不能复用旧节点 Arc,即使子内容完全相同。
if self.surface != other.surface
|| self.flex_policy != other.flex_policy
|| self.flex != other.flex
|| self.main_align != other.main_align
|| self.cross_align != other.cross_align
|| self.align_self != other.align_self
|| self.margin != other.margin
|| self.sizing != other.sizing
|| self.padding_edges != other.padding_edges
|| self.border != other.border
|| self.border_color != other.border_color
|| self.clip_children != other.clip_children
|| self.block_id != other.block_id
|| self.protocol_key != other.protocol_key
|| self.slots != other.slots
@@ -39,6 +89,7 @@ impl UiNode {
{
return false;
}
// 直接孩子按 Arc 指针比较而不是递归深比较;依赖提取保证未变子树复用 Arc,父级只需验证其身份。
let same_children = || {
self.children().len() == other.children().len()
&& self
@@ -47,7 +98,49 @@ impl UiNode {
.zip(other.children())
.all(|(a, b)| Arc::ptr_eq(a, b))
};
// 同类变体再比较其专有数据和直接子项;变体不同必为 false,避免布局种类切换后误复用缓存。
match (&self.kind, &other.kind) {
(
UiKind::Viewport3D {
width: a,
height: b,
view: c,
controls: g,
panels: i,
..
},
UiKind::Viewport3D {
width: d,
height: e,
view: f,
controls: h,
panels: j,
..
},
) => a == d && b == e && c == f && g == h && i == j && same_children(),
(
UiKind::CanvasViewport {
width: a,
height: b,
camera: c,
..
},
UiKind::CanvasViewport {
width: d,
height: e,
camera: f,
..
},
) => a == d && b == e && c == f && same_children(),
(UiKind::Scene2D { positions: a, .. }, UiKind::Scene2D { positions: b, .. }) => {
a == b && same_children()
}
(UiKind::Overlay { padding: a, .. }, UiKind::Overlay { padding: b, .. }) => {
a == b && same_children()
}
(UiKind::Canvas { positions: a, .. }, UiKind::Canvas { positions: b, .. }) => {
a == b && same_children()
}
(
UiKind::Text {
value: a,
@@ -123,12 +216,46 @@ impl UiNode {
}
#[derive(Debug, Clone, PartialEq)]
/// 视觉内容的原生分类。叶节点保存绘制数据,容器保存子树,场景视口另外保存相机与世界投影。
pub enum UiKind {
// 有限 3D 视口包含不可变场景视图和面板 UI 孩子;panels 与 children/slots 按顺序对应。
Viewport3D {
width: f32,
height: f32,
view: Arc<crate::scene3d::View>,
controls: bool,
panels: Vec<crate::layout::Size>,
children: Vec<Arc<UiNode>>,
},
// 二维世界组成保留孩子的显式位置,不使用行列的累计偏移定位。
Scene2D {
positions: Vec<[f32; 2]>,
children: Vec<Arc<UiNode>>,
},
// 有限布局盒子观察独立 Scene2D,相机仅影响投影,不改变场景节点原始逻辑位置。
CanvasViewport {
width: f32,
height: f32,
camera: crate::viewport::CameraView,
children: Vec<Arc<UiNode>>,
},
// 孩子重叠布置,顺序同时决定叠放次序;普通有限布局与世界 Canvas 不同。
Overlay {
padding: f32,
children: Vec<Arc<UiNode>>,
},
// 旧 Page Canvas 模式的兼容投影,位置数组与孩子顺序一致,页面级 Surface 保存其相机。
Canvas {
positions: Vec<[f32; 2]>,
children: Vec<Arc<UiNode>>,
},
// 按钮是有子内容的容器,不把标签文本硬编码进 UiKind;交互资格另由 UiNode 标志表达。
Button {
padding: f32,
gap: f32,
children: Vec<Arc<UiNode>>,
},
// 文字叶节点保留源字号等级与颜色文本;排版及最终设备比例由后续布局/显示列表转换处理。
Text {
value: String,
size: u32,
@@ -150,7 +277,9 @@ pub enum UiKind {
gap: f32,
children: Vec<Arc<UiNode>>,
},
// 无视觉内容的间隔叶节点,提供布局占用尺寸。
Spacer(f32),
// 滚动盒保存有限分配尺寸、允许轴和普通内容;实际偏移按 Mount 出现分别维护,不放到共享节点里。
Scroll {
axis: crate::scroll::Axis,
width: f32,
+16 -3
View File
@@ -1,3 +1,6 @@
//! 应用组合入口。先创建原生事件循环和共享发布状态,再启动 API/VM worker,最后把窗口事件交给 TesseraApp。
//! VM 操作由运行时线程串行处理;窗口线程只消费发布文档与纯数据通知。crate 私有别名用于保持模块内部引用路径稳定。
//!
//! Application composition root. Directory modules own implementation; these
//! crate-private aliases keep existing internal paths stable during reorganization.
mod bridge;
@@ -7,19 +10,23 @@ mod runtime;
mod shell;
pub(crate) use bridge::ui_bridge;
// 模型层负责从 VM 场景对象提取快照,渲染层 scene3d 负责原生计算;别名区分两者。
pub(crate) use model::scene3d as scene3d_model;
pub(crate) use model::{
binding, block, document, mount, object_list, presentation, protocols, snapshot, window,
};
pub(crate) use render::{
display_list, geometry, layout, native_visual, scene, scroll, software_renderer, source_hit,
source_layout, source_paint, text,
box_model, display_list, geometry, layout, native_visual, page, scene, scene3d, scroll,
software_renderer, source_hit, source_layout, source_paint, text, viewport,
};
pub(crate) use runtime::{
api, endor_host, endor_script, event_dispatch, events, io_tasks, tasks, vm_sequence,
};
#[cfg(target_os = "macos")]
pub(crate) use shell::native_chrome;
pub(crate) use shell::{activity, app, clipboard, developer_tools, sources, system_ui, tabs};
pub(crate) use shell::{
activity, app, clipboard, developer_tools, sidebar, sources, system_ui, tabs,
};
use std::sync::{Arc, RwLock};
@@ -28,12 +35,18 @@ use app::{AppEvent, TesseraApp};
use document::NamespaceState;
use winit::event_loop::{ControlFlow, EventLoop};
/// 按顺序建立事件循环、共享文档、API/VM worker 和应用窗口;事件循环结束后返回原生退出清理结果。
fn main() -> Result<()> {
// 原生窗口由事件循环所属线程驱动;Wait 让空闲时休眠,而不是持续重绘。
let event_loop = EventLoop::<AppEvent>::with_user_event().build()?;
event_loop.set_control_flow(ControlFlow::Wait);
// 共享最近完整发布的文档。后台 worker 拥有 VM,GUI 不借用其托管堆。
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
// worker 发布完整 NamespaceState,activity 供 Network 工具观察,control 用于原生窗口发运行时控制请求。
let (activity, control) = api::start(namespace.clone(), event_loop.create_proxy())?;
// 两个 proxy 指向同一事件循环;后台线程通过纯数据通知唤醒 GUI,不直接操作原生窗口。
let mut app = TesseraApp::new(namespace, activity, event_loop.create_proxy(), control)?;
// run_app 接管线程直至退出;退出后仍需检查清理过程中记录的错误。
event_loop.run_app(&mut app)?;
app.exit_result()
}
+34 -2
View File
@@ -1,3 +1,6 @@
//! 属性绑定:在只读计算中收集依赖,将结果写回绑定目标。
//! 缓存记录读取版本而不是复制业务模型;依赖变化才重新计算。托管连接对象追踪其拥有的引用。
//!
//! Target-owned managed bindings. Dependency versions are borrowed identities;
//! the host's weak Block registry never roots an otherwise unreachable graph.
use std::{
@@ -13,27 +16,38 @@ use crate::{block, event_dispatch::Subscription, ui_bridge::attach_desktop_type}
/// The output value is borrowed for equality only, including object handles.
/// It never owns a reference or dereferences a possibly reclaimed cached object.
#[derive(Debug, Clone)]
/// 上次绑定计算的读取观察,用于判断是否需要重新执行源计算。
struct Cached {
// 每项保存读取对象/属性的版本观察;刷新时向对象存储核对,而非复制业务值。
reads: Vec<endor_core::ReadDependency>,
// 只借用上次结果的身份做相等比较;不能解引用其中可能已被回收的对象。
output: Value,
}
#[derive(Debug, Clone)]
/// 托管属性连接,强持有计算/目标所需引用,并保留只读计算的版本缓存。
pub struct Binding {
// 目标属性名;目标 Block 通过 bindings 列表拥有本连接。
field: String,
// Some 表示连接有效且拥有计算函数;取消/替换后置 None,让 trace 停止保活函数。
pub compute: Option<ObjectRef>,
// payload 克隆共享同一缓存,刷新局部副本也能更新缓存;首次计算前为 None。
reads: Arc<Mutex<Option<Cached>>>,
}
impl ObjectPayload for Binding {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-binding"
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
if let Some(compute) = self.compute {
visit(compute);
@@ -41,6 +55,7 @@ impl ObjectPayload for Binding {
}
}
/// 检查绑定目标与可调用计算,建立托管连接及其引用关系。
pub fn connect(
cx: &mut NativeContext<'_>,
target: ObjectRef,
@@ -48,12 +63,13 @@ pub fn connect(
compute: ObjectRef,
desktop: ObjectRef,
) -> Result<ObjectRef, VmError> {
// 在触碰连接列表前拒绝非法名称,尤其禁止绑定运行时类型属性。
if field.is_empty() || field.len() > 128 || field == "type" {
return Err(VmError::InvalidArgument("invalid binding property"));
}
let (reference, data) = block::resolve(cx.store(), target)?;
let mut data = data.clone();
// Only actual fields can be bound; method names are not writable targets.
// 只允许属性存储里已存在的字段;方法名即使可读取,也不能作为绑定写入目标。
let properties = data
.properties
.ok_or(VmError::InvalidArgument("missing property storage"))?;
@@ -63,6 +79,7 @@ pub fn connect(
"binding compute must be a Function",
));
}
// 刷新统一以零参数调用 compute,因此必须有参数元数据并且接受零实参。
if !cx
.store()
.call_arity(compute)?
@@ -75,6 +92,8 @@ pub fn connect(
"binding compute requires zero unbound parameters",
));
}
// 同一字段只保留一个有效计算。其他字段连接沿用;旧同字段连接失效并清缓存,
// 使外部仍持有的 Subscription 不会继续驱动被替换的计算。
let mut retained = vec![];
for connection in &data.bindings {
let mut binding = cx
@@ -102,11 +121,12 @@ pub fn connect(
})?;
retained.push(connection);
data.bindings = retained;
// Generic text bindings replace the legacy textOf derivation.
// text 改由通用绑定负责后,清除旧 textOf 推导入口,避免两套机制竞争同一属性。
if field == "text" {
data.text_binding = None;
}
cx.replace_payload(reference, data)?;
// 返回的取消令牌仅弱引用连接;连接生命期由目标 Block 的 bindings 决定。
let token = cx.allocate(Subscription {
connection: cx.store().downgrade(connection)?,
})?;
@@ -114,19 +134,23 @@ pub fn connect(
Ok(token)
}
/// 在只读调用中重算失效绑定,采集新的读取版本,再将成功结果应用到目标属性。
pub fn refresh(
vm: &mut Vm,
nodes: &[WeakObjectRef],
host_desktops: &HashSet<ObjectRef>,
) -> Result<usize> {
// work 跨所有轮次累计。属性 A 的写入可能使已检查的 B 失效,所以不能只扫描一次。
let mut work = 0;
loop {
let mut evaluated = false;
// 每轮按解析后的真实 Block 身份去重,多个包装对象共享数据时只处理一次。
let mut seen = HashSet::new();
for weak in nodes {
let Some(target) = vm.upgrade(*weak)? else {
continue;
};
// 用闭包暂存失败,让下方 release 无论处理成功与否都能释放 upgrade 的强引用。
let result: Result<()> = (|| {
let (reference, data) = block::resolve(vm.object_store(), target)?;
if !seen.insert(reference) {
@@ -143,6 +167,8 @@ pub fn refresh(
.lock()
.map_err(|_| anyhow::anyhow!("binding cache poisoned"))?
.clone();
// 两项都成立才能跳过:读取依赖版本未变,且目标仍等于上次输出。
// 后一项让外部直接改写目标后,绑定仍能恢复其计算值。
if cached.as_ref().is_some_and(|cached| {
cached
.reads
@@ -154,11 +180,13 @@ pub fn refresh(
}) {
continue;
}
// 防止相互写入的绑定持续振荡;预算跨轮累计,超限向宿主报告不稳定。
work += 1;
if work > 10_000 {
bail!("property bindings did not stabilize within the execution budget");
}
evaluated = true;
// 计算阶段禁止写入,精确记录本次实际读取;单次调用另设帧数与指令上限。
let (value, reads) = vm
.call_read_only_precise_with_limits(
compute,
@@ -170,12 +198,14 @@ pub fn refresh(
)
.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
let applied: Result<()> = (|| {
// 宿主桌面对象不纳入此缓存依赖;保留业务读取,避免宿主发布状态驱动重算。
let reads = reads
.dependencies
.into_iter()
.filter(|read| !host_desktops.contains(&read.object()))
.collect();
let (_, data) = block::resolve(vm.object_store(), target)?;
// 先在数据副本上验证属性类型/范围,验证通过后才写回托管属性。
block::apply_visual_property(&mut data.clone(), &binding.field, &value)?;
if vm.get_attr(target, &binding.field)? != value {
vm.set_attr(target, &binding.field, value.clone())?;
@@ -190,6 +220,7 @@ pub fn refresh(
});
Ok(())
})();
// 缓存不拥有输出对象;释放调用返回值后再传播应用错误,避免失败路径漏引用。
vm.release_value(value)?;
applied?;
}
@@ -198,6 +229,7 @@ pub fn refresh(
vm.release(target)?;
result?;
}
// 整轮无需计算才达到固定点;即使某次计算输出未变,也再扫描一轮确认依赖有效。
if !evaluated {
return Ok(work);
}
+406 -1
View File
@@ -1,3 +1,7 @@
//! Block 的共享赋值、底层 Data 与视觉属性实现。Block 通过 Cell 间接指向 Data,使别名能观察到同一次替换。
//! Data 保存子组件 slot、布局配置、绑定和订阅。属性读写需要沿有效的类型/存储路径解析,不能把快照当成可变业务对象。
//! 提取视觉数据时将托管属性覆盖到原生描述;校验负责拒绝循环、非法尺寸和不符合类型的组成。
//!
//! Live visual objects. All semantic references are owned by Aster's heap;
//! native Arcs are reserved for immutable rendering snapshots.
use std::sync::Arc;
@@ -7,12 +11,15 @@ use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, Vm
use crate::ui_bridge::UiNode;
#[derive(Debug, Clone)]
/// 稳定组成 slot 与子 Block 的配对;slot 不依赖孩子当前的屏幕位置。
pub struct Child {
// slot 标识父组件中这条组成边,移动行序不改变它;同一 Block 可通过不同 slot 出现多次。
pub slot: u64,
pub block: ObjectRef,
}
#[derive(Debug, Clone)]
/// 原生组件分类,只描述业务组件种类;每种展示几何由布局和 Mount 另行生成。
pub enum Kind {
Text {
value: String,
@@ -32,6 +39,16 @@ pub enum Kind {
},
Row,
Column,
Overlay,
Scene2D,
Viewport3D {
width: f32,
height: f32,
},
CanvasViewport {
width: f32,
height: f32,
},
Button,
Page,
}
@@ -39,20 +56,25 @@ pub enum Kind {
/// The VM owns the Block -> Cell -> Data relationship. Shared assignment
/// changes the Cell to point at another Block, retaining the original identity.
#[derive(Debug, Clone)]
/// 对共享 Cell 的稳定包装。赋值可让所有别名一起改指向,而每次出现的 Mount 仍独立。
pub struct Block {
// 共享赋值单元 Cell;其值可为最终 Data 或另一个 Block,从而保留别名身份并转发属性/类型查询。
pub state: ObjectRef,
pub page: bool,
pub tasks: Option<ObjectRef>,
}
impl ObjectPayload for Block {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 沿 Block 的共享 Cell 定位属性存储,使别名替换后读取到同一份新 Data。
fn attribute_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 若 Cell 指向替代 Block,将属性查询继续转发;否则读取 Data.properties 的普通托管字段。
if store.object(value)?.payload().as_any().is::<Block>() {
Ok(Some(value))
} else {
@@ -66,6 +88,7 @@ impl ObjectPayload for Block {
}
}
/// 从 Block 当前 Data 解析其类型转发目标,使共享替换后的类型查询仍一致。
fn type_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
let Value::Object(value) = store.cell_get(self.state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
@@ -74,25 +97,31 @@ impl ObjectPayload for Block {
Ok(Some(value))
} else {
store.payload::<Data>(value, "Block data")?;
// 到达最终 Data 时停止类型转发,使用当前 Block 自身名义类型;只有替换为其他 Block 才继续转发类型。
Ok(None)
}
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-block"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.state);
if let Some(tasks) = self.tasks {
visit(tasks);
}
}
/// 向 VM 暴露共享赋值单元;替换的是指向关系,不是逐项复制组件业务字段。
fn assignment_cell(
&self,
source: ObjectRef,
@@ -101,17 +130,20 @@ impl ObjectPayload for Block {
if !store.object(source)?.payload().as_any().is::<Self>() {
return Ok(None);
}
// Page 与普通组件不能交叉共享赋值,防止把窗口/页面生命周期能力替换成普通叶组件。
if store.payload::<Self>(source, "Block")?.page != self.page {
return Err(VmError::InvalidArgument(
"Page assignment requires another Page",
));
}
// 建立转发前检查源展开图不能回到本 Cell,禁止共享赋值在别名或组成关系中形成环。
validate(store, source, Some(self.state))?;
Ok(Some(self.state))
}
}
#[derive(Debug, Clone)]
/// 组件当前业务描述与组成。与一次求出的屏幕几何分开保存,替换或属性变化会触发派生快照失效。
pub struct Data {
pub kind: Kind,
pub children: Vec<Child>,
@@ -120,19 +152,38 @@ pub struct Data {
pub flex: f32,
pub flex_policy: crate::layout::FlexPolicy,
pub main_align: crate::layout::MainAlign,
pub cross_align: crate::layout::CrossAlign,
pub align_self: Option<crate::layout::CrossAlign>,
pub margin: crate::layout::Insets,
pub sizing: crate::box_model::Sizing,
// 四边覆盖顺序为上、右、下、左;None 表示回退统一 padding,不等于零。
pub padding_edges: [Option<f32>; 4],
pub border: crate::layout::Insets,
pub border_color: String,
pub clip_children: bool,
pub camera: Option<ObjectRef>,
pub scene3d: Option<ObjectRef>,
pub controller: Option<ObjectRef>,
pub scroll_axis: crate::scroll::Axis,
pub position: [f32; 2],
pub surface: crate::page::Surface,
// 下次插入的单调 slot,初始孩子按 0..len 编号,后续重排不把旧 slot 重新编号。
pub next_slot: u64,
// Page 导航历史是真实托管拥有边;后退/前进可重新展示同一个对象,而非复制快照。
pub history: Vec<ObjectRef>,
pub history_index: usize,
pub bindings: Vec<ObjectRef>,
pub text_binding: Option<(ObjectRef, String)>,
pub click: Option<ObjectRef>,
pub subscriptions: Vec<ObjectRef>,
// 源可读写的视觉字段存储;Data 自身保存结构边与构造默认值,提取时再将当前属性覆盖到派生副本。
pub properties: Option<ObjectRef>,
}
impl Data {
/// 建立组件业务描述,为初始孩子分配稳定 slot,并初始化无绑定、无订阅的默认布局策略。
pub fn new(kind: Kind, children: Vec<ObjectRef>, padding: f32, gap: f32) -> Self {
// 先为输入孩子分配稳定 slot,next_slot 从当前长度继续,后续追加不会复用被删除条目的编号。
let next_slot = children.len() as u64;
Self {
kind,
@@ -149,7 +200,20 @@ impl Data {
flex: 0.0,
flex_policy: crate::layout::FlexPolicy::default(),
main_align: crate::layout::MainAlign::Start,
cross_align: crate::layout::CrossAlign::Start,
align_self: None,
margin: crate::layout::Insets::default(),
sizing: crate::box_model::Sizing::default(),
padding_edges: [None; 4],
border: crate::layout::Insets::default(),
border_color: "#D8DCE2".into(),
clip_children: false,
camera: None,
scene3d: None,
controller: None,
scroll_axis: crate::scroll::Axis::Both,
position: [0.0, 0.0],
surface: crate::page::Surface::new(crate::page::Mode::Window),
next_slot,
history: vec![],
history_index: 0,
@@ -160,25 +224,48 @@ impl Data {
properties: None,
}
}
/// 检查原生组件种类是否承载子组成,供树修改和布局选择使用。
pub fn is_container(&self) -> bool {
// 这些类别允许通过通用 Children 方法编辑;Viewport3D 的面板内容来自 Scene3D 提取,不是普通 children 列表。
matches!(
self.kind,
Kind::Row | Kind::Column | Kind::Button | Kind::Page | Kind::Scroll { .. }
Kind::Row
| Kind::Column
| Kind::Overlay
| Kind::Scene2D
| Kind::CanvasViewport { .. }
| Kind::Button
| Kind::Page
| Kind::Scroll { .. }
)
}
}
impl ObjectPayload for Data {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-block-data"
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
// 所有结构字段中出现的托管强引用必须枚举:场景/相机、属性存储、孩子、历史、绑定、订阅和回调。
if let Some(scene) = self.scene3d {
visit(scene);
}
if let Some(controller) = self.controller {
visit(controller);
}
if let Some(camera) = self.camera {
visit(camera);
}
if let Some(properties) = self.properties {
visit(properties);
}
@@ -203,17 +290,21 @@ impl ObjectPayload for Data {
}
}
/// 分配 Data、共享 Cell 和 Block 包装,并让托管图拥有必要引用。
pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef, VmError> {
let page = matches!(data.kind, Kind::Page);
// 分配拥有边前检查每个孩子都是有效有限 Block 图;非法组成直接失败,不截断部分 UI。
for child in &data.children {
validate(cx.store(), child.block, None)?;
}
// 把原生默认参数显式写成普通属性,使源赋值、绑定及精确依赖跟踪观察到同一存储。
let properties = cx.new_object()?;
for (name, value) in native_properties(&data) {
cx.set_attr(properties, name, value)?;
}
data.properties = Some(properties);
let data = cx.allocate(data)?;
// 最终建立 Block -> Cell -> Data -> properties/children 拥有链,均在 VM 中参与追踪和循环回收。
let state = cx.new_cell(Some(Value::Object(data)))?;
cx.allocate(Block {
state,
@@ -224,11 +315,13 @@ pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef,
/// Borrowed final data; forwarding is part of the managed graph, never an Arc
/// ownership graph hidden from the VM's cycle collector.
/// 解析一个 Block 当前指向的 Data,返回底层身份和借用描述。
pub fn resolve(store: &ObjectStore, block: ObjectRef) -> Result<(ObjectRef, &Data), VmError> {
let (reference, data, _) = resolve_tracked(store, block)?;
Ok((reference, data))
}
/// 解析 Block 的完整引用路径,并返回快照失效所需的依赖身份。
pub fn resolve_tracked(
store: &ObjectStore,
block: ObjectRef,
@@ -237,6 +330,7 @@ pub fn resolve_tracked(
let mut seen = std::collections::HashSet::new();
let mut dependencies = Vec::new();
loop {
// 解析整条转发路径防环;依赖收集同时记录每层 Block 与 Cell,任一转发变更都会让视觉缓存失效。
if !seen.insert(current) {
return Err(VmError::InvalidArgument("cyclic Block reference"));
}
@@ -245,6 +339,7 @@ pub fn resolve_tracked(
let Value::Object(value) = store.cell_get(state)? else {
return Err(VmError::InvalidArgument("Block state is not an object"));
};
// 遇到 Data 返回最终存储身份与借用引用;否则下一轮要求该值必须是 Block,不接受任意对象。
if store.object(value)?.payload().as_any().is::<Data>() {
dependencies.push(value);
return Ok((
@@ -259,11 +354,13 @@ pub fn resolve_tracked(
/// Validate the entire finite expansion before establishing a new composition
/// edge. The node count is an explicit rejected resource quota, not truncated UI.
/// 完整展开 Block/Cell/孩子图,拒绝环、禁止目标回边及超过 10000 次展开的组成。
pub fn validate(
store: &ObjectStore,
root: ObjectRef,
forbidden_state: Option<ObjectRef>,
) -> Result<(), VmError> {
// 显式进入/退出栈模拟深度优先遍历;退出时移除祖先链标记,允许共享子树。
enum Step {
Enter(ObjectRef),
Exit(ObjectRef),
@@ -277,11 +374,13 @@ pub fn validate(
path.remove(&state);
}
Step::Enter(block) => {
// 预算按实际展开次数而非不同对象数计算,共享子树重复出现也计入资源工作量。
visits += 1;
if visits > 10_000 {
return Err(VmError::ExecutionLimit("expanded Block graph"));
}
let state = store.payload::<Block>(block, "Block")?.state;
// path 只追踪当前祖先 Cell,允许其他分支共享同一组件;forbidden_state 额外阻止新边回到将被修改的目标。
if forbidden_state == Some(state) || !path.insert(state) {
return Err(VmError::InvalidArgument(
"Block composition would create a cycle",
@@ -296,6 +395,7 @@ pub fn validate(
"Block composition would create a cycle",
));
}
// Data 展开孩子,非 Data 则沿共享赋值转发链继续;显式 Exit 在子树完成后清除祖先标记。
if let Ok(data) = store.payload::<Data>(value, "Block data") {
for child in data.children.iter().rev() {
work.push(Step::Enter(child.block));
@@ -309,17 +409,24 @@ pub fn validate(
Ok(())
}
/// 用一次性空 RenderCache 提取不可变视觉树;不跨调用保存快照缓存。
pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result<Arc<UiNode>, VmError> {
// 一次性提取使用空缓存,不保留跨调用结果;有增量复用需求的窗口应使用自己的 RenderCache。
crate::snapshot::RenderCache::default().snapshot(store, root)
}
/// Native visual parameters are ordinary managed attributes too. Composition,
/// histories and connections remain structural payload edges.
/// 列出本类型可投影到托管属性的原生字段及默认值。
pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
fn number(value: f32) -> Value {
Value::Number((value as f64).into())
}
// 先枚举每种 Block 都有的定位、弹性、盒尺寸、边框与对齐属性,再按 kind 增补专有字段。
let mut fields = vec![
("x", number(data.position[0])),
("y", number(data.position[1])),
("flex", number(data.flex)),
(
"flexBasis",
@@ -330,8 +437,74 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
("flexMin", number(data.flex_policy.min)),
("flexMax", number(data.flex_policy.max)),
("flexShrink", number(data.flex_policy.shrink)),
("marginTop", number(data.margin.top)),
("marginRight", number(data.margin.right)),
("marginBottom", number(data.margin.bottom)),
("marginLeft", number(data.margin.left)),
("boxWidth", data.sizing.width.value()),
("boxHeight", data.sizing.height.value()),
("minWidth", number(data.sizing.min_width)),
("minHeight", number(data.sizing.min_height)),
("maxWidth", number(data.sizing.max_width)),
("maxHeight", number(data.sizing.max_height)),
("borderTop", number(data.border.top)),
("borderRight", number(data.border.right)),
("borderBottom", number(data.border.bottom)),
("borderLeft", number(data.border.left)),
("borderColor", Value::Text(data.border_color.clone())),
(
"overflow",
Value::Text(
if data.clip_children {
"clip"
} else {
"visible"
}
.into(),
),
),
(
"alignSelf",
Value::Text(
data.align_self
.map_or("auto", crate::layout::CrossAlign::name)
.into(),
),
),
];
for (name, edge) in ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]
.into_iter()
.zip(data.padding_edges)
{
fields.push((
name,
// 单边 padding 的 auto 编码成文本,不用数值哨兵,读取时才能区分“继承统一值”和显式零。
edge.map_or_else(|| Value::Text("auto".into()), number),
));
}
match &data.kind {
Kind::Viewport3D { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
("scene", data.scene3d.map_or(Value::Nil, Value::Object)),
(
"controller",
data.controller
.map_or_else(|| Value::Text("none".into()), Value::Object),
),
("camera", data.camera.map_or(Value::Nil, Value::Object)),
]),
Kind::CanvasViewport { width, height } => fields.extend([
("width", number(*width)),
("height", number(*height)),
("camera", data.camera.map_or(Value::Nil, Value::Object)),
]),
Kind::Page => fields.extend([
("mode", Value::Text(data.surface.mode.name().into())),
("panX", number(data.surface.pan.x)),
("panY", number(data.surface.pan.y)),
("zoom", number(data.surface.zoom)),
]),
Kind::Text { value, size, color } => fields.extend([
("text", Value::Text(value.clone())),
("size", Value::Number((*size as i64).into())),
@@ -356,17 +529,21 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
Kind::Spacer(size) => fields.push(("size", number(*size))),
_ => {}
}
// 容器才暴露统一 padding/gap 与主交叉轴策略;叶节点不因同名自定义字段被当成通用容器。
if data.is_container() {
fields.extend([
("padding", number(data.padding)),
("gap", number(data.gap)),
("mainAlign", Value::Text(data.main_align.name().into())),
("crossAlign", Value::Text(data.cross_align.name().into())),
]);
}
fields
}
/// 复制原生描述并用当前托管属性覆盖它;结果只用于展示,不反向修改业务对象。
pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
// 只改本轮派生副本,逐个读取实际属性并验证;读取字段名集合来自 native_properties,不遍历任意用户字段。
let mut data = data.clone();
let properties = data
.properties
@@ -375,14 +552,62 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
let value = store.get_attr(properties, name)?;
apply_visual_property(&mut data, name, &value)?;
}
// 各单值合法仍可能存在跨字段矛盾,必须在全部覆盖完成后校验 min/max 关系。
if data.flex_policy.min > data.flex_policy.max {
return Err(VmError::InvalidArgument("flexMin exceeds flexMax"));
}
if data.sizing.min_width > data.sizing.max_width
|| data.sizing.min_height > data.sizing.max_height
{
return Err(VmError::InvalidArgument("box minimum exceeds maximum"));
}
if matches!(data.kind, Kind::CanvasViewport { .. }) {
crate::viewport::camera(
store,
data.camera.ok_or(VmError::InvalidArgument(
"CanvasViewport requires a Camera2D",
))?,
)?;
// CanvasViewport 只容纳一个 Scene2D 根,允许其内部无限空间布局,但不接受多个普通孩子冒充场景。
if data.children.len() != 1
|| !matches!(
resolve(store, data.children[0].block)?.1.kind,
Kind::Scene2D
)
{
return Err(VmError::InvalidArgument(
"CanvasViewport requires exactly one Scene2D",
));
}
}
if matches!(data.kind, Kind::Viewport3D { .. })
&& let Some(controller) = data.controller
{
let definition = store.type_of(controller)?.ok_or(VmError::InvalidArgument(
"3D controller requires a nominal update method",
))?;
// 3D 控制器要求名义类型上的 update,接受含接收者在内三参;调用执行与预算在输入分发路径处理。
let method = store
.lookup_method(definition, "update")?
.ok_or(VmError::InvalidArgument(
"3D controller must define update(camera, gesture)",
))?;
if store
.call_arity(method)?
.is_none_or(|arity| !arity.accepts(3))
{
return Err(VmError::InvalidArgument(
"3D controller update expects camera and CameraGesture",
));
}
}
Ok(data)
}
/// 按属性名检查和解码源值,再更新对应视觉字段;非法尺寸、枚举或颜色被拒绝。
pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Result<(), VmError> {
let bad = || VmError::InvalidArgument("invalid native visual property");
// 常规视觉长度/系数只接受非负有限值;坐标和 pan 在下面走允许负数的独立路径。
let number = || -> Result<f32, VmError> {
let Value::Number(value) = value else {
return Err(bad());
@@ -393,7 +618,158 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
}
Ok(n)
};
// x/y 对所有组件生效;页面 pan/zoom 只作用 Page。这里不是任意字段存在性校验,不适用种类会跳过更新。
if matches!(name, "x" | "y" | "panX" | "panY" | "zoom") {
let n = crate::scroll::coordinate(value)?;
match name {
"x" => data.position[0] = n,
"y" => data.position[1] = n,
"panX" if matches!(data.kind, Kind::Page) => data.surface.pan.x = n,
"panY" if matches!(data.kind, Kind::Page) => data.surface.pan.y = n,
"zoom" if matches!(data.kind, Kind::Page) => {
// Page 兼容相机缩放范围不同于独立 Camera2D 的 0.01..100,保持两套源协议各自限制。
if !(0.1..=4.0).contains(&n) {
return Err(invalid_zoom());
}
data.surface.zoom = n;
}
_ => {}
}
return Ok(());
}
if matches!(data.kind, Kind::Page) && name == "mode" {
let Value::Text(name) = value else {
return Err(bad());
};
data.surface.mode = crate::page::Mode::parse(name)?;
return Ok(());
}
match name {
// controller 接受对象或明确的字符串 none,不能拿 Nil 隐式取消;后续 visual_data 检查其名义方法。
"controller" if matches!(data.kind, Kind::Viewport3D { .. }) => {
data.controller = match value {
Value::Object(controller) => Some(*controller),
Value::Text(text) if text == "none" => None,
_ => return Err(bad()),
};
return Ok(());
}
// camera/scene 这里仅检查 Object 并记录身份,实际 payload/结构验证由各视口快照入口继续执行。
"camera"
if matches!(
data.kind,
Kind::CanvasViewport { .. } | Kind::Viewport3D { .. }
) =>
{
let Value::Object(camera) = value else {
return Err(bad());
};
data.camera = Some(*camera);
return Ok(());
}
"scene" if matches!(data.kind, Kind::Viewport3D { .. }) => {
let Value::Object(scene) = value else {
return Err(bad());
};
data.scene3d = Some(*scene);
return Ok(());
}
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
let index = match name {
"paddingTop" => 0,
"paddingRight" => 1,
"paddingBottom" => 2,
_ => 3,
};
// 单边 auto 清除覆盖值;数值零保留 Some(0),从而确实覆盖统一 padding。
data.padding_edges[index] = if matches!(value, Value::Text(text) if text == "auto") {
None
} else {
Some(number()?)
};
return Ok(());
}
"borderTop" => {
data.border.top = number()?;
return Ok(());
}
"borderRight" => {
data.border.right = number()?;
return Ok(());
}
"borderBottom" => {
data.border.bottom = number()?;
return Ok(());
}
"borderLeft" => {
data.border.left = number()?;
return Ok(());
}
"borderColor" => {
let Value::Text(color) = value else {
return Err(bad());
};
crate::document::parse_color(color).map_err(|_| bad())?;
data.border_color.clone_from(color);
return Ok(());
}
"overflow" => {
let Value::Text(overflow) = value else {
return Err(bad());
};
// overflow 只切换可见/裁剪,不暗中启用滚动;需要偏移与输入消费必须使用 ScrollBlock。
data.clip_children = match overflow.as_str() {
"clip" => true,
"visible" => false,
_ => {
return Err(VmError::InvalidArgument(
"overflow expects visible or clip; use ScrollBlock for scrolling",
));
}
};
return Ok(());
}
"boxWidth" => {
data.sizing.width = crate::box_model::Dimension::parse(value)?;
return Ok(());
}
"boxHeight" => {
data.sizing.height = crate::box_model::Dimension::parse(value)?;
return Ok(());
}
"minWidth" => {
data.sizing.min_width = number()?;
return Ok(());
}
"minHeight" => {
data.sizing.min_height = number()?;
return Ok(());
}
"maxWidth" => {
data.sizing.max_width = number()?;
return Ok(());
}
"maxHeight" => {
data.sizing.max_height = number()?;
return Ok(());
}
"marginTop" => {
data.margin.top = number()?;
return Ok(());
}
"marginRight" => {
data.margin.right = number()?;
return Ok(());
}
"marginBottom" => {
data.margin.bottom = number()?;
return Ok(());
}
"marginLeft" => {
data.margin.left = number()?;
return Ok(());
}
// basis 的 Nil 与 auto 都表示内容基准;其他值使用非负长度检查。flexMin/Max 的相互关系在完整提取后验证。
"flexBasis" => {
data.flex_policy.basis = if matches!(value, Value::Nil)
|| matches!(value, Value::Text(text) if text == "auto")
@@ -423,6 +799,22 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
return Ok(());
}
let container = data.is_container();
// alignSelf 可由任何孩子独立声明,crossAlign 只解释容器配置;auto 恢复继承父交叉轴策略。
if name == "alignSelf" || (container && name == "crossAlign") {
let Value::Text(value) = value else {
return Err(bad());
};
if name == "alignSelf" {
data.align_self = if value == "auto" {
None
} else {
Some(crate::layout::CrossAlign::parse(value)?)
};
} else {
data.cross_align = crate::layout::CrossAlign::parse(value)?;
}
return Ok(());
}
if container && name == "mainAlign" {
let Value::Text(value) = value else {
return Err(bad());
@@ -431,10 +823,15 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
return Ok(());
}
match (&mut data.kind, name) {
(Kind::Viewport3D { width, .. }, "width") => *width = number()?,
(Kind::Viewport3D { height, .. }, "height") => *height = number()?,
(Kind::CanvasViewport { width, .. }, "width") => *width = number()?,
(Kind::CanvasViewport { height, .. }, "height") => *height = number()?,
(Kind::Text { value: text, .. }, "text") => {
let Value::Text(value) = value else {
return Err(bad());
};
// 文本属性按 UTF-8 字节设 16 KiB 上限,防止单个 Block 超过原生视觉文本预算。
if value.len() > 16_384 {
return Err(bad());
}
@@ -442,6 +839,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
}
(Kind::Text { size, .. }, "size") => {
let n = number()?;
// Text size 是 1..8 的离散倍率,不能把任意浮点字号通过 as u32 悄悄截断。
if !(1.0..=8.0).contains(&n) || n.fract() != 0.0 {
return Err(bad());
}
@@ -451,6 +849,7 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
let Value::Text(value) = value else {
return Err(bad());
};
// 颜色赋值先验证文本格式再替换派生颜色,避免错误字符串进入软件渲染器。
crate::document::parse_color(value).map_err(|_| bad())?;
color.clone_from(value);
}
@@ -466,9 +865,15 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
(Kind::Rect { height, .. }, "height") => *height = number()?,
(Kind::Rect { radius, .. }, "radius") => *radius = number()?,
(Kind::Spacer(size), "size") => *size = number()?,
// 剩余 kind 专有尺寸与容器字段按各自规则覆盖;未知字段忽略,不在此处删除或修改用户自定义属性。
(_, "padding") if container => data.padding = number()?,
(_, "gap") if container => data.gap = number()?,
_ => {}
}
Ok(())
}
/// 统一返回非法页面缩放错误,确保属性写入与输入路径使用相同限制。
fn invalid_zoom() -> VmError {
VmError::InvalidArgument("Page.zoom expects a number from 0.1 to 4")
}
+236 -10
View File
@@ -1,3 +1,7 @@
//! 运行时与窗口线程之间的发布数据契约。NamespaceState 管理提交序号、版本和当前完整文档。
//! RenderDocument/Frame 包含绘制命令、Mount 几何、页面标签和相机投影。发布前统一检查数量、坐标、颜色及索引一致性。
//! 屏幕命中、逻辑坐标和世界 UI 的射线映射必须与绘制采用同一变换;这里的 ID 是数据身份,不是窗口线程可操作的托管引用。
//!
use std::collections::HashSet;
use std::sync::Arc;
@@ -13,18 +17,23 @@ use crate::{
pub const DEFAULT_NAMESPACE: &str = "app.main";
#[derive(Clone, Debug)]
/// HTTP/GUI 共享的最近完整发布状态;submission 决定结果归属,revision 表示实际发布次数。
pub struct NamespaceState {
pub name: String,
pub revision: u64,
// 较新完整 PUT/DELETE 会改变提交身份;旧输入即使晚完成也不得覆盖新结果。
pub submission: u64,
pub document: RenderDocument,
// viewport 是主窗口逻辑页面区域;window_viewports 同时记录仍存活的副窗口。
pub viewport: Option<crate::layout::Size>,
pub window_viewports: std::collections::BTreeMap<u64, crate::layout::Size>,
// 原生命令与运行时世代一起发布,防止窗口 ID 在跨 VM 时被误用。
pub runtime_session: u64,
pub window_commands: Vec<crate::window::Envelope>,
}
impl Default for NamespaceState {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
name: DEFAULT_NAMESPACE.to_owned(),
@@ -40,7 +49,9 @@ impl Default for NamespaceState {
}
impl NamespaceState {
/// 在成功发布边界切换到指定 session,并按动作类别合并窗口命令;世代变化时丢弃旧命令。
pub fn publish_window_commands(&mut self, session: u64, commands: Vec<crate::window::Command>) {
// 跨运行时世代先丢弃旧命令,并去除旧副窗口视口记录;主壳窗口可跨 VM 替换继续存在。
if self.runtime_session != session {
self.window_commands.clear();
// The primary shell survives VM replacement. Secondary native
@@ -49,10 +60,12 @@ impl NamespaceState {
.retain(|id, _| *id == crate::window::MAIN_WINDOW);
}
self.runtime_session = session;
// 零表示无有效 VM 会话,清空传输请求并忽略传入命令,不能让退役结果重新控制窗口。
if session == 0 {
self.window_commands.clear();
return;
}
// 本方法接纳传入 session 作为当前世代,调用方负责只在成功发布边界调用;同资源同动作保留最新请求。
for command in commands {
self.window_commands
.retain(|old| !old.command.same_kind(&command));
@@ -63,13 +76,16 @@ impl NamespaceState {
.sort_by_key(|command| command.command.sequence);
}
/// 计算下一个完整源码提交身份;计数溢出时返回错误而不是复用旧身份。
pub fn next_submission(&self) -> Result<u64> {
self.submission
.checked_add(1)
.context("submission counter exhausted")
}
/// 仅当提交身份仍是最新时替换完整文档并递增版本;已被取代的结果不发布。
pub fn commit(&mut self, submission: u64, document: RenderDocument) -> Result<Option<u64>> {
// 先核对归属,再计算新版本;失败或过时的结果不会替换已显示文档。
if submission != self.submission {
return Ok(None);
}
@@ -82,12 +98,14 @@ impl NamespaceState {
Ok(Some(revision))
}
/// 推进提交身份和发布版本,替换为空文档并清除运行时命令,使旧提交不能覆盖清空结果。
pub fn clear(&mut self) -> Result<u64> {
let submission = self.next_submission()?;
let revision = self
.revision
.checked_add(1)
.context("revision counter exhausted")?;
// 先算好 submission 与 revision,任一溢出都不改状态;清空显示同时使旧异步发布失去提交资格。
self.submission = submission;
self.document = RenderDocument::default();
self.runtime_session = 0;
@@ -98,10 +116,12 @@ impl NamespaceState {
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
/// 可以离开 VM 线程的完整展示值,包含源码投影、根帧和其他窗口的文档。
pub struct RenderDocument {
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub windows: std::collections::BTreeMap<u64, RenderDocument>,
#[serde(skip)]
// 仅本机增量渲染使用,不序列化;JSON 文档仍可凭 frame.commands 绘制,不需要持有 VM 句柄。
pub ui_root: Option<Arc<UiNode>>,
#[serde(default)]
pub source: String,
@@ -112,10 +132,25 @@ pub struct RenderDocument {
}
impl RenderDocument {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub fn validate(&self) -> Result<()> {
// 校验页面兼容相机的缩放、pan 和有限 extent;独立视口变换随后按各表与 Mount 另行检查。
let surface = &self.frame.surface;
if !surface.zoom.is_finite()
|| !(0.1..=4.0).contains(&surface.zoom)
|| ![surface.pan.x, surface.pan.y]
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|| ![surface.extent.width, surface.extent.height]
.iter()
.all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v))
{
bail!("invalid page camera or extent");
}
if self.windows.len() > 15 {
bail!("too many window projections");
}
// 副窗口只允许一层投影,键须等于其 frame.window,不能重复嵌入主窗口或再带嵌套 windows。
for (id, document) in &self.windows {
if *id == crate::window::MAIN_WINDOW
|| *id != document.frame.window
@@ -136,6 +171,7 @@ impl RenderDocument {
bail!("invalid window identity");
}
parse_color(&self.frame.background)?;
// 空平行表是兼容的“全部无 clip/view”;一旦非空就必须与 commands 等长,禁止半张索引表。
if !self.frame.command_clips.is_empty()
&& self.frame.command_clips.len() != self.frame.commands.len()
{
@@ -144,12 +180,23 @@ impl RenderDocument {
for clip in self.frame.command_clips.iter().flatten() {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
if !self.frame.command_views.is_empty()
&& self.frame.command_views.len() != self.frame.commands.len()
{
bail!("invalid command view table");
}
for view in self.frame.command_views.iter().flatten() {
if !view.valid() {
bail!("invalid command view transform");
}
}
for command in &self.frame.commands {
command.validate()?;
}
if self.frame.mounts.len() > 10_000 {
bail!("frame exceeds the 10000 mount limit");
}
// 标签 ID 必须非零且唯一,最多一个活动页;标题按 UTF-8 字节限长,标签数受 MAX_PAGES 限制。
let mut tab_ids = HashSet::new();
if self.frame.tabs.len() > crate::ui_bridge::MAX_PAGES
|| self.frame.tabs.iter().filter(|tab| tab.active).count() > 1
@@ -161,8 +208,12 @@ impl RenderDocument {
{
bail!("invalid tab projection");
}
// 先收集全部 Mount ID 并校验几何,再第二遍核对关系下标,允许链接指向帧中较后位置。
let mut mount_ids = HashSet::new();
for mount in &self.frame.mounts {
if mount.view.is_some_and(|view| !view.valid()) {
bail!("invalid Mount view transform");
}
if mount.id == 0 || mount.block_id.is_empty() || !mount_ids.insert(mount.id) {
bail!("invalid or duplicate mount identity");
}
@@ -170,6 +221,10 @@ impl RenderDocument {
if let Some(clip) = mount.clip {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
if let Some(clip) = mount.viewport_clip {
validate_rect(clip[0], clip[1], clip[2], clip[3], 0.0)?;
}
// 滚动投影为 [x,y,maxX,maxY],四项有限非负且实际偏移不得大于对应最大偏移。
if let Some(scroll) = mount.scroll
&& (!scroll.iter().all(|n| {
n.is_finite() && (0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(n)
@@ -179,6 +234,7 @@ impl RenderDocument {
bail!("invalid scroll projection");
}
}
// 此处只保证关系目标在帧内,不做完整树环/双向一致性验证;组成与生命周期层承担更强结构不变量。
for mount in &self.frame.mounts {
for linked in [mount.parent, mount.first_child, mount.next_sibling]
.into_iter()
@@ -190,6 +246,7 @@ impl RenderDocument {
}
}
// 携带本机视觉根时必须与第一个根出现的业务身份一致;反序列化无 ui_root 时跳过这项。
if let Some(root) = &self.ui_root
&& self
.frame
@@ -206,25 +263,41 @@ impl RenderDocument {
Ok(())
}
/// 将逻辑命令转换到设备比例并应用逐命令视图/裁剪;不自动调用 validate,发布入口须先校验。
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
let frame = &self.frame;
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(parse_color(&frame.background)?));
// 按命令顺序取对应 clip/view;缺项按无裁剪/单位变换处理,完整文档合法性应在发布入口预先验证。
for (index, command) in frame.commands.iter().enumerate() {
let clip = frame.command_clips.get(index).copied().flatten();
// clip 已在父视图/面板对应空间,只乘设备 scale;命令本体则乘 view.zoom 再加 view 平移。
if let Some(clip) = clip {
list.push(DrawCommand::PushClip(scaled_rect(
clip[0], clip[1], clip[2], clip[3], scale,
)));
}
list.push(command.to_draw_command(scale)?);
let view = frame
.command_views
.get(index)
.copied()
.flatten()
.unwrap_or_default();
list.push(
command
// 均匀缩放包含字体、半径和描边,随后平移只改原点;不能把位移再乘一次 view.zoom。
.to_draw_command(scale * view.zoom)?
.translated(Point::new(view.x * scale, view.y * scale)),
);
if clip.is_some() {
list.push(DrawCommand::PopClip);
}
}
// 根层只绘制非面板的焦点框;面板内焦点已在 panel::compose 放入纹理,避免重复或脱离面板。
if let Some(focus) = frame
.focus
.and_then(|id| frame.mounts.iter().find(|m| m.id == id))
.filter(|mount| mount.panel.is_none())
{
if let Some(c) = focus.clip {
list.push(DrawCommand::PushClip(scaled_rect(
@@ -233,10 +306,18 @@ impl RenderDocument {
}
list.push(
RenderCommand::StrokeRoundedRect {
x: focus.x,
y: focus.y,
width: focus.width,
height: focus.height,
x: focus
.view
.unwrap_or_default()
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
.x,
y: focus
.view
.unwrap_or_default()
.rect(Rect::new(focus.x, focus.y, focus.width, focus.height))
.y,
width: focus.width * focus.view.unwrap_or_default().zoom,
height: focus.height * focus.view.unwrap_or_default().zoom,
radius: 7.0,
stroke_width: 2.0,
color: "#4F6FDB".into(),
@@ -247,6 +328,7 @@ impl RenderDocument {
list.push(DrawCommand::PopClip);
}
}
// 故障提示最后追加在页面上方,只显示错误第一行;状态是否允许输入由 Frame.hit_test 等另行限制。
if let Some(error) = &frame.fault {
list.push(
RenderCommand::RoundedRect {
@@ -274,12 +356,14 @@ impl RenderDocument {
Ok(list)
}
/// 从源码投影根按 ID 查找节点,供 Sources 查看与定位使用。
pub fn node(&self, id: &str) -> Option<&CodeNode> {
self.root.as_ref()?.find(id)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// Sources 使用的源码层次节点;ID、名称、种类和行列范围都属于代码投影而不是运行时对象句柄。
pub struct CodeNode {
pub id: String,
pub name: String,
@@ -293,7 +377,9 @@ pub struct CodeNode {
}
impl CodeNode {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
fn validate<'a>(&'a self, depth: usize, ids: &mut HashSet<&'a str>) -> Result<()> {
// 限制代码投影深度,避免递归查找/展示无界;同一 ids 集合跨所有分支检查全树 ID 唯一。
if depth > 64 {
bail!("architecture tree exceeds 64 levels");
}
@@ -303,6 +389,7 @@ impl CodeNode {
if !ids.insert(&self.id) {
bail!("duplicate architecture node id: {}", self.id);
}
// 零行号表示未提供来源;只有两端均非零时检查顺序,不把兼容缺失字段当非法。
if self.start_line > 0 && self.end_line > 0 && self.start_line > self.end_line {
bail!("invalid source range for node {}", self.id);
}
@@ -312,6 +399,7 @@ impl CodeNode {
Ok(())
}
/// 按请求的代码节点身份或路径查找对应的源码投影。
fn find(&self, id: &str) -> Option<&CodeNode> {
if self.id == id {
return Some(self);
@@ -321,7 +409,13 @@ impl CodeNode {
}
#[derive(Clone, Debug, Serialize, Deserialize)]
/// 一个窗口的一次完整视觉发布:有序命令及其裁剪/变换、出现几何、焦点和版本。平行索引表必须和命令数量一致。
pub struct Frame {
// 命中索引是可重建的本地优化,不作为序列化协议的一部分保存。
#[serde(skip)]
pub hit_index: Arc<crate::viewport::spatial::Index>,
#[serde(default)]
pub surface: crate::page::Surface,
#[serde(default = "default_window")]
pub window: u64,
#[serde(default = "default_background")]
@@ -331,10 +425,16 @@ pub struct Frame {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_clips: Vec<Option<[f32; 4]>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_views: Vec<Option<crate::viewport::Transform>>,
#[serde(skip)]
// 仅本机装配源覆盖/面板时使用的命令出现归属表,不进入 JSON;display_list 绘制本身不读取它。
pub command_mounts: Vec<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mounts: Vec<MountGeometry>,
#[serde(default)]
pub layout_version: u64,
#[serde(default)]
// 运行时世代与 layout_version 共同确定输入归属;仅布局版本相同不能跨 VM 接受事件。
pub session: u64,
#[serde(default)]
pub focus: Option<u64>,
@@ -345,12 +445,17 @@ pub struct Frame {
}
impl Default for Frame {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
hit_index: Arc::default(),
surface: crate::page::Surface::default(),
window: crate::window::MAIN_WINDOW,
background: default_background(),
commands: Vec::new(),
command_clips: Vec::new(),
command_views: Vec::new(),
command_mounts: Vec::new(),
mounts: Vec::new(),
layout_version: 0,
session: 0,
@@ -363,10 +468,26 @@ impl Default for Frame {
/// A position in a published frame. Identifiers are opaque data, never VM handles.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// 某次出现的几何与投影元数据。逻辑矩形不被相机直接改写,view/panel 保存通往视图空间的映射。
pub struct MountGeometry {
#[serde(default, skip_serializing_if = "Option::is_none")]
// 可嵌套的世界 UI 映射,把页面坐标还原到此出现所在面板纹理空间。
pub panel: Option<Arc<crate::scene3d::panel::Mapping>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub canvas_viewport: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub scene3d_viewport: bool,
#[serde(default)]
pub camera_controls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 视口内容区的额外裁剪,供相机手势锚点检查;区别于一般 Mount 的祖先 clip。
pub viewport_clip: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view: Option<crate::viewport::Transform>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clip: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
// 当前有效滚动位置和上限,不是原始请求值;非滚动容器为 None。
pub scroll: Option<[f32; 4]>,
pub id: u64,
pub block_id: String,
@@ -381,8 +502,43 @@ pub struct MountGeometry {
}
impl MountGeometry {
/// 先沿世界 UI 面板逆投影指针,按需要执行可见性和裁剪检查。
pub fn view_point(&self, point: Point, clipped: bool) -> Option<Point> {
self.panel
.as_ref()
.map_or(Some(point), |panel| panel.texture_point(point, clipped))
}
/// 把页面指针转换为该出现的布局坐标,供 hitTest 和局部事件处理使用。
pub fn logical_point(&self, point: Point) -> Option<Point> {
// 先处理面板反投影及遮挡,再撤销二维 view,结果仍是布局空间绝对坐标,尚未减节点原点。
let point = self.view_point(point, true)?;
Some(self.view.unwrap_or_default().inverse(point))
}
/// 把视图位移换算成目标出现的局部位移,处理缩放与世界 UI 投影差异。
pub fn logical_delta(&self, point: Option<[f32; 2]>, delta: [f32; 2]) -> Option<[f32; 2]> {
let zoom = self.view.unwrap_or_default().zoom;
// 普通二维映射为线性,只除累计 zoom 即可;世界面板透视非线性,必须映射两个点求差。
if self.panel.is_none() {
return Some([delta[0] / zoom, delta[1] / zoom]);
}
let point = point?;
let current = self.view_point(Point::new(point[0], point[1]), false)?;
// 对 point 与 point+delta 分别投影,使用 clipped=false 允许捕获拖动越出面板;投影不可逆则返回 None。
let previous =
self.view_point(Point::new(point[0] + delta[0], point[1] + delta[1]), false)?;
Some([
((previous.x - current.x) / zoom).clamp(-1_000_000., 1_000_000.),
((previous.y - current.y) / zoom).clamp(-1_000_000., 1_000_000.),
])
}
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
pub fn contains(&self, point: Point) -> bool {
Rect::new(self.x, self.y, self.width, self.height).contains(point)
let Some(point) = self.view_point(point, true) else {
return false;
};
// 节点矩形在逻辑布局空间检查,clip 在视图/面板纹理空间检查,不能将二者坐标混用。
let local = self.view.map_or(point, |view| view.inverse(point));
Rect::new(self.x, self.y, self.width, self.height).contains(local)
&& self.clip.is_none_or(|c| {
c[2] > 0.0 && c[3] > 0.0 && Rect::new(c[0], c[1], c[2], c[3]).contains(point)
})
@@ -390,28 +546,59 @@ impl MountGeometry {
}
impl Frame {
/// 从顶层绘制顺序向后查找可控制的视口,命中与裁剪均成立才接收相机手势。
pub fn camera_at(&self, point: Point) -> Option<&MountGeometry> {
self.hit_index
.candidates(point, self.mounts.len())
.into_iter()
.map(|index| &self.mounts[index])
.find(|mount| {
// 二维视口可控制,三维视口还要求显式 camera_controls;命中外框之外,还须位于内容 viewport_clip。
(mount.canvas_viewport || mount.scene3d_viewport && mount.camera_controls)
&& mount.contains(point)
&& mount.viewport_clip.is_some_and(|clip| {
mount.view_point(point, true).is_some_and(|point| {
Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point)
})
})
})
}
/// 在无故障帧中按逆绘制顺序选中可交互的出现,空间索引只缩小候选。
pub fn hit_test(&self, point: Point) -> Option<&MountGeometry> {
// 故障帧不返回可交互目标,防止用户继续向已经停止的域发送页面点击。
if self.fault.is_some() {
return None;
}
self.mounts
.iter()
.rev()
// 索引只筛几何候选,返回最前景 interactive 出现;真正源 hitTest 协议由 worker 的 source_hit 再执行。
self.hit_index
.candidates(point, self.mounts.len())
.into_iter()
.map(|index| &self.mounts[index])
.find(|mount| mount.interactive && mount.contains(point))
}
}
/// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。
fn default_window() -> u64 {
crate::window::MAIN_WINDOW
}
/// 提供空帧或未指定背景时的默认页面底色。
fn default_background() -> String {
"#F7F7F5".to_owned()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
/// 可校验、可序列化的逻辑绘制命令;转换到设备像素在 display_list 阶段完成。
pub enum RenderCommand {
Scene3D {
x: f32,
y: f32,
width: f32,
height: f32,
view: Arc<crate::scene3d::View>,
},
RoundedRect {
x: f32,
y: f32,
@@ -445,8 +632,21 @@ pub enum RenderCommand {
}
impl RenderCommand {
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
pub(crate) fn validate(&self) -> Result<()> {
match self {
Self::Scene3D {
x,
y,
width,
height,
view,
} => {
validate_rect(*x, *y, *width, *height, 0.0)?;
if !view.valid() {
bail!("invalid 3D view");
}
}
Self::RoundedRect {
x,
y,
@@ -468,6 +668,7 @@ impl RenderCommand {
color,
} => {
validate_rect(*x, *y, *width, *height, *radius)?;
// 描边必须正且有限;具体半宽限制在软件渲染器处理,这里不要求描边小于矩形尺寸。
if !stroke_width.is_finite() || *stroke_width <= 0.0 {
bail!("stroke_width must be a positive finite number");
}
@@ -484,6 +685,7 @@ impl RenderCommand {
if !x.is_finite() || !y.is_finite() {
bail!("text coordinates must be finite");
}
// 有换行框时同时限制框尺寸、原点与右/下端点;无框文本在此只校验坐标有限性和文本/字号预算。
if let Some([width, height]) = wrap_bounds {
validate_rect(*x, *y, *width, *height, 0.0)?;
if [*x, *y, *width, *height, *x + *width, *y + *height]
@@ -505,8 +707,19 @@ impl RenderCommand {
Ok(())
}
/// 把一条已验证的发布命令变成原生绘制原语,缩放几何和文字样式。
fn to_draw_command(&self, scale: f32) -> Result<DrawCommand> {
Ok(match self {
Self::Scene3D {
x,
y,
width,
height,
view,
} => DrawCommand::Scene3D {
rect: scaled_rect(*x, *y, *width, *height, scale),
view: view.clone(),
},
Self::RoundedRect {
x,
y,
@@ -540,6 +753,7 @@ impl RenderCommand {
size,
color,
wrap_bounds,
// 根据是否提供换行边界选择 WrappedText 或 Text;两者都使用同一等级字号与设备缩放。
} => match wrap_bounds {
Some([width, height]) => DrawCommand::WrappedText {
rect: scaled_rect(*x, *y, *width, *height, scale),
@@ -558,32 +772,39 @@ impl RenderCommand {
}
}
/// 提供未指定描边宽度时的兼容默认值。
fn default_stroke_width() -> f32 {
1.0
}
/// 提供未指定字号时的协议默认值。
fn default_text_size() -> u32 {
1
}
/// 统一检查有限矩形坐标、尺寸与可选圆角,阻止异常值进入光栅循环。
fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result<()> {
if !x.is_finite() || !y.is_finite() || !width.is_finite() || !height.is_finite() {
bail!("rectangle values must be finite");
}
// 此基础检查只要求有限、非负几何与圆角,不加世界坐标绝对值上限;更严格范围由具体协议调用路径决定。
if width < 0.0 || height < 0.0 || !radius.is_finite() || radius < 0.0 {
bail!("rectangle dimensions and radius cannot be negative");
}
Ok(())
}
/// 将逻辑矩形转换为设备比例下的矩形,保持位置与尺寸同比缩放。
fn scaled_rect(x: f32, y: f32, width: f32, height: f32, scale: f32) -> Rect {
Rect::new(x * scale, y * scale, width * scale, height * scale)
}
/// 解析支持的十六进制 RGB/RGBA 文本,拒绝错误长度或非法字符。
pub fn parse_color(value: &str) -> Result<Color> {
let hex = value
.strip_prefix('#')
.ok_or_else(|| anyhow::anyhow!("color must begin with #"))?;
// 先验证 ASCII 和精确长度,后面按字节区间切片才不会在多字节字符中间切开;不支持短色号或命名颜色。
if !hex.is_ascii() || (hex.len() != 6 && hex.len() != 8) {
bail!("color must use #RRGGBB or #RRGGBBAA");
}
@@ -592,6 +813,7 @@ pub fn parse_color(value: &str) -> Result<Color> {
parse(0..2)?,
parse(2..4)?,
parse(4..6)?,
// 顺序为 RRGGBBAA 而非 AARRGGBB,六位色号默认完全不透明。
if hex.len() == 8 { parse(6..8)? } else { 255 },
))
}
@@ -599,6 +821,7 @@ pub fn parse_color(value: &str) -> Result<Color> {
#[cfg(test)]
mod tests {
#[test]
/// 旧文字JSON缺wrap_bounds仍走不换行命令;合法边界随DPI缩放,负宽度在发布验证中拒绝。
fn text_wrap_bounds_validate_and_legacy_text_remains_unwrapped() {
let mut command: RenderCommand = serde_json::from_str(
r##"{"type":"text","x":0,"y":0,"value":"text","size":1,"color":"#000000"}"##,
@@ -624,6 +847,7 @@ mod tests {
use super::*;
#[test]
/// 默认空文档可通过验证且没有结构根/绘图命令,支持初始状态与清空发布。
fn empty_document_is_valid_and_blank() {
let document = RenderDocument::default();
document.validate().unwrap();
@@ -632,6 +856,7 @@ mod tests {
}
#[test]
/// 检查六位色默认alpha255,八位色按RRGGBBAA解码,避免将末尾透明度当成首通道。
fn parses_rgb_and_rgba_colors() {
assert_eq!(
parse_color("#102030").unwrap(),
@@ -648,6 +873,7 @@ mod tests {
mod window_projection_tests {
use super::*;
#[test]
/// 同会话窗口命令可累积,新会话发布先丢旧命令;clear重置runtime_session并清空所有原生命令。
fn replacement_and_reset_discard_old_window_projections() {
let mut state = NamespaceState::default();
let command = crate::window::Command {
+14 -1
View File
@@ -1,10 +1,23 @@
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
//! 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 维护托管属性绑定及其读依赖;计算结果写入目标,绑定令牌不应让隐藏目标意外常驻。
pub(crate) mod binding;
/// 定义托管视觉对象的业务状态、共享替换关系和容器子项;几何属于 Mount,不能写回共享 Block。
pub(crate) mod block;
/// 定义 GUI/HTTP 可见的完整发布文档和共享 namespace;跨线程传递拥有的数据,不传 VM 句柄。
pub(crate) mod document;
/// 维护对象在某个页面中的具体出现位置、稳定子槽与生命周期;同一对象可对应多个独立 Mount。
pub(crate) mod mount;
/// 将语言对象列表适配为宿主遍历与写入接口,保留托管身份并参与读取依赖跟踪。
pub(crate) mod object_list;
/// 按窗口管理页面展示上下文、Mount 准备与提交,分离业务对象寿命和一次展示的寿命。
pub(crate) mod presentation;
/// 提供托管视觉协议方法的解析/调用约定,让源覆盖与原生实现沿同一入口组合。
pub(crate) mod protocols;
/// 提取托管三维场景的节点、相机和材质;验证对象图并生成供纯数据渲染使用的场景快照。
pub(crate) mod scene3d;
/// 从托管对象提取展示快照与属性版本;快照是渲染输入,不承担源程序业务状态。
pub(crate) mod snapshot;
/// 管理 VM 内 Window/Page 资源关系、窗口命令与页面顺序,操作结果再由原生壳层执行。
pub(crate) mod window;
+184 -4
View File
@@ -1,3 +1,7 @@
//! 每次展示的托管上下文:同一个 Block 出现在两个位置时,有两个独立 Mount。
//! Mount 保存父子关系、出现几何和布局期间的查询上下文;MountTable 用弱索引定位对象,不靠索引强行保活业务树。
//! 同步会安排 onMount/onUnmount。布局取消时使用 Checkpoint 恢复未发布状态,避免半完成的出现关系影响下一次刷新。
//!
//! Managed presentation contexts. The active desktop owns the mount dictionary;
//! reverse links and the host registry are weak, so shared content cannot retain a Page.
use crate::document::{Frame, MountGeometry};
@@ -11,16 +15,22 @@ use std::{
};
#[derive(Debug, Clone)]
/// 一个组件在一个展示位置上的上下文。强持有 Block,反向页面/父子链接使用弱身份,避免共享组件把已关闭页面保活。
pub struct Mount {
// 组件是强引用;页面、父节点和兄弟链接是弱引用,防止反向链接形成保活环。
pub block: ObjectRef,
pub page: Option<WeakObjectRef>,
pub parent: Option<WeakObjectRef>,
pub first_child: Option<WeakObjectRef>,
pub next_sibling: Option<WeakObjectRef>,
// 已发布几何与每次出现的滚动位置分开保存,同一 Block 的多个位置互不覆盖。
pub geometry: MountGeometry,
pub scroll_offset: [f32; 2],
// 最后同步的布局版本,Bounds 查询会一并返回,便于调用方识别几何属于哪轮发布。
pub version: u64,
// false 后禁止依赖当前几何的操作;外部即使还持有 Mount 对象也不能把它当活动出现。
pub mounted: bool,
// Mount 可以拥有任务;退役后任务交付还要检查 owner 是否仍有效。
pub tasks: Option<ObjectRef>,
// Private derived scratch, installed before the read-only layout call and
// cleared by geometry commit. It contains no managed references or roots.
@@ -28,18 +38,24 @@ pub struct Mount {
}
impl ObjectPayload for Mount {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-mount"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
// 仅 block 和 tasks 是 payload 的强拥有边;page/parent/child/sibling 是弱边,不参与 trace。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.block);
if let Some(tasks) = self.tasks {
@@ -49,12 +65,17 @@ impl ObjectPayload for Mount {
}
#[derive(Debug, Clone, Copy)]
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
enum Query {
Bounds,
ScrollTo,
ScrollBy,
ScrollPosition,
ScrollIntoView,
FitView,
Pick3D,
ToView,
ToLocal,
Parent,
FirstChild,
NextSibling,
@@ -64,22 +85,31 @@ enum Query {
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
signature: endor_core::CallSignature,
owner: ObjectRef,
// 方法对象拥有 Mount,但不能经桌面反向保活整个窗口;需要桌面能力时再 upgrade。
desktop: WeakObjectRef,
query: Query,
}
impl NativeCallable for Method {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
/// 适配器已绑定 Mount;坐标测试/转换与scrollTo/By接受两个数,其余层次/几何/焦点查询零参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(
if matches!(
self.query,
Query::HitTest | Query::ScrollTo | Query::ScrollBy
Query::HitTest
| Query::Pick3D
| Query::ToView
| Query::ToLocal
| Query::ScrollTo
| Query::ScrollBy
) {
2
} else {
@@ -88,8 +118,10 @@ impl NativeCallable for Method {
))
}
/// 读取具体出现位置,先检查仍挂载与几何可用性,再执行层次、坐标、焦点、滚动或拾取操作,避免使用已退役上下文。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let mount = cx.store().payload::<Mount>(self.owner, "Mount")?.clone();
// 层次查询允许观察已经脱离的对象(通常得到 Nil);几何、焦点、滚动、拾取等必须仍挂接。
if !mount.mounted
&& matches!(
self.query,
@@ -99,14 +131,24 @@ impl NativeCallable for Method {
| Query::ScrollTo
| Query::ScrollBy
| Query::ScrollIntoView
| Query::FitView
| Query::Pick3D
| Query::ToView
| Query::ToLocal
| Query::ScrollPosition
)
{
return Err(VmError::InvalidArgument("Mount is no longer attached"));
}
// 除 VM 元数据检查外入口再核对实际参数数目;二维坐标/位移操作取两个 Number,其余零参。
let arity = if matches!(
self.query,
Query::HitTest | Query::ScrollTo | Query::ScrollBy
Query::HitTest
| Query::Pick3D
| Query::ToView
| Query::ToLocal
| Query::ScrollTo
| Query::ScrollBy
) {
2
} else {
@@ -116,6 +158,55 @@ impl NativeCallable for Method {
return Err(VmError::InvalidArgument("incorrect Mount method arity"));
}
let value = match self.query {
Query::ToView | Query::ToLocal => {
// 布局期间几何尚未提交,坐标转换不能把临时 bounds 与上帧投影混用。
if mount.layout.is_some() {
return Err(VmError::InvalidArgument(
"coordinate conversion requires committed geometry",
));
}
let point = crate::geometry::Point::new(
crate::scroll::coordinate(&args[0])?,
crate::scroll::coordinate(&args[1])?,
);
let point =
if matches!(self.query, Query::ToView) {
// ToView 先将局部点加节点原点,再套二维 view;若在世界面板内,还继续向外投影到页面。
let point = mount.geometry.view.unwrap_or_default().point(
crate::geometry::Point::new(
point.x + mount.geometry.x,
point.y + mount.geometry.y,
),
);
mount
.geometry
.panel
.as_ref()
.map_or(Some(point), |panel| panel.project_point(point))
} else {
// ToLocal 先撤销面板和视图投影,再减节点几何原点;不可逆时返回 Nil,而非虚构坐标。
mount.geometry.logical_point(point).map(|point| {
crate::geometry::Point::new(
point.x - mount.geometry.x,
point.y - mount.geometry.y,
)
})
};
// 转换成功才分配名义 Point;失败返回 Nil,不把无效数值写进结果属性。
if let Some(point) = point {
let result = cx.new_object()?;
let desktop = cx.upgrade(self.desktop)?.ok_or(VmError::InvalidArgument(
"coordinate conversion desktop expired",
))?;
crate::ui_bridge::attach_desktop_type(cx, result, "Point", desktop)?;
cx.set_attr(result, "x", crate::native_visual::value(point.x))?;
cx.set_attr(result, "y", crate::native_visual::value(point.y))?;
Value::Object(result)
} else {
Value::Nil
}
}
// 同一滚动入口区分绝对位置和相对增量,钳制、挂接状态与写屏障交由 scroll::set 统一执行。
Query::ScrollTo | Query::ScrollBy => {
crate::scroll::set(
cx,
@@ -133,6 +224,25 @@ impl NativeCallable for Method {
crate::scroll::into_view(cx, self.owner, self.desktop)?;
Value::Nil
}
Query::FitView => {
crate::viewport::fit(cx, &mount)?;
Value::Nil
}
Query::Pick3D => {
let desktop = cx
.upgrade(self.desktop)?
.ok_or(VmError::InvalidArgument("viewport desktop expired"))?;
crate::scene3d_model::pick(
cx,
desktop,
&mount,
crate::geometry::Point::new(
crate::scroll::coordinate(&args[0])?,
crate::scroll::coordinate(&args[1])?,
),
)?
}
// 返回帧中已钳制的有效位置与最大偏移,不直接暴露可能尚未投影的 scroll_offset 原值。
Query::ScrollPosition => {
let p = mount
.geometry
@@ -145,6 +255,8 @@ impl NativeCallable for Method {
Value::Object(result)
}
Query::Bounds => {
// 布局只读调用中可查询当前安排阶段的临时 bounds;测量时尚无 bounds,明确报错。
// 非布局期则读取最后提交的 MountGeometry。
let geometry = if let Some(layout) = &mount.layout {
layout
.lock()
@@ -162,6 +274,7 @@ impl NativeCallable for Method {
]
};
let bounds = cx.new_object()?;
// 从本方法返回签名取得 Bounds 的名义类型,避免生成一个仅字段相似的普通对象。
let endor_core::CallType::Single(endor_core::AnnotationType::Nominal(definition)) =
&self.signature.returns
else {
@@ -183,6 +296,7 @@ impl NativeCallable for Method {
)?;
Value::Object(bounds)
}
// 请求焦点先让出现进入可见范围,再检查可聚焦资格并写入桌面请求;后续发布负责实际生效。
Query::Focus => {
crate::scroll::into_view(cx, self.owner, self.desktop)?;
if !mount.geometry.interactive {
@@ -199,12 +313,14 @@ impl NativeCallable for Method {
cx.set_attr(desktop, "focusRequested", Value::Bool(true))?;
Value::Nil
}
// 这里是 Mount 已提交几何的 contains 查询;源 Block 的 hitTest 覆盖由 source_hit 驱动另行执行。
Query::HitTest => {
if mount.layout.is_some() {
return Err(VmError::InvalidArgument(
"Mount hitTest is unavailable during layout",
));
}
// 命中坐标只要求 Number 可转有限 f32;与 scroll::coordinate 不同,此处不限制绝对值至一百万。
let number = |value: &Value| -> Result<f32, VmError> {
let Value::Number(number) = value else {
return Err(VmError::InvalidArgument(
@@ -233,6 +349,7 @@ impl NativeCallable for Method {
Query::Page => mount.page,
_ => unreachable!(),
};
// 弱链接不存在或目标已回收都返回 Nil;升级本身失败才传播 VM 错误。
weak.map(|weak| cx.upgrade(weak))
.transpose()?
.flatten()
@@ -244,6 +361,7 @@ impl NativeCallable for Method {
}
}
/// 声明 Mount 查询方法的源类型契约,区分几何、层次与坐标操作。
fn mount_signature(
store: &endor_core::ObjectStore,
desktop: ObjectRef,
@@ -251,6 +369,7 @@ fn mount_signature(
) -> Result<endor_core::CallSignature, VmError> {
use endor_core::{AnnotationType as T, CallEffect, CallSignature, CallType};
let nominal = |name| crate::ui_bridge::desktop_type(store, name, desktop).map(T::Nominal);
// 层次、拾取和坐标转换可能没有结果,使用 nullable 名义类型;变更操作返回 Unit。
let returns = match query {
Query::Bounds => nominal("Bounds")?.into(),
Query::Parent | Query::FirstChild | Query::NextSibling => {
@@ -258,18 +377,29 @@ fn mount_signature(
}
Query::Page => CallType::nullable(nominal("Page")?),
Query::HitTest => T::Bool.into(),
Query::Pick3D => CallType::nullable(nominal("RayHit3D")?),
Query::ToView | Query::ToLocal => CallType::nullable(nominal("Point")?),
Query::ScrollPosition => T::Any.into(),
_ => T::Unit.into(),
};
// 滚动、相机拟合和焦点会写业务/桌面属性,声明 Mutating;其余查询允许在只读协议中调用。
let effect = if matches!(
query,
Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::Focus
Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::FitView | Query::Focus
) {
CallEffect::Mutating
} else {
CallEffect::ReadOnly
};
let parameters = if matches!(query, Query::HitTest | Query::ScrollTo | Query::ScrollBy) {
let parameters = if matches!(
query,
Query::HitTest
| Query::Pick3D
| Query::ToView
| Query::ToLocal
| Query::ScrollTo
| Query::ScrollBy
) {
vec![T::Number, T::Number]
} else {
vec![]
@@ -280,12 +410,15 @@ fn mount_signature(
}
#[derive(Debug, Default, Clone)]
/// 活动出现的弱索引。真实托管拥有关系在 desktop 的挂载字典中,索引不能充当隐藏 GC 根。
pub struct MountTable {
// Mount ID 到托管弱身份,真实强拥有者是 desktop.mounts 字典;order 单独保存原布局顺序。
pub active: HashMap<u64, WeakObjectRef>,
order: Vec<u64>,
}
#[derive(Debug)]
/// 同步出现关系时的共享临时工作状态,保存本轮重用、建立和退役安排。
struct Sync {
desktop: ObjectRef,
page: Option<ObjectRef>,
@@ -293,11 +426,13 @@ struct Sync {
old: HashMap<u64, WeakObjectRef>,
old_order: Vec<u64>,
frame: Frame,
// true 为布局准备阶段,安装临时 Context 并保留已有滚动偏移;false 为几何提交,清 Context。
preparing: bool,
output: Arc<Mutex<HashMap<u64, WeakObjectRef>>>,
}
#[derive(Debug)]
/// 将一个生命周期回调及参数接入同一 VM 调用序列。
struct LifecycleCall {
function: ObjectRef,
block: ObjectRef,
@@ -305,11 +440,14 @@ struct LifecycleCall {
unmount: bool,
}
impl NativeCallable for LifecycleCall {
/// Block、Mount 与用户钩子都已捕获,生命周期序列无参调用此适配器。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 卸载时先取消 Mount 所有任务,再以 Block/Mount 为实参请求 VM 执行用户 onMount/onUnmount,不在 Rust 中直接运行源体。
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
// 用户 onUnmount 运行前先取消该出现拥有的任务,防止退役后继续交付旧工作。
if self.unmount {
crate::tasks::cancel_owner(cx, self.mount)?;
}
@@ -320,6 +458,7 @@ impl NativeCallable for LifecycleCall {
}
}
/// 构造生命周期调用所需的托管函数与参数,使回调遵循同一 VM 的引用和执行约束。
fn lifecycle_call(
cx: &mut NativeContext<'_>,
mount: ObjectRef,
@@ -334,6 +473,7 @@ fn lifecycle_call(
.store()
.lookup_method(definition, name)?
.ok_or(VmError::InvalidArgument("Block has no lifecycle method"))?;
// 生命周期包装显式捕获函数、Block、Mount,使序列执行到回调前这些对象不会被回收。
cx.allocate(ContextNativeFunction::new(
name,
LifecycleCall {
@@ -347,11 +487,14 @@ fn lifecycle_call(
}
impl NativeCallable for Sync {
/// 展示同步步骤从捕获的帧与弱节点表读取输入,无需外部实参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 把弱业务身份升级为本轮对象映射,再同步帧中出现位置到托管 Mount,建立结构/几何与待通知生命周期。
fn invoke(&self, cx: &mut NativeContext<'_>, _args: &[Value]) -> Result<NativeResult, VmError> {
// 帧只含 Block 字符串身份,先将弱节点表升级为本次同步可用的实际对象映射。
let mut blocks = HashMap::new();
for weak in &self.nodes {
if let Some(block) = cx.upgrade(*weak)? {
@@ -376,6 +519,7 @@ impl NativeCallable for Sync {
.map(|weak| cx.upgrade(*weak))
.transpose()?
.flatten();
// Mount ID 相同仍不足以复用:必须仍挂接、页面相同、真实 Block 相同,防止身份误归属。
let reusable = if let Some(old) = old {
let mount = cx.store().payload::<Mount>(old, "Mount")?;
(mount.mounted && mount.page == page && mount.block == block).then_some(old)
@@ -399,12 +543,17 @@ impl NativeCallable for Sync {
layout: None,
})?;
crate::ui_bridge::attach_desktop_type(cx, object, "Mount", self.desktop)?;
// 每个新 Mount 安装捕获自身的原生方法;captures 中只放 Mount,桌面仍保留弱身份。
for (name, query) in [
("bounds", Query::Bounds),
("scrollTo", Query::ScrollTo),
("scrollBy", Query::ScrollBy),
("scrollPosition", Query::ScrollPosition),
("scrollIntoView", Query::ScrollIntoView),
("fitView", Query::FitView),
("pick3D", Query::Pick3D),
("toView", Query::ToView),
("toLocal", Query::ToLocal),
("parent", Query::Parent),
("firstChild", Query::FirstChild),
("nextSibling", Query::NextSibling),
@@ -431,6 +580,7 @@ impl NativeCallable for Sync {
};
objects.insert(geometry.id, object);
}
// 先建立全部 Mount,再第二遍补链接,保证 parent/child/sibling 无论先后都能找到目标身份。
let root = cx.new_dict()?;
for geometry in &self.frame.mounts {
let object = objects[&geometry.id];
@@ -445,6 +595,7 @@ impl NativeCallable for Sync {
first_child: link(geometry.first_child)?,
next_sibling: link(geometry.next_sibling)?,
geometry: geometry.clone(),
// 准备阶段沿用用户滚动状态;提交阶段以帧中有效偏移为准,非滚动容器归零。
scroll_offset: if self.preparing {
cx.store().payload::<Mount>(object, "Mount")?.scroll_offset
} else {
@@ -452,6 +603,7 @@ impl NativeCallable for Sync {
},
version: self.frame.layout_version,
mounted: true,
// 临时 Context 只有原生派生数据,无托管根;提交时置 None,表示几何可供外部查询。
layout: if self.preparing {
Some(Arc::new(Mutex::new(crate::layout::Context::default())))
} else {
@@ -459,6 +611,7 @@ impl NativeCallable for Sync {
},
};
let old = cx.store().payload::<Mount>(object, "Mount")?;
// 只在上下文、几何、版本、链接或公开挂接属性确实需更新时替换 payload,减少无关变更日志。
if self.preparing
|| old.layout.is_some()
|| !old.mounted
@@ -481,6 +634,7 @@ impl NativeCallable for Sync {
Value::Number((self.frame.layout_version as i64).into()),
)?;
}
// 新字典逐项拥有活动 Mount;弱链接本身不负责让整棵出现树存活。
cx.dict_set(
root,
Value::Text(geometry.id.to_string()),
@@ -490,12 +644,14 @@ impl NativeCallable for Sync {
let mut hooks = Vec::new();
// Layout records are preorder; reversing guarantees children unmount
// before their parents. A Page change creates fresh Mount contexts.
// 旧帧按父先子后记录,逆序退役保证孩子 onUnmount 在父之前;复用对象不退役。
for id in self.old_order.iter().rev() {
let weak = self.old[id];
if let Some(object) = cx.upgrade(weak)?
&& objects.get(id) != Some(&object)
{
let mut mount = cx.store().payload::<Mount>(object, "Mount")?.clone();
// 先撤销挂接和全部关系,再安排回调;onUnmount 不能继续使用失效几何或沿旧链操作。
mount.mounted = false;
mount.layout = None;
mount.parent = None;
@@ -506,9 +662,11 @@ impl NativeCallable for Sync {
hooks.push(lifecycle_call(cx, object, "onUnmount")?);
}
}
// 新建顺序沿帧先序,onMount 默认父先子后;全部旧退役回调排在新挂载回调之前。
for object in created {
hooks.push(lifecycle_call(cx, object, "onMount")?);
}
// 同步只返回生命周期序列,不在此处立即执行用户回调;调用方决定在后续阶段何时运行。
let driver = if hooks.is_empty() {
None
} else {
@@ -523,6 +681,7 @@ impl NativeCallable for Sync {
.into_iter()
.map(|(id, object)| Ok((id, cx.store().downgrade(object)?)))
.collect::<Result<HashMap<_, _>, VmError>>()?;
// 先让桌面拥有新字典,再公布原生弱索引;返回的驱动保持待退役对象直到回调结束。
cx.set_attr(self.desktop, "mounts", Value::Object(root))?;
*self
.output
@@ -536,16 +695,20 @@ impl NativeCallable for Sync {
/// Explicitly owned roots protect the old presentation while preparation
/// temporarily replaces its desktop dictionary and Mount payloads.
/// 布局准备前的挂载快照与临时保留,用于取消恢复;成功提交后必须释放其保留引用。
pub struct Checkpoint {
table: MountTable,
dictionary: Option<ObjectRef>,
// 保存 payload 与 mounted/layoutVersion 两项公开属性;None 表示原先没有属性,回滚要删除。
states: Vec<(ObjectRef, Mount, Option<Value>, Option<Value>)>,
roots: Vec<ObjectRef>,
}
impl Checkpoint {
/// 释放检查点为回滚临时保留的托管根,成功提交后不再需要这些引用。
pub fn release(self, vm: &mut Vm) -> Result<(), VmError> {
let mut result = Ok(());
// 逆序释放所有临时根,单项失败仍继续尝试余下根,最终返回最后一次释放错误。
for root in self.roots.into_iter().rev() {
if let Err(error) = vm.release(root) {
result = Err(error);
@@ -554,6 +717,7 @@ impl Checkpoint {
result
}
/// 恢复检查点中尚未发布的出现关系与布局上下文,并释放临时保留。
pub fn restore(
self,
vm: &mut Vm,
@@ -561,6 +725,7 @@ impl Checkpoint {
table: &mut MountTable,
) -> Result<(), VmError> {
let result = (|| {
// 回滚先识别本轮新增的出现并将其脱离,再恢复原有 Mount payload、公开属性和桌面拥有字典。
let old: std::collections::HashSet<_> =
self.states.iter().map(|state| state.0).collect();
for weak in table.ordered() {
@@ -578,6 +743,7 @@ impl Checkpoint {
}
Ok::<_, VmError>(())
})();
// 保存处理结果后先释放 upgrade 根,再传播处理错误,避免失败路径遗留临时强引用。
vm.release(object)?;
result?;
}
@@ -585,6 +751,7 @@ impl Checkpoint {
for (object, mount, mounted, version) in &self.states {
vm.replace_payload(*object, mount.clone())?;
for (name, value) in [("mounted", mounted), ("layoutVersion", version)] {
// 原属性存在则恢复旧值,不存在则 del_attr;已缺失可忽略,其余删除错误仍要传播。
if let Some(value) = value {
vm.set_attr(*object, name, value.clone())?;
} else {
@@ -606,6 +773,7 @@ impl Checkpoint {
*table = self.table.clone();
Ok(())
})();
// 恢复过程失败也要释放检查点根;先尝试两项操作,再按恢复错误优先的顺序返回。
let released = self.release(vm);
result?;
released
@@ -613,6 +781,7 @@ impl Checkpoint {
}
impl MountTable {
/// 保留布局准备前的 Mount 状态,供失败或取消路径恢复。
pub fn checkpoint(&self, vm: &mut Vm, desktop: ObjectRef) -> Result<Checkpoint, VmError> {
let dictionary = match vm.get_attr(desktop, "mounts") {
Ok(Value::Object(dictionary)) => Some(dictionary),
@@ -621,6 +790,7 @@ impl MountTable {
Err(error) => return Err(error),
};
let mut roots = Vec::new();
// 保留旧字典,避免准备阶段 desktop.mounts 换成新字典后旧出现提前回收,导致无法恢复。
if let Some(dictionary) = dictionary {
vm.retain(dictionary)?;
roots.push(dictionary);
@@ -634,6 +804,7 @@ impl MountTable {
let result = (|| {
for weak in self.ordered() {
if let Some(object) = vm.upgrade(weak)? {
// upgrade 已提供一个拥有根,直接记录待释放列表,不重复 retain;随后读取失败也会统一清理。
checkpoint.roots.push(object);
let mount = vm.payload::<Mount>(object, "Mount")?.clone();
let mut attributes = Vec::new();
@@ -643,6 +814,7 @@ impl MountTable {
Err(VmError::AttributeNotFound(_)) => None,
Err(error) => return Err(error),
};
// 公开属性理论上可能持有对象,快照保存 Value 本身不会自动增加 VM 根,须额外 retain。
if let Some(Value::Object(object)) = &value {
vm.retain(*object)?;
checkpoint.roots.push(*object);
@@ -659,6 +831,7 @@ impl MountTable {
}
Ok::<_, VmError>(())
})();
// 检查点创建到一半失败时释放已经取得的根,不返回一个不完整且可能泄漏的检查点。
if let Err(error) = result {
checkpoint.release(vm)?;
return Err(error);
@@ -668,6 +841,7 @@ impl MountTable {
/// A faulted domain cannot run user onUnmount callbacks. Still detach every
/// context and release its owning dictionary through the normal heap barrier.
/// 退役当前出现表,撤销挂载关系并准备不再展示的生命周期状态。
pub fn retire(&mut self, vm: &mut Vm, desktop: ObjectRef) -> Result<(), VmError> {
for weak in self.ordered().into_iter().rev() {
if let Some(object) = vm.upgrade(weak)? {
@@ -685,6 +859,7 @@ impl MountTable {
result?;
}
}
// 故障退役不执行用户回调;将 desktop.mounts 换成空字典,再清除原生索引,撤销桌面拥有关系。
let dictionary = vm.new_dict()?;
let result = vm.set_attr(desktop, "mounts", Value::Object(dictionary));
vm.release(dictionary)?;
@@ -694,6 +869,7 @@ impl MountTable {
Ok(())
}
/// 按原始展示顺序返回弱 Mount 身份,供事件和协议驱动器保持稳定顺序。
pub fn ordered(&self) -> Vec<WeakObjectRef> {
self.order
.iter()
@@ -701,6 +877,7 @@ impl MountTable {
.collect()
}
/// 根据新结构复用或建立每次出现的 Mount,连接父子关系并准备生命周期调用。
pub fn synchronize(
&mut self,
vm: &mut Vm,
@@ -711,6 +888,7 @@ impl MountTable {
preparing: bool,
) -> Result<Option<ObjectRef>, VmError> {
let output = Arc::new(Mutex::new(HashMap::new()));
// 同步入口显式保活桌面与可选页面;旧表和节点表本身仍是弱索引,不增加隐式根。
let mut captures = vec![desktop];
captures.extend(page);
let function = vm.allocate(ContextNativeFunction::new(
@@ -727,9 +905,11 @@ impl MountTable {
},
captures,
))?;
// 先保存调用结果再释放同步入口,错误时同样清理;只有成功后才替换活动索引与顺序。
let result = vm.call(function, &[]);
vm.release(function)?;
let result = result?;
// 从共享输出移动弱表而不是克隆;生命周期驱动若存在则作为拥有结果交给调用方释放。
self.active = std::mem::take(
&mut *output
.lock()
+28
View File
@@ -1,23 +1,32 @@
//! 给源协议返回的对象列表提供受控封装。列表内容通过托管引用被追踪,读方法只暴露检查后的条目。
//! 用于子节点组成、布局结果和几何输入等需要保留对象身份的场景;不是窗口线程共享的可变容器。
//!
//! Managed indexed host results; values and the at method are traced edges.
use endor_core::{
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef,
Value, VmError,
};
#[derive(Debug)]
/// 托管对象序列,trace 负责拥有其中的对象引用;源代码通过受检查方法读取。
struct ObjectList(Vec<ObjectRef>);
impl ObjectPayload for ObjectList {
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-object-list"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
for mount in &self.0 {
visit(*mount);
@@ -26,8 +35,10 @@ impl ObjectPayload for ObjectList {
}
#[derive(Debug)]
/// 绑定到某个列表对象的下标访问适配器。
struct At(ObjectRef);
impl NativeCallable for At {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(endor_core::CallSignature::exact(
vec![endor_core::AnnotationType::Number],
@@ -35,14 +46,17 @@ impl NativeCallable for At {
endor_core::CallEffect::ReadOnly,
))
}
/// 绑定列表接收者已捕获,只接受一个显式索引。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(1))
}
/// 要求索引可精确转为非负平台整数,检查列表范围后返回对应托管成员,不复制成员对象。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Number(index)] = args else {
return Err(VmError::InvalidArgument("object list at expects an index"));
};
// 先要求可精确表示为 i64 的整数,再转为 usize,拒绝小数、负数及目标平台越界值。
let index = index
.as_i64()
.and_then(|v| usize::try_from(v).ok())
@@ -57,6 +71,7 @@ impl NativeCallable for At {
}
}
/// 将已有对象引用包装为新的 ObjectList,并安装默认名义类型及下标查询方法。
pub fn collection(
cx: &mut NativeContext<'_>,
mounts: Vec<ObjectRef>,
@@ -65,6 +80,8 @@ pub fn collection(
build(cx, mounts, desktop, None)
}
/// 建立列表 payload 和 first/count/at 属性;definition 指定子类时保留该名义类型。
/// 最多接受 10000 个成员;空列表的 first 为 Nil,at 越界仍报错而不返回 Nil。
fn build(
cx: &mut NativeContext<'_>,
mounts: Vec<ObjectRef>,
@@ -76,6 +93,7 @@ fn build(
"object list exceeds 10000 members",
));
}
// 在移动 Vec 之前提取元信息;成员对象的拥有边由 ObjectList::trace 声明。
let first = mounts
.first()
.copied()
@@ -90,44 +108,53 @@ fn build(
}
cx.set_attr(set, "first", first)?;
cx.set_attr(set, "count", Value::Number((count as i64).into()))?;
// at 捕获列表,列表属性又指向 at,形成托管环;显式 captures 让 GC 能追踪和回收此环。
let at = cx.allocate(ContextNativeFunction::new("at", At(set), vec![set]))?;
crate::ui_bridge::attach_desktop_type(cx, at, "Function", desktop)?;
cx.set_attr(set, "at", Value::Object(at))?;
Ok(set)
}
/// 借用受检查列表的条目;返回值的有效期仍依赖原托管对象存活。
pub fn items(store: &endor_core::ObjectStore, list: ObjectRef) -> Result<&[ObjectRef], VmError> {
Ok(&store.payload::<ObjectList>(list, "ObjectList")?.0)
}
#[derive(Debug)]
/// ObjectList 原生类型的可调用构造器,负责检查传入子对象。
pub struct Constructor {
pub signature: Option<endor_core::CallSignature>,
pub desktop: ObjectRef,
}
impl NativeCallable for Constructor {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
self.signature.clone()
}
/// 至少包含请求 Type,之后为可变成员对象与源子类字段,具体分段在 invoke 校验。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::range(1, usize::MAX))
}
/// 按请求类型字段数切分成员/字段,禁止覆盖列表协议保留属性,验证后分配列表并初始化源字段。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Object(definition), values @ ..] = args else {
return Err(VmError::InvalidArgument("ObjectList type argument"));
};
// 首参是要构造的名义类型,余下实参分成“成员对象前缀”和“子类字段后缀”。
let fields = cx
.store()
.payload::<endor_core::EndorTypeObject>(*definition, "Type")?
.fields()
.to_vec();
// 这些名称由列表协议占用;允许子类覆盖会令 count/first/at 与底层成员不一致。
if fields
.iter()
.any(|field| matches!(field.name.as_str(), "count" | "first" | "at"))
{
return Err(VmError::InvalidArgument("reserved ObjectList field"));
}
// 字段按类型定义顺序占据最后 N 项;checked_sub 在实参不足时返回错误,避免下标下溢。
let count = values
.len()
.checked_sub(fields.len())
@@ -136,6 +163,7 @@ impl NativeCallable for Constructor {
))?;
cx.store()
.check_class_field_arguments(*definition, &values[count..])?;
// 字段值先按声明检查;成员前缀另行要求全部是对象,禁止把标量装入宿主对象列表。
let members = values[..count]
.iter()
.map(|value| {
+37
View File
@@ -1,3 +1,6 @@
//! 一个原生窗口对应一份 Presentation:独立视口、Mount、测量缓存、焦点与依赖记录。
//! 多个窗口仍使用同一个 VM 和共享业务对象。读取版本失效时只标记相关展示需要刷新,不能因此复制一份业务状态。
//!
//! Per-window rendering state. Business objects and code remain in one VM.
//! Registries and caches borrow checked identities; the managed desktop owns
//! presentation roots through its pending/mount dictionaries.
@@ -6,21 +9,28 @@ use endor_core::{ChangeBatch, NativeContext, ObjectRef, Vm, VmError, WeakObjectR
use std::sync::Arc;
#[derive(Debug)]
/// 一个窗口独立的展示状态:活动页面、待提交请求、Mount、布局与源读取缓存;共享 VM 的业务状态不放在这里复制。
pub(crate) struct Presentation {
// 所属托管桌面身份;此结构借用身份,真正的展示根由托管桌面字典维持。
pub desktop: ObjectRef,
pub closing: bool,
pub tabs: ObjectRef,
pub active: Option<ObjectRef>,
// 双层 Option 区分三态:None 无请求,Some(None) 请求清空,Some(Some(page)) 请求切页。
pub pending: Option<Option<ObjectRef>>,
pub last_snapshot: Option<Arc<UiNode>>,
// 弱身份到原生稳定标签 ID 的映射;不能因为标签历史而保活已经关闭的页面。
pub tab_ids: Vec<(WeakObjectRef, u64)>,
pub next_tab: u64,
pub last_tabs: Vec<crate::tabs::Tab>,
pub surface: crate::page::Surface,
pub render_cache: crate::snapshot::RenderCache,
pub layout: crate::layout::LayoutRuntime,
pub mounts: crate::mount::MountTable,
pub published_version: u64,
// 即使根快照身份未变也要求重新发布一帧,例如源绘制读取的数据发生变化时。
pub force_frame: bool,
// 精确名称/字典种类依赖,与下面按整个对象版本记录的 reads 分开检查。
pub paint_names: Vec<endor_core::ReadDependency>,
pub layout_names: Vec<endor_core::ReadDependency>,
pub paint_reads: std::collections::BTreeMap<ObjectRef, u64>,
@@ -33,7 +43,10 @@ pub(crate) struct Presentation {
}
impl Presentation {
/// 比较布局/绘制读取版本和名称依赖,标记需要刷新并通知快照缓存。
pub fn observe_changes(&mut self, vm: &Vm, changes: &ChangeBatch) {
// 先检查粗粒度对象版本,layout 优先于 paint;每类只记录第一个失效项用于诊断。
// 对象已经释放时版本查询失败,也视作失效,而非继续使用旧缓存。
if let Some((phase, object, version)) = self
.layout_reads
.iter()
@@ -56,6 +69,8 @@ impl Presentation {
vm.change_version(*object).ok()
);
}
// 再核对名称级依赖,避免仅凭对象版本漏掉名称解析路径上的变化。
// 两个检查独立执行,因此同次观察可能分别增加计数,后者诊断可覆盖前者。
if let Some((phase, dependency)) = self
.layout_names
.iter()
@@ -75,28 +90,33 @@ impl Presentation {
};
self.last_invalid_dependency = format!("{phase} name {}:{name}", dependency.object());
}
// 把同一批 VM 写入交给视觉快照缓存,源布局/绘制依赖与原生快照依赖分别维护。
self.render_cache.invalidate(ChangeBatch {
changes: changes.changes.clone(),
rescan_required: changes.rescan_required,
});
}
/// 按尺寸、内容或绘制失效原因清理对应缓存,避免把所有变化都当成相同重建。
pub fn invalidate(
&mut self,
cx: &mut NativeContext<'_>,
affected: &std::collections::HashSet<ObjectRef>,
reason: &str,
) -> Result<(), VmError> {
// 仅绘制失效不影响测量;其余原因清除测量结果并按 Block ID 使布局缓存失效。
if reason != "paint" {
self.measure_cache.invalidate(affected);
for block in affected {
self.layout.invalidate(&block.to_string());
}
}
// 内容变化还可能改变组成结构,因此显式失效视觉快照及其父链;其他原因不额外做此传播。
if reason == "content" {
self.render_cache
.invalidate_objects(affected.iter().copied());
}
// 仅当受影响 Block 在该窗口有活动出现时强制刷新,避免无关窗口重复发帧。
for weak in self.mounts.active.values() {
if let Some(mount) = cx.upgrade(*weak)?
&& affected.contains(
@@ -111,6 +131,8 @@ impl Presentation {
Ok(())
}
/// 用桌面与标签注册表身份初始化窗口状态,布局和视觉缓存从空状态开始。
// 这里只初始化窗口级缓存,不创建 VM、不复制业务对象;active/pending 留空等待窗口展示请求。
pub fn new(desktop: ObjectRef, tabs: ObjectRef) -> Self {
Self {
desktop,
@@ -122,6 +144,7 @@ impl Presentation {
tab_ids: Vec::new(),
next_tab: 0,
last_tabs: Vec::new(),
surface: crate::page::Surface::default(),
render_cache: crate::snapshot::RenderCache::default(),
layout: crate::layout::LayoutRuntime::default(),
mounts: crate::mount::MountTable::default(),
@@ -140,6 +163,7 @@ impl Presentation {
}
}
/// 把读取记录拆成对象版本和精确名称依赖,便于按实际观察内容判断失效。
pub(crate) fn split_reads(
reads: Vec<endor_core::ReadDependency>,
) -> (
@@ -148,6 +172,7 @@ pub(crate) fn split_reads(
) {
let mut objects = std::collections::BTreeMap::new();
let mut names = Vec::new();
// 整对象依赖按身份收集到有序表,重复项以最后一次版本为准;其余精确依赖保持原记录。
for dependency in reads {
match dependency {
endor_core::ReadDependency::Object { object, version } => {
@@ -164,7 +189,9 @@ mod tests {
use super::*;
use crate::{layout::Size, ui_bridge::UiKind};
#[test]
/// 同VM中两Presentation分别布局共享树,尺寸/出现身份独立;读观察可追踪共享对象但不能增添GC根。
fn independent_view_geometry_and_shared_vm_reads_do_not_add_roots() {
// 测试在同一 VM 中建立两个 Presentation,验证各自布局尺寸/出现 ID 独立,读取依赖共享业务对象。
let mut vm = Vm::new().unwrap();
let mut left = Presentation::new(vm.new_object().unwrap(), vm.new_dict().unwrap());
let mut right = Presentation::new(vm.new_object().unwrap(), vm.new_dict().unwrap());
@@ -177,9 +204,18 @@ mod tests {
height: 160.0,
});
let root = UiNode {
surface: None,
flex: 0.0,
flex_policy: crate::layout::FlexPolicy::default(),
main_align: crate::layout::MainAlign::Start,
cross_align: crate::layout::CrossAlign::Start,
align_self: None,
margin: crate::layout::Insets::default(),
sizing: crate::box_model::Sizing::default(),
padding_edges: [None; 4],
border: crate::layout::Insets::default(),
border_color: "#D8DCE2".into(),
clip_children: false,
block_id: "shared".into(),
protocol_key: "rect".into(),
slots: Vec::new(),
@@ -210,6 +246,7 @@ mod tests {
left.observe_changes(&vm, &changes);
right.observe_changes(&vm, &changes);
assert!(left.force_frame && right.force_frame);
// 最后释放业务对象并回收:两份读取记录都不应成为托管根,弱引用应升级失败。
let weak = vm.downgrade(shared).unwrap();
vm.release(shared).unwrap();
vm.collect_cycles();
+15
View File
@@ -1,23 +1,31 @@
//! 安装 Visual、EventTarget、Children 和 Presentable 等能力契约。
//! 契约描述对象能响应什么源方法;具体测量、绘制、事件执行仍在各自协议驱动器中完成。
//!
//! Host capability definitions over Aster's explicit managed trait relations.
use endor_core::{
AnnotationType, CallArity, CallEffect, CallSignature, ObjectRef, TypedMethodRequirement, Value,
Vm, VmError,
};
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
desktop: ObjectRef,
ui: ObjectRef,
) -> Result<(), VmError> {
// 安装前要求桥接已经创建 Trait 类型与 desktop.types 注册表;缺失时立即失败,不能安装半定义能力。
let Value::Object(trait_type) = vm.get_attr(ui, "Trait")? else {
return Err(VmError::InvalidArgument("missing Trait type"));
};
let Value::Object(types) = vm.get_attr(desktop, "types")? else {
return Err(VmError::InvalidArgument("missing type registry"));
};
// 每组声明能力名、方法及显式参数数目、提供默认签名的原生类型。
// 签名还含接收者参数,下面会把总 arity 调整为 parameters+1。
for (name, methods, owner) in [
(
// 测量/布局/绘制/命中能力,签名取自 Block 的原生协议实现,保证效果约束与具体方法一致。
"Visual",
vec![("measure", 2), ("arrange", 2), ("paint", 2), ("hitTest", 2)],
Some("Block"),
@@ -28,6 +36,7 @@ pub fn install(
Some("Block"),
),
(
// 组成编辑能力属于 ContainerBlock,普通叶 Block 不因此获得追加或重排孩子的方法。
"Children",
vec![
("append", 1),
@@ -41,6 +50,7 @@ pub fn install(
),
("Presentable", vec![("present", 1)], None),
] {
// 从当前 ui 模块查名义类型身份,不靠字符串名称把另一个运行时的类型误接进来。
let owner_definition = if let Some(owner) = owner {
let Value::Object(definition) = vm.get_attr(ui, owner)? else {
return Err(VmError::InvalidArgument("missing protocol owner"));
@@ -51,6 +61,7 @@ pub fn install(
};
let mut requirements = Vec::new();
for (method, parameters) in methods {
// 优先复用原生方法参数/返回注解和调用效果,缺少原生签名元数据时拒绝安装。
let mut signature = if let Some(owner) = owner_definition {
let function = vm
.object_store()
@@ -62,6 +73,7 @@ pub fn install(
"protocol method lacks native types",
))?
} else {
// Presentable 没有默认 owner,手工声明 present(receiver, Window) -> Block,调用效果为 External。
let window = crate::ui_bridge::desktop_type(vm.object_store(), "Window", desktop)?;
let block = crate::ui_bridge::desktop_type(vm.object_store(), "Block", desktop)?;
CallSignature::exact(
@@ -71,6 +83,7 @@ pub fn install(
)
};
signature.arity = CallArity::exact(parameters + 1);
// 接收者类型改为 Any,使能力可由其他符合方法契约的名义类型实现,不把契约绑定死在 Block。
signature.parameters[0] = AnnotationType::Any.into();
requirements.push(TypedMethodRequirement {
name: method.into(),
@@ -78,11 +91,13 @@ pub fn install(
});
}
let capability = vm.new_typed_trait(name, requirements)?;
// 新能力放入宿主 roots 管理生命周期,再同时暴露于 ui 属性与 desktop 类型注册表。
roots.push(capability);
vm.attach_type(capability, trait_type)?;
vm.set_attr(ui, name, Value::Object(capability))?;
vm.dict_set(types, Value::Text(name.into()), Value::Object(capability))?;
if let Some(owner) = owner_definition {
// 有默认提供者时显式登记 implements 关系;Presentable 留待具体类型声明,不自动给所有 Block 安装。
vm.implement_trait(owner, capability)?;
}
}
+970
View File
@@ -0,0 +1,970 @@
//! 将 Endor 托管场景对象转换成可供渲染线程读取的 3D 数据。
//! 构造器和字段仍属于 VM;快照复制必要的数值、不可变网格和依赖,渲染器不读取托管堆。
//! 场景遍历组合局部变换、检查环和资源限制,并为 Panel3D 提取独立 UI 根。控制器更新相机;拾取结果再映射回托管节点。
//!
//! Traced 3D objects and checked immutable snapshots. No managed handle enters
//! the renderer; cameras and node transforms remain editable Endor attributes.
use crate::{
object_list,
scene3d::{
self,
math::{Camera, Matrix, Projection, Vec3},
},
};
use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, VmError};
use std::{collections::HashSet, sync::Arc};
#[derive(Debug, Clone, Copy)]
/// 源 3D 类型的构造元数据入口,统一管理名称、参数、数量和可变子项。
pub enum Constructor {
Vec3,
Triangle,
Geometry,
Mesh,
Box,
Group,
Scene,
Light,
Perspective,
Orthographic,
Orbit,
Panel,
}
impl Constructor {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
match self {
Self::Vec3 => "Vec3",
Self::Triangle => "Triangle3D",
Self::Geometry => "Geometry3D",
Self::Mesh => "Mesh3D",
Self::Box => "Box3D",
Self::Group => "Group3D",
Self::Scene => "Scene3D",
Self::Light => "DirectionalLight",
Self::Perspective => "PerspectiveCamera",
Self::Orthographic => "OrthographicCamera",
Self::Orbit => "OrbitController",
Self::Panel => "Panel3D",
}
}
/// 取得本构造器对应的源工厂函数名称。
pub fn function(self) -> &'static str {
match self {
Self::Vec3 => "vec3",
Self::Triangle => "triangle3D",
Self::Geometry => "geometry3D",
Self::Mesh => "mesh3D",
Self::Box => "box3D",
Self::Group => "group3D",
Self::Scene => "scene3D",
Self::Light => "directionalLight",
Self::Perspective => "perspectiveCamera",
Self::Orthographic => "orthographicCamera",
Self::Orbit => "orbitController",
Self::Panel => "panel3D",
}
}
/// 反查源构造器名称对应的原生 3D 种类,不认识的名称返回空值。
pub fn from_function(name: &str) -> Option<Self> {
[
Self::Vec3,
Self::Triangle,
Self::Geometry,
Self::Mesh,
Self::Box,
Self::Group,
Self::Scene,
Self::Light,
Self::Perspective,
Self::Orthographic,
Self::Orbit,
Self::Panel,
]
.into_iter()
.find(|kind| kind.function() == name)
}
/// 返回三维原生构造的显式最小/最大参数数,不含请求 Type;Group/Scene 支持最多4096子节点。
pub fn arity(self) -> (usize, usize) {
// 构造器 arity 不含 NativeTyped 的类型首参,Group/Scene 允许零到 4096 个孩子,其他零参对象使用默认属性。
match self {
Self::Vec3 | Self::Triangle => (3, 3),
Self::Geometry | Self::Mesh => (2, 2),
Self::Box => (4, 4),
Self::Group | Self::Scene => (0, 4096),
Self::Light => (5, 5),
Self::Panel => (3, 3),
_ => (0, 0),
}
}
/// 列出本构造器的参数类型名称,供签名安装与源调用检查使用。
pub fn parameters(self) -> &'static [&'static str] {
match self {
Self::Vec3 | Self::Triangle => &["Number", "Number", "Number"],
Self::Geometry => &["ObjectList", "ObjectList"],
Self::Mesh => &["Geometry3D", "String"],
Self::Box => &["Number", "Number", "Number", "String"],
Self::Light => &["Number", "Number", "Number", "String", "Number"],
Self::Panel => &["Number", "Number", "Block"],
_ => &[],
}
}
/// 标记构造器是否接收数量可变的子对象。
pub fn variadic(self) -> bool {
matches!(self, Self::Group | Self::Scene)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// 托管 3D 节点的名义种类,防止仅有同名字段的任意对象冒充场景节点。
pub enum NodeKind {
Vector,
Triangle,
Mesh,
Group,
Scene,
Light,
Perspective,
Orthographic,
Orbit,
Panel,
}
#[derive(Debug)]
/// 允许普通属性的 3D 标记 payload;数值字段仍由托管属性系统拥有。
pub struct Node(pub NodeKind);
impl ObjectPayload for Node {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"scene-3d-object"
}
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
}
#[derive(Debug)]
/// 托管不可变网格包装,顶点和三角形数值已经复制为原生 Arc 数据。
pub struct Geometry(pub Arc<scene3d::Geometry>);
impl ObjectPayload for Geometry {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"geometry-3d"
}
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
}
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 把源 Value 转换为受检查的托管对象引用,拒绝非对象输入。
fn object_ref(value: &Value) -> Result<ObjectRef, VmError> {
if let Value::Object(object) = value {
Ok(*object)
} else {
Err(invalid("3D field requires an object"))
}
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
fn number(value: &Value) -> Result<f32, VmError> {
crate::scroll::coordinate(value)
}
/// 把当前源值或文本选择解码成此调用需要的文本表示。
fn text(value: &Value) -> Result<String, VmError> {
if let Value::Text(text) = value {
Ok(text.clone())
} else {
Err(invalid("3D field requires text"))
}
}
/// 把经过转换的数值写回指定托管字段,仍通过 VM/NativeContext 的检查入口。
fn set_number(
cx: &mut NativeContext<'_>,
object: ObjectRef,
name: &str,
value: f32,
) -> Result<(), VmError> {
cx.set_attr(object, name, crate::native_visual::value(value))
}
/// 解析颜色值并检查其满足当前原生绘制约定。
fn color(value: &str) -> Result<[f32; 3], VmError> {
// 3D 不透明材质拒绝半透明颜色,转换为 [0,1] RGB 数值;普通二维 UI 的 alpha 不受这里限制。
let color = crate::document::parse_color(value).map_err(|_| invalid("invalid 3D color"))?;
if color.alpha != 255 {
return Err(invalid("3D opaque material color requires full alpha"));
}
Ok([
color.red as f32 / 255.,
color.green as f32 / 255.,
color.blue as f32 / 255.,
])
}
/// 按构造器建立对应托管 3D 类型,检查几何输入并安装默认属性。
pub fn allocate(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
definition: ObjectRef,
constructor: Constructor,
args: &[Value],
) -> Result<ObjectRef, VmError> {
// 入口假设桥接工厂已按 arity/类型检查 args;Geometry 特殊地复制缓冲并提前返回,其余创建可编辑标记节点。
let kind = match constructor {
Constructor::Vec3 => NodeKind::Vector,
Constructor::Triangle => NodeKind::Triangle,
Constructor::Mesh | Constructor::Box => NodeKind::Mesh,
Constructor::Group => NodeKind::Group,
Constructor::Scene => NodeKind::Scene,
Constructor::Light => NodeKind::Light,
Constructor::Perspective => NodeKind::Perspective,
Constructor::Orthographic => NodeKind::Orthographic,
Constructor::Orbit => NodeKind::Orbit,
Constructor::Panel => NodeKind::Panel,
Constructor::Geometry => {
// Geometry 构造只接受受控 ObjectList,逐成员验证 Vec3 payload 并复制数值;以后改源 Vec3 不会改已建几何。
let vertices = object_list::items(cx.store(), object_ref(&args[0])?)?
.iter()
.map(|&item| {
let p = cx.store();
if p.payload::<Node>(item, "Vec3")?.0 != NodeKind::Vector {
return Err(invalid("geometry vertices require Vec3"));
}
Ok(Vec3::new(
number(&p.get_attr(item, "x")?)?,
number(&p.get_attr(item, "y")?)?,
number(&p.get_attr(item, "z")?)?,
))
})
.collect::<Result<Vec<_>, VmError>>()?;
let triangles = object_list::items(cx.store(), object_ref(&args[1])?)?
.iter()
.map(|&item| {
if cx.store().payload::<Node>(item, "Triangle3D")?.0 != NodeKind::Triangle {
return Err(invalid("geometry indices require Triangle3D"));
}
let mut triangle = [0; 3];
// 索引先从 Number 检查非负整数,再转 u32;是否越出顶点范围在整份 geometry.valid 校验。
for (index, name) in ["a", "b", "c"].into_iter().enumerate() {
let value = number(&cx.store().get_attr(item, name)?)?;
if value < 0. || value.fract() != 0. {
return Err(invalid("triangle indices must be nonnegative integers"));
}
triangle[index] = value as u32;
}
Ok(triangle)
})
.collect::<Result<Vec<_>, VmError>>()?;
let geometry = scene3d::Geometry {
vertices,
triangles,
uv: vec![],
};
// 缓存为原生 Arc 前统一验证顶点/三角形预算与下标范围,避免渲染线程再接触源缓冲对象。
if !geometry.valid() {
return Err(invalid(
"invalid Geometry3D buffers or geometry budget exceeded",
));
}
let object = cx.allocate(Geometry(Arc::new(geometry)))?;
cx.attach_type(object, definition)?;
return Ok(object);
}
};
let instance = cx.allocate(Node(kind))?;
cx.attach_type(instance, definition)?;
match kind {
NodeKind::Vector | NodeKind::Triangle => {
for (name, value) in (if kind == NodeKind::Vector {
["x", "y", "z"]
} else {
["a", "b", "c"]
})
.into_iter()
.zip(args)
{
set_number(cx, instance, name, number(value)?)?;
}
}
NodeKind::Mesh => {
let (geometry, material) = if matches!(constructor, Constructor::Box) {
let (w, h, d) = (number(&args[0])?, number(&args[1])?, number(&args[2])?);
// Box 允许零尺寸产生退化面,后续光栅/拾取跳过退化三角形;负尺寸在构造入口拒绝。
if w < 0. || h < 0. || d < 0. {
return Err(invalid("Box3D dimensions must be nonnegative"));
}
let geometry =
cx.allocate(Geometry(Arc::new(scene3d::Geometry::cuboid(w, h, d))))?;
crate::ui_bridge::attach_desktop_type(cx, geometry, "Geometry3D", desktop)?;
(geometry, text(&args[3])?)
} else {
(object_ref(&args[0])?, text(&args[1])?)
};
cx.store().payload::<Geometry>(geometry, "Geometry3D")?;
color(&material)?;
cx.set_attr(instance, "geometry", Value::Object(geometry))?;
cx.set_attr(instance, "color", Value::Text(material))?;
}
// 孩子只接受 Mesh/Group/Light/Panel 标记,不允许把相机、控制器或任意 Object 放进场景组成。
NodeKind::Group | NodeKind::Scene => {
let children = args.iter().map(object_ref).collect::<Result<Vec<_>, _>>()?;
for child in &children {
if !matches!(
cx.store().payload::<Node>(*child, "3D node")?.0,
NodeKind::Mesh | NodeKind::Group | NodeKind::Light | NodeKind::Panel
) {
return Err(invalid(
"Scene3D/Group3D children require meshes, groups or lights",
));
}
}
// 托管 children 列表拥有子节点;Node payload 自身只是标记,属性拥有边由 VM 正常追踪。
let children = object_list::collection(cx, children, desktop)?;
cx.set_attr(instance, "children", Value::Object(children))?;
if kind == NodeKind::Scene {
set_number(cx, instance, "ambient", 0.25)?;
cx.set_attr(instance, "background", Value::Text("#F1F3F6".into()))?;
}
}
NodeKind::Light => {
for (name, value) in ["directionX", "directionY", "directionZ"]
.into_iter()
.zip(args)
{
set_number(cx, instance, name, number(value)?)?;
}
color(&text(&args[3])?)?;
cx.set_attr(instance, "color", args[3].clone())?;
set_number(cx, instance, "intensity", number(&args[4])?)?;
}
// 世界宽高与 UI 像素分辨率独立:初始纹理 512×320,content 是普通 Block 图,稍后参与独立有限布局。
NodeKind::Panel => {
let content = object_ref(&args[2])?;
crate::block::validate(cx.store(), content, None)?;
set_number(cx, instance, "width", number(&args[0])?)?;
set_number(cx, instance, "height", number(&args[1])?)?;
set_number(cx, instance, "uiWidth", 512.)?;
set_number(cx, instance, "uiHeight", 320.)?;
cx.set_attr(instance, "content", Value::Object(content))?;
cx.set_attr(instance, "background", Value::Text("#FFFFFF".into()))?;
}
NodeKind::Perspective | NodeKind::Orthographic => {
for (name, value) in [
("x", 0.),
("y", 0.),
("z", 5.),
("targetX", 0.),
("targetY", 0.),
("targetZ", 0.),
("upX", 0.),
("upY", 1.),
("upZ", 0.),
("near", 0.1),
("far", 1000.),
("fov", 50.),
("height", 5.),
] {
set_number(cx, instance, name, value)?;
}
}
NodeKind::Orbit => {
set_number(cx, instance, "rotationSpeed", 0.005)?;
set_number(cx, instance, "panSpeed", 1.)?;
}
}
// 只有可放置节点安装 TRS;相机用 position/target/up,方向光用方向向量,不复用网格欧拉角字段。
if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
for (name, value) in [
("x", 0.),
("y", 0.),
("z", 0.),
("rotationX", 0.),
("rotationY", 0.),
("rotationZ", 0.),
("scaleX", 1.),
("scaleY", 1.),
("scaleZ", 1.),
] {
set_number(cx, instance, name, value)?;
}
}
Ok(instance)
}
/// 快照字段读取器,同时记录实际观察到的对象/属性依赖。
struct Reader<'a> {
store: &'a ObjectStore,
reads: HashSet<(ObjectRef, String)>,
}
impl Reader<'_> {
/// 读取字段并登记依赖,使后续字段变更能够使 3D 快照失效。
fn value(&mut self, object: ObjectRef, name: &str) -> Result<Value, VmError> {
// 读取前登记精确对象/属性依赖,HashSet 去重;返回给视觉缓存后,相关字段变化才能驱动重建。
self.reads.insert((object, name.into()));
self.store.get_attr(object, name)
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
fn number(&mut self, object: ObjectRef, name: &str) -> Result<f32, VmError> {
number(&self.value(object, name)?)
}
/// 连续读取三个数值字段,组成位置、旋转或缩放向量。
fn vector(&mut self, object: ObjectRef, names: [&str; 3]) -> Result<Vec3, VmError> {
Ok(Vec3::new(
self.number(object, names[0])?,
self.number(object, names[1])?,
self.number(object, names[2])?,
))
}
/// 读取并校验透视或正交摄像机字段,生成不持有 VM 引用的投影描述。
fn camera(&mut self, object: ObjectRef) -> Result<Camera, VmError> {
// 先验证宿主相机种类,再按投影策略读取 fov 或 height;不把有同名字段的对象当相机。
let projection = match self.store.payload::<Node>(object, "3D camera")?.0 {
NodeKind::Perspective => Projection::Perspective {
fov: self.number(object, "fov")?,
},
NodeKind::Orthographic => Projection::Orthographic {
height: self.number(object, "height")?,
},
_ => {
return Err(invalid(
"Viewport3D requires a PerspectiveCamera or OrthographicCamera",
));
}
};
let camera = Camera {
position: self.vector(object, ["x", "y", "z"])?,
target: self.vector(object, ["targetX", "targetY", "targetZ"])?,
up: self.vector(object, ["upX", "upY", "upZ"])?,
near: self.number(object, "near")?,
far: self.number(object, "far")?,
projection,
};
if !camera.valid() {
return Err(invalid("invalid 3D camera basis or projection range"));
}
Ok(camera)
}
}
/// 从场景中提取的有限 UI 根,携带稳定面板 key 和逻辑排版尺寸。
pub struct PanelMount {
pub key: u64,
pub content: ObjectRef,
pub size: crate::layout::Size,
}
// 三部分分别是可跨线程的不可变 View、VM 内缓存用的属性依赖、仍由 VM 驱动布局的面板 Block 根。
pub type Snapshot = (
Arc<scene3d::View>,
Vec<(ObjectRef, String)>,
Vec<PanelMount>,
);
/// 将当前托管或可变状态提取为后续阶段使用的派生表示。
pub fn snapshot(
store: &ObjectStore,
scene: ObjectRef,
camera: ObjectRef,
) -> Result<Snapshot, VmError> {
snapshot_reusing(store, scene, camera, None)
}
/// 遍历场景并记录字段依赖;如果世界数据未变则复用不可变场景和 BVH,只替换摄像机视图。
pub fn snapshot_reusing(
store: &ObjectStore,
scene: ObjectRef,
camera: ObjectRef,
reuse: Option<Arc<scene3d::Scene>>,
) -> Result<Snapshot, VmError> {
if store.payload::<Node>(scene, "Scene3D")?.0 != NodeKind::Scene {
return Err(invalid("Viewport3D requires a Scene3D"));
}
let mut reader = Reader {
store,
reads: HashSet::new(),
};
let camera = reader.camera(camera)?;
let ambient = reader.number(scene, "ambient")?;
if !(0.0..=1.0).contains(&ambient) {
return Err(invalid("Scene3D.ambient expects 0 through 1"));
}
let background = color(&text(&reader.value(scene, "background")?)?)?;
let mut output = scene3d::Scene {
meshes: Vec::new(),
lights: Vec::new(),
ambient,
background,
acceleration: Arc::default(),
};
// 显式进入/退出栈保存节点、父世界矩阵、退出标记与路径 key;path 仅追踪祖先,因此允许共享子树多次出现。
let mut work = vec![(scene, Matrix::default(), false, 0u64)];
let mut panels = Vec::new();
let mut path = HashSet::new();
let mut visits = 0;
let mut triangles = 0;
let mut vertices = 0;
while let Some((object, parent, finish, key)) = work.pop() {
if finish {
path.remove(&object);
continue;
}
visits += 1;
// 预算按展开出现次数,重复共享同一网格也消耗实例/顶点/三角形工作,不能只数不同对象身份。
if visits > 4096 {
return Err(VmError::ExecutionLimit("3D scene occurrences"));
}
if !path.insert(object) {
return Err(invalid("cyclic 3D scene graph"));
}
work.push((object, parent, true, key));
let kind = store.payload::<Node>(object, "3D node")?.0;
let world = if matches!(kind, NodeKind::Mesh | NodeKind::Group | NodeKind::Panel) {
// 局部先缩放、旋转、平移,再乘父 world;Scene/Light 不新增 TRS,方向光仍受祖先 Group 变换。
parent.compose(Matrix::trs(
reader.vector(object, ["x", "y", "z"])?,
reader.vector(object, ["rotationX", "rotationY", "rotationZ"])?,
reader.vector(object, ["scaleX", "scaleY", "scaleZ"])?,
))
} else {
parent
};
match kind {
NodeKind::Scene | NodeKind::Group => {
let list = object_ref(&reader.value(object, "children")?)?;
for (index, child) in object_list::items(store, list)?.iter().enumerate().rev() {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
// 路径 key 包含父 key、当前孩子索引和托管身份,区分同一 Panel 节点在不同路径的出现。
// 此 key 与普通 Block slot 不同,场景列表重排会改变路径身份。
key.hash(&mut hasher);
index.hash(&mut hasher);
child.hash(&mut hasher);
work.push((*child, world, false, hasher.finish()));
}
}
NodeKind::Mesh => {
let geometry = object_ref(&reader.value(object, "geometry")?)?;
let geometry = store.payload::<Geometry>(geometry, "Geometry3D")?.0.clone();
// 每次 mesh 出现都累计完整几何,Arc 共享只节约存储,不减少本帧实例绘制工作预算。
triangles += geometry.triangles.len();
vertices += geometry.vertices.len();
if vertices > 262_144 {
return Err(VmError::ExecutionLimit("3D scene vertices"));
}
if triangles > scene3d::MAX_TRIANGLES {
return Err(VmError::ExecutionLimit("3D scene triangles"));
}
let material = color(&text(&reader.value(object, "color")?)?)?;
output.meshes.push(scene3d::Mesh {
identity: object.to_string(),
geometry,
world,
color: material,
panel: None,
});
}
NodeKind::Panel => {
// Arrangement slots cross Endor's signed integer API. A raw
// u64 hash can turn negative when native Bounds are created.
// Bounds.slot 经有符号整数 API 传递,清最高位避免变负;仍显式检测碰撞,不能假设哈希必然唯一。
let key = key & i64::MAX as u64;
if panels.iter().any(|panel: &PanelMount| panel.key == key) {
return Err(invalid("colliding 3D UI occurrence identities"));
}
// 有限面板根数量另限 64;每个面板还有独立像素分辨率限制和递归纹理总预算。
if panels.len() >= 64 {
return Err(VmError::ExecutionLimit("3D UI panels"));
}
let width = reader.number(object, "width")?;
let height = reader.number(object, "height")?;
let ui_width = reader.number(object, "uiWidth")?;
let ui_height = reader.number(object, "uiHeight")?;
if width <= 0.
|| height <= 0.
|| ![ui_width, ui_height]
.iter()
.all(|size| (1.0..=2048.0).contains(size) && size.fract() == 0.)
{
return Err(invalid(
"Panel3D requires positive world dimensions and integer UI dimensions from 1 through 2048",
));
}
let content = object_ref(&reader.value(object, "content")?)?;
crate::block::resolve(store, content)?;
let background = color(&text(&reader.value(object, "background")?)?)?;
// 面板平面以中心为原点,顶点 y 向上,UV 左上为 (0,0);两三角形共用四个角点。
let geometry = Arc::new(scene3d::Geometry {
vertices: vec![
Vec3::new(-width / 2., height / 2., 0.),
Vec3::new(width / 2., height / 2., 0.),
Vec3::new(width / 2., -height / 2., 0.),
Vec3::new(-width / 2., -height / 2., 0.),
],
triangles: vec![[0, 1, 2], [0, 2, 3]],
uv: vec![[0., 0.], [1., 0.], [1., 1.], [0., 1.]],
});
output.meshes.push(scene3d::Mesh {
identity: object.to_string(),
geometry,
world,
color: [1., 1., 1.],
panel: Some(scene3d::Panel {
key,
width: ui_width as u32,
height: ui_height as u32,
background,
surface: None,
}),
});
// 同时把普通 UI 根交给宿主布局,scene Mesh 先只带分辨率/背景,surface 由后续 panel::compose 装配。
panels.push(PanelMount {
key,
content,
size: crate::layout::Size {
width: ui_width,
height: ui_height,
},
});
}
NodeKind::Light => {
if output.lights.len() >= 32 {
return Err(VmError::ExecutionLimit("3D scene lights"));
}
let direction =
reader.vector(object, ["directionX", "directionY", "directionZ"])?;
// 用两点之差撤销父矩阵平移,仅保留线性部分对方向的影响,再归一化;零方向拒绝。
let direction = (world.point(direction) - world.point(Vec3::default()))
.normalized()
.ok_or(invalid("light direction cannot be zero"))?;
let color = color(&text(&reader.value(object, "color")?)?)?;
let intensity = reader.number(object, "intensity")?;
if !(0.0..=32.0).contains(&intensity) {
return Err(invalid("light intensity expects 0 through 32"));
}
output.lights.push(scene3d::Light {
direction,
color,
intensity,
});
}
_ => return Err(invalid("invalid node in 3D scene graph")),
}
}
// 完整世界值相等时复用旧 Scene/BVH,单独换 camera 生成新 View;不相等才重建世界空间索引。
let scene = if let Some(scene) = reuse.filter(|scene| **scene == output) {
scene
} else {
output.acceleration = Arc::new(scene3d::spatial::Index::build(&output.meshes));
Arc::new(output)
};
let view = Arc::new(scene3d::View { scene, camera });
// 提取各项之后仍做完整视图校验,覆盖累计世界变换、光照、面板及整体资源不变量。
if !view.valid() {
return Err(invalid("invalid 3D scene snapshot"));
}
Ok((view, reader.reads.into_iter().collect(), panels))
}
/// 把视口中的指针射线结果转换成 RayHit3D,关联实际托管网格和交点数据。
pub fn pick(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
mount: &crate::mount::Mount,
point: crate::geometry::Point,
) -> Result<Value, VmError> {
if mount.layout.is_some() {
return Err(invalid("pick3D requires a committed viewport"));
}
let (_, data) = crate::block::resolve(cx.store(), mount.block)?;
let data = crate::block::visual_data(cx.store(), data)?;
if !matches!(data.kind, crate::block::Kind::Viewport3D { .. }) {
return Err(invalid("pick3D requires a Viewport3D"));
}
// 先做出现/面板遮挡和裁剪命中,未命中直接 Nil;即使场景射线有交点也不能点穿外层遮挡。
if !mount.geometry.contains(point) {
return Ok(Value::Nil);
}
let node = crate::block::snapshot(cx.store(), mount.block)?;
let inset = crate::layout::insets(&node);
let Some(point) = mount.geometry.logical_point(point) else {
return Ok(Value::Nil);
};
// 从布局盒扣 inset 得到实际内容视口,再把逻辑点归一化;零面积视口没有有效投影。
let (width, height) = (
(mount.geometry.width - inset.horizontal()).max(0.),
(mount.geometry.height - inset.vertical()).max(0.),
);
if width <= 0. || height <= 0. {
return Ok(Value::Nil);
}
let (view, reads, _) = snapshot(
cx.store(),
data.scene3d.ok_or(invalid("missing Scene3D"))?,
data.camera.ok_or(invalid("missing 3D camera"))?,
)?;
let hit = view.pick(
(point.x - mount.geometry.x - inset.left) / width,
(point.y - mount.geometry.y - inset.top) / height,
width / height,
);
let Some(hit) = hit else {
return Ok(Value::Nil);
};
// 原生 Hit.identity 只带文本,通过本次实际读取的托管身份重新关联节点;渲染器本身从不解引用 VM 对象。
let mesh = reads
.iter()
.find_map(|(object, _)| (object.to_string() == hit.identity).then_some(*object))
.ok_or(invalid("picked mesh is unavailable"))?;
let result = cx.new_object()?;
crate::ui_bridge::attach_desktop_type(cx, result, "RayHit3D", desktop)?;
cx.set_attr(result, "mesh", Value::Object(mesh))?;
cx.set_attr(result, "node", Value::Object(mesh))?;
cx.set_attr(
result,
"instance",
Value::Number((hit.instance as i64).into()),
)?;
cx.set_attr(
result,
"distance",
crate::native_visual::value(hit.distance),
)?;
cx.set_attr(
result,
"triangle",
Value::Number((hit.triangle as i64).into()),
)?;
// 交点、法线、重心权重都包装为名义 Vec3,结果对象通过普通属性强持有它们。
for (name, vector) in [
("point", hit.point),
("normal", hit.normal),
(
"barycentric",
Vec3::new(hit.barycentric[0], hit.barycentric[1], hit.barycentric[2]),
),
] {
let definition = crate::ui_bridge::desktop_type(cx.store(), "Vec3", desktop)?;
let vector = allocate(
cx,
desktop,
definition,
Constructor::Vec3,
&[
crate::native_visual::value(vector.x),
crate::native_visual::value(vector.y),
crate::native_visual::value(vector.z),
],
)?;
cx.set_attr(result, name, Value::Object(vector))?;
}
Ok(Value::Object(result))
}
#[derive(Debug)]
/// 指针拾取的中间结果,保留交点与所属实例信息供 Event/RayHit3D 使用。
pub struct PointerPick {
pub mount: ObjectRef,
pub desktop: ObjectRef,
pub point: crate::geometry::Point,
}
/// The default controller is replaceable; camera data has no dependency on it.
/// 默认 OrbitController 根据已解码的 CameraGesture 更新相机,处理轨道、平移和缩放;可由源控制器替换。
pub fn control(
cx: &mut NativeContext<'_>,
controller: ObjectRef,
camera_ref: ObjectRef,
gesture: ObjectRef,
) -> Result<(), VmError> {
if cx.store().payload::<Node>(controller, "OrbitController")?.0 != NodeKind::Orbit {
return Err(invalid("invalid orbit controller"));
}
let mut reader = Reader {
store: cx.store(),
reads: HashSet::new(),
};
let camera = reader.camera(camera_ref)?;
let dx = reader.number(gesture, "dx")?;
let dy = reader.number(gesture, "dy")?;
let kind = text(&reader.value(gesture, "kind")?)?;
// 默认轨道控制围绕 target 使用 position-target 偏移和半径;相机有效性保证观察方向不退化。
let offset = camera.position - camera.target;
let radius = offset.length();
let mut position = camera.position;
let mut target = camera.target;
match kind.as_str() {
"scene-orbit" => {
let speed = reader.number(controller, "rotationSpeed")?;
if !(0.0..=1.0).contains(&speed) {
return Err(invalid("rotationSpeed expects 0 through 1"));
}
let up = camera
.up
.normalized()
.ok_or(invalid("camera up cannot be zero"))?;
// 将偏移分解为 up 方向和水平面方向,用水平旋转更新 yaw、asin(vertical) 更新 pitch。
let vertical = (offset.dot(up) / radius).clamp(-1., 1.);
let horizontal = (offset - up * offset.dot(up))
.normalized()
.ok_or(invalid("orbit at pole requires a different up vector"))?;
let (sine, cosine) = (dx * speed).sin_cos();
let horizontal = horizontal * cosine + up.cross(horizontal) * sine;
// 俯仰限制在两极内侧 0.01 弧度,避免绕极点时水平基退化并导致轨道跳变。
let pitch = (vertical.asin() + dy * speed).clamp(
-std::f32::consts::FRAC_PI_2 + 0.01,
std::f32::consts::FRAC_PI_2 - 0.01,
);
position = target + horizontal * (radius * pitch.cos()) + up * (radius * pitch.sin());
}
"scene-pan" => {
let speed = reader.number(controller, "panSpeed")?;
if !(0.0..=100.0).contains(&speed) {
return Err(invalid("panSpeed expects 0 through 100"));
}
let height = reader.number(gesture, "height")?.max(1.);
// 把逻辑像素位移换成世界单位:透视按 target 距离处的可见高度,正交按固定投影高度。
let units = match camera.projection {
Projection::Perspective { fov } => {
2. * radius * (fov.to_radians() / 2.).tan() / height
}
Projection::Orthographic { height: extent } => extent / height,
};
let basis = camera.basis().ok_or(invalid("invalid camera basis"))?;
let delta = basis.right * (dx * units * speed) + basis.up * (-dy * units * speed);
// 平移同时移动 position 和 target,保留观察方向与轨道半径;屏幕 y 向下所以 up 分量取负。
position = position + delta;
target = target + delta;
}
"scene-zoom" => {
let factor = reader.number(gesture, "factor")?;
if !(0.1..=10.0).contains(&factor) {
return Err(invalid("zoom factor expects 0.1 through 10"));
}
// 正交缩放只改投影高度,透视缩放改变到 target 的距离;倍率大于一均表现为拉近。
if let Projection::Orthographic { height } = camera.projection {
set_number(
cx,
camera_ref,
"height",
(height / factor).clamp(0.001, 1_000_000.),
)?;
} else {
let next =
(radius / factor).clamp(camera.near * 2., camera.far.max(camera.near * 2.));
position = target + offset * (next / radius);
}
}
_ => return Err(invalid("unsupported 3D camera gesture")),
}
// 写回前校验整个计算结果,拒绝超世界范围;控制器通过普通属性更新让后续快照依赖观察变化。
if !position.valid() || !target.valid() {
return Err(invalid("camera movement exceeds world budget"));
}
for (name, value) in [
("x", position.x),
("y", position.y),
("z", position.z),
("targetX", target.x),
("targetY", target.y),
("targetZ", target.z),
] {
set_number(cx, camera_ref, name, value)?;
}
Ok(())
}
/// 对允许的 3D 节点累计平移或角度旋转;相机平移同时更新 target,旋转值归一到 [0,360)。
pub fn transform(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
args: &[Value],
rotate: bool,
) -> Result<(), VmError> {
let kind = cx.store().payload::<Node>(owner, "3D transform target")?.0;
let camera = matches!(kind, NodeKind::Perspective | NodeKind::Orthographic);
if !matches!(
kind,
NodeKind::Mesh
| NodeKind::Group
| NodeKind::Panel
| NodeKind::Perspective
| NodeKind::Orthographic
) || rotate && camera
{
return Err(invalid("unsupported 3D transform target"));
}
// 旋转增量作用欧拉角,平移增量作用位置;相机不接受本接口的欧拉角旋转。
let names = if rotate {
["rotationX", "rotationY", "rotationZ"]
} else {
["x", "y", "z"]
};
// 先收集并验证全部轴再写入,避免验证到第二/三轴才发现越界时已修改前面轴。
let mut updates = Vec::new();
for (index, name) in names.into_iter().enumerate() {
let delta = number(&args[index])?;
let value = number(&cx.store().get_attr(owner, name)?)? + delta;
if !value.is_finite() || !rotate && value.abs() > 1_000_000. {
return Err(invalid("3D transform exceeds world budget"));
}
updates.push((
name,
if rotate {
// 角度归一到 [0,360),支持负增量而不使用产生负余数的普通取模。
value.rem_euclid(360.)
} else {
value
},
));
// 相机平移同步改 target,保留朝向;不把“移动摄像机”意外变成绕固定目标转动。
if camera {
let name = ["targetX", "targetY", "targetZ"][index];
let value = number(&cx.store().get_attr(owner, name)?)? + delta;
if value.abs() > 1_000_000. {
return Err(invalid("camera target exceeds world budget"));
}
updates.push((name, value));
}
}
for (name, value) in updates {
set_number(cx, owner, name, value)?;
}
Ok(())
}
impl endor_core::NativeCallable for PointerPick {
/// 输入 Mount 与点击点已捕获,拾取/分发准备步骤不再接受实参。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(0))
}
/// 在当前 Mount 中重新射线拾取,构造含真实 mesh 命中的 click Event,再交托管事件系统分发。
fn invoke(
&self,
cx: &mut NativeContext<'_>,
_: &[Value],
) -> Result<endor_core::NativeResult, VmError> {
let mount = cx
.store()
.payload::<crate::mount::Mount>(self.mount, "Mount")?
.clone();
let hit = pick(cx, self.desktop, &mount, self.point)?;
let definition = crate::ui_bridge::desktop_type(cx.store(), "Event", self.desktop)?;
let event = crate::event_dispatch::new_event(cx, definition, "click")?;
// 点击事件把精确拾取值放到 hit(未击中则 Nil),仍以 Viewport 的 Block/Mount 作为事件传播目标。
cx.set_attr(event, "hit", hit)?;
crate::event_dispatch::dispatch(cx, mount.block, event, Some(self.mount), self.desktop)
}
}
+151 -2
View File
@@ -1,3 +1,7 @@
//! 从可变托管对象图提取不可变 UiNode,并维护反向依赖与父子失效传播。
//! 缓存只借用可检查的对象身份,不增加托管根。对象释放后必须清理缓存索引;快照仍可持有独立的原生数值数据。
//! 干净子树直接复用 Arc;脏节点重新检查属性、方法、绑定和子项,循环与展开数量在发布前拒绝。
//!
//! Object-to-visual dependencies. Cached results never own VM references.
use crate::{
block::{self, Kind},
@@ -10,6 +14,7 @@ use std::{
};
#[derive(Debug)]
/// 某个业务组件的视觉快照,以及使它失效的读取依赖和子项身份。
struct Cached {
node: Arc<UiNode>,
dependencies: Vec<Dependency>,
@@ -17,16 +22,24 @@ struct Cached {
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
/// 反向依赖键:区分整个对象内容和精确属性名称,减少无关字段变化引起的失效。
struct Dependency {
object: ObjectRef,
property: Option<String>,
}
#[derive(Debug, Default)]
/// 视觉快照缓存与反向依赖图。对象身份是借用索引,原生 Arc 数据不拥有 VM 根。
pub struct RenderCache {
// 场景对象身份到原生不可变几何的缓存;相机改变时仍有机会复用同一场景数据。
scenes3d: HashMap<ObjectRef, Arc<crate::scene3d::Scene>>,
// 每个业务 Block 的视觉结果;这里只保存原生快照,不通过 ObjectRef 增加 VM 强引用。
entries: HashMap<ObjectRef, Cached>,
// 读取源 -> 读取它的 Block。属性变动从这里找到首批需要重建的快照。
dependents: HashMap<Dependency, HashSet<ObjectRef>>,
// 子 Block -> 所有父 Block。共享子组件改变后,所有引用该子项的父快照都需要更新。
parents: HashMap<ObjectRef, HashSet<ObjectRef>>,
// 已失效但尚未重建的 Block 集合,同时用于传播去重。
dirty: HashSet<ObjectRef>,
pub last_invalidation: String,
pub visited: usize,
@@ -34,13 +47,16 @@ pub struct RenderCache {
}
impl RenderCache {
/// 把 VM 变化映射到读取过这些属性/对象的视觉节点,再沿父链向上传播脏标记。
pub fn invalidate(&mut self, batch: ChangeBatch) {
let mut queue = VecDeque::new();
// 变更日志溢出后无法知道遗漏了哪些写入,保守地将已有快照全部失效。
if batch.rescan_required {
self.last_invalidation = "change journal overflow".into();
queue.extend(self.entries.keys().copied());
} else {
for change in batch.changes {
// 属性变更使用精确名称,Contents 使用整个对象键;与提取时登记的依赖粒度一致。
let key = Dependency {
object: change.object,
property: match change.key {
@@ -57,9 +73,11 @@ impl RenderCache {
self.invalidate_objects(queue);
}
/// 迭代传播显式失效,使用集合避免共享父链重复遍历或循环入队。
pub fn invalidate_objects(&mut self, objects: impl IntoIterator<Item = ObjectRef>) {
let mut queue: VecDeque<_> = objects.into_iter().collect();
while let Some(block) = queue.pop_front() {
// 只有第一次加入 dirty 才继续向父链传播,避免共享祖先被反复展开。
if self.dirty.insert(block)
&& let Some(parents) = self.parents.get(&block)
{
@@ -68,8 +86,10 @@ impl RenderCache {
}
}
/// 撤销旧缓存的反向读取边和父子边,为重新登记该节点做准备。
fn remove(&mut self, block: ObjectRef) -> Option<Cached> {
let old = self.entries.remove(&block)?;
// 旧边必须先撤销,否则节点重建后仍会被已经不再读取的对象触发失效。
for source in &old.dependencies {
if let Some(owners) = self.dependents.get_mut(source) {
owners.remove(&block);
@@ -78,6 +98,7 @@ impl RenderCache {
}
}
}
// 只撤销当前 Block 对孩子的父边;其他共享父节点仍保留。空集合及时删除以缩小索引。
for child in &old.children {
if let Some(parents) = self.parents.get_mut(child) {
parents.remove(&block);
@@ -89,7 +110,11 @@ impl RenderCache {
Some(old)
}
/// 删除托管堆中已不存在的身份,缓存本身不负责保持业务对象存活。
pub fn prune(&mut self, store: &ObjectStore) {
self.scenes3d
.retain(|object, _| store.object(*object).is_ok());
// 先收集死亡身份,再修改 entries 与反向索引,避免一边借用遍历一边删除。
let dead: Vec<_> = self
.entries
.keys()
@@ -98,10 +123,12 @@ impl RenderCache {
.collect();
for block in dead {
self.remove(block);
// 只有成功建立新条目后才清脏标记;前面任何校验失败都不会把节点错误标成干净。
self.dirty.remove(&block);
}
}
/// 按显式栈遍历托管组成;复用干净子树,重建脏节点,并在完成前检查循环和资源上限。
pub fn snapshot(
&mut self,
store: &ObjectStore,
@@ -109,10 +136,13 @@ impl RenderCache {
) -> Result<Arc<UiNode>, VmError> {
self.visited = 0;
self.reused = 0;
// 使用进入/退出两阶段的显式 DFS,避免深层组成消耗 Rust 调用栈。
// values 是后序结果栈:每个退出节点消费刚完成的孩子快照,再压入自己。
let mut work = vec![(root, false)];
let mut path = HashSet::new();
let mut values = Vec::<Arc<UiNode>>::new();
while let Some((block, finish)) = work.pop() {
// 入栈时先尝试复用;退出标记表示孩子已形成快照,可以组装父节点。
if !finish {
if !self.dirty.contains(&block)
&& let Some(cached) = self.entries.get(&block)
@@ -121,14 +151,16 @@ impl RenderCache {
values.push(cached.node.clone());
continue;
}
// 计数的是本次实际展开次数;共享节点可复用缓存,脏图展开仍有 10000 次上限。
self.visited += 1;
if self.visited > 10_000 {
return Err(VmError::ExecutionLimit("expanded Block graph"));
}
}
// 提取前验证实例存储与类型契约;resolve_tracked 同时提供数据以及经过的代理/存储依赖。
store.check_typed_instance_storage(block)?;
let (data_ref, raw_data, raw_dependencies) = block::resolve_tracked(store, block)?;
let data = block::visual_data(store, raw_data)?;
let mut data = block::visual_data(store, raw_data)?;
let mut dependencies: Vec<_> = raw_dependencies
.into_iter()
.map(|object| Dependency {
@@ -136,6 +168,7 @@ impl RenderCache {
property: None,
})
.collect();
// 方法名称查找也构成依赖。即使属性数值未变,协议方法被替换也必须让快照失效。
let storage = *store
.attribute_path(block)?
.last()
@@ -146,6 +179,7 @@ impl RenderCache {
property: Some(name),
});
}
// 原生视觉属性按精确字段登记,避免不参与视觉的其他属性写入触发整个节点重建。
if let Some(properties) = data.properties {
for (name, _) in block::native_properties(&data) {
dependencies.push(Dependency {
@@ -154,6 +188,49 @@ impl RenderCache {
});
}
}
if matches!(data.kind, Kind::CanvasViewport { .. })
&& let Some(camera) = data.camera
{
for name in ["x", "y", "zoom"] {
dependencies.push(Dependency {
object: camera,
property: Some(name.into()),
});
}
}
// 3D 面板把托管内容映射为子 Block,同时保留尺寸与稳定 slot,供后续布局和出现身份使用。
let mut panel_sizes = Vec::new();
let view3d = if matches!(data.kind, Kind::Viewport3D { .. }) {
let scene = data
.scene3d
.ok_or(VmError::InvalidArgument("missing Scene3D"))?;
let (view, reads, panels) = crate::scene3d_model::snapshot_reusing(
store,
scene,
data.camera
.ok_or(VmError::InvalidArgument("missing 3D camera"))?,
self.scenes3d.get(&scene).cloned(),
)?;
self.scenes3d.insert(scene, view.scene.clone());
dependencies.extend(reads.into_iter().map(|(object, name)| Dependency {
object,
property: Some(name),
}));
data.children = panels
.into_iter()
.map(|panel| {
panel_sizes.push(panel.size);
crate::block::Child {
slot: panel.key,
block: panel.content,
}
})
.collect();
Some(view)
} else {
None
};
// path 只追踪当前祖先链,因此允许同一业务组件在不同分支多次出现。
if !finish {
if !path.insert(data_ref) {
return Err(VmError::InvalidArgument("cyclic Block composition"));
@@ -165,6 +242,7 @@ impl RenderCache {
continue;
}
path.remove(&data_ref);
// 协议键包含类型身份、实际方法身份及其内容版本,供布局/绘制缓存识别实现变化。
let mut protocol_key = String::new();
let mut custom_event = false;
if let Some(definition) = store.type_of(block)? {
@@ -179,6 +257,7 @@ impl RenderCache {
});
}
}
// 不仅记录最终方法,还沿基类链登记类型内容依赖,使继承关系或方法表变化能触发重建。
let event = store.lookup_method(definition, "onEvent")?;
let mut current = Some(definition);
while let Some(definition) = current {
@@ -196,6 +275,7 @@ impl RenderCache {
.transpose()?
.unwrap_or(false);
}
// 订阅连接的取消会改变 handler;记录连接内容依赖,使交互资格能随取消及时更新。
let mut click_connection = false;
for connection in &data.subscriptions {
dependencies.push(Dependency {
@@ -206,9 +286,11 @@ impl RenderCache {
.payload::<crate::event_dispatch::Connection>(*connection, "Connection")?;
click_connection |= item.kind == "click" && item.handler.is_some();
}
// 逆序压入孩子确保后序结果与 data.children 原顺序一致;只取尾部的本节点孩子,保留兄弟结果。
let children = values.split_off(values.len() - data.children.len());
let kind = match &data.kind {
Kind::Text { value, size, color } => {
// 旧 textOf 路径按实际源字段提取字符串。属性路径中间对象用整对象依赖,末端用字段依赖。
let value = if let Some((source, field)) = &data.text_binding {
let storage = store.attribute_path(*source)?;
for (index, object) in storage.iter().enumerate() {
@@ -222,6 +304,7 @@ impl RenderCache {
Value::Bool(v) => v.to_string(),
Value::Number(v) => v.to_string(),
Value::Text(v) => v,
// 对象字符串化可能依赖其内容,所以除属性本身外还记录被引用对象的内容变化。
Value::Object(v) => {
dependencies.push(Dependency {
object: v,
@@ -259,11 +342,61 @@ impl RenderCache {
children,
},
Kind::Spacer(size) => UiKind::Spacer(*size),
Kind::Viewport3D { width, height } => UiKind::Viewport3D {
width: *width,
height: *height,
view: view3d.ok_or(VmError::InvalidArgument("missing 3D view"))?,
controls: data.controller.is_some(),
panels: panel_sizes,
children,
},
Kind::CanvasViewport { width, height } => UiKind::CanvasViewport {
width: *width,
height: *height,
camera: crate::viewport::camera(
store,
data.camera
.ok_or(VmError::InvalidArgument("missing camera"))?,
)?,
children,
},
// 二维场景保留每个孩子的世界位置;普通行列容器则由布局决定孩子坐标。
Kind::Scene2D => UiKind::Scene2D {
positions: data
.children
.iter()
.map(|child| {
let (_, child) = block::resolve(store, child.block)?;
Ok(block::visual_data(store, child)?.position)
})
.collect::<Result<Vec<_>, VmError>>()?,
children,
},
Kind::Overlay => UiKind::Overlay {
padding: data.padding,
children,
},
Kind::Row => UiKind::Row {
padding: data.padding,
gap: data.gap,
children,
},
Kind::Page if data.surface.mode == crate::page::Mode::Canvas => UiKind::Canvas {
positions: data
.children
.iter()
.map(|child| {
let (_, data) = block::resolve(store, child.block)?;
Ok(block::visual_data(store, data)?.position)
})
.collect::<Result<Vec<_>, VmError>>()?,
children,
},
Kind::Page if data.surface.mode == crate::page::Mode::Horizontal => UiKind::Row {
padding: data.padding,
gap: data.gap,
children,
},
Kind::Column | Kind::Page => UiKind::Column {
padding: data.padding,
gap: data.gap,
@@ -275,25 +408,41 @@ impl RenderCache {
children,
},
};
// 统一外层样式与交互标志。UiKind 只表示具体内容,UiNode 保存所有类别共享的视觉元信息。
let next = UiNode {
surface: matches!(data.kind, Kind::Page).then(|| {
let mut surface = data.surface.clone();
surface.page = block.to_string();
surface
}),
flex: data.flex,
flex_policy: data.flex_policy,
main_align: data.main_align,
cross_align: data.cross_align,
align_self: data.align_self,
margin: data.margin,
sizing: data.sizing,
padding_edges: data.padding_edges,
border: data.border,
border_color: data.border_color.clone(),
clip_children: data.clip_children,
block_id: block.to_string(),
protocol_key,
slots: data.children.iter().map(|c| c.slot).collect(),
interactive: matches!(&data.kind, Kind::Button)
interactive: matches!(&data.kind, Kind::Button | Kind::Viewport3D { .. })
|| data.click.is_some()
|| click_connection
|| custom_event,
kind,
};
// 即使本次重算结果相同,也要重新登记依赖。shallow_eq 相同时复用旧 Arc,减少下游无效重建。
let old = self.remove(block);
let node = if let Some(old) = old.as_ref().filter(|old| old.node.shallow_eq(&next)) {
old.node.clone()
} else {
Arc::new(next)
};
// 同一属性可能由不同提取路径重复读取,排序去重后再建立反向边。
dependencies.sort_unstable();
dependencies.dedup();
let child_refs: Vec<_> = data.children.iter().map(|child| child.block).collect();
+57
View File
@@ -1,3 +1,7 @@
//! 托管 Window 的页面所有权、原生命令与关闭生命周期。
//! 源码 resize/focus/setTitle 先产生纯数据命令;只有所属输入成功发布后,GUI 线程才应用它们。
//! 窗口 ID 和运行时 session 共同限定资源归属;切换 VM 或关闭窗口时不能让旧命令作用到新资源。
//!
//! Managed window resources and versioned, coalesced native projections.
//! The VM owns Page roots; only scalar commands cross to the GUI thread.
use crate::{native_visual, object_list, ui_bridge::desktop_type};
@@ -10,6 +14,7 @@ use std::sync::{Arc, Mutex};
pub const MAIN_WINDOW: u64 = 1;
pub const MAX_WINDOW_DIMENSION: f64 = 4096.0;
#[derive(Debug, Clone, PartialEq)]
/// 可投影到原生窗口的动作类别,如尺寸、标题、焦点与关闭。
pub enum Action {
Resize { width: f64, height: f64 },
SetTitle(String),
@@ -18,48 +23,61 @@ pub enum Action {
Close,
}
#[derive(Debug, Clone, PartialEq)]
/// 成功发布后由 GUI 执行的一条原生窗口请求,含资源身份和动作顺序。
pub struct Command {
pub window: u64,
pub sequence: u64,
pub action: Action,
}
impl Command {
/// 判断两条命令是否针对同一资源的同类动作,用于替换尚未执行的旧请求。
pub fn same_kind(&self, other: &Self) -> bool {
// 比较动作变体而不是参数值:同窗口多次 Resize/SetTitle 可合并为最后一次,不同动作仍按序保留。
self.window == other.window
&& std::mem::discriminant(&self.action) == std::mem::discriminant(&other.action)
}
}
#[derive(Debug, Clone)]
/// 附带运行时 session 的命令,阻止旧域请求在新 VM 上生效。
pub struct Envelope {
pub session: u64,
pub command: Command,
}
#[derive(Debug, Clone)]
/// 托管窗口资源状态与页面拥有关系;本对象存在不等于 OS 窗口已创建。
pub struct ManagedWindow {
pub id: u64,
// 每窗口拥有独立 desktop、tabs 与页面顺序;同 VM 的类型注册表可共享,不复制业务模型。
pub desktop: ObjectRef,
pub closed: bool,
pub tasks: Option<ObjectRef>,
tabs: ObjectRef,
// 页面身份的有序强拥有集合,tabs 字典同时提供托管注册入口;移动顺序只调整此 Vec。
pages: Vec<ObjectRef>,
sequence: u64,
// Transport state only. Draining it must not change a language object's
// version or create a render-dependency feedback loop.
// 仅保存纯数据传输队列,克隆 payload 仍共享同一 outbox;drain 不触发 VM 内容版本,避免反馈循环。
outbox: Arc<Mutex<Vec<Command>>>,
}
impl ObjectPayload for ManagedWindow {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"tessera-window"
}
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
fn allows_attributes(&self) -> bool {
true
}
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
fn reference_assignment_only(&self) -> bool {
true
}
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
visit(self.desktop);
if let Some(tasks) = self.tasks {
@@ -71,19 +89,23 @@ impl ObjectPayload for ManagedWindow {
}
}
}
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 把源数值转换为有限屏幕坐标,拒绝越界或不可表示的值。
fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
let Value::Number(value) = value else {
return Err(invalid("screen position requires integer coordinates"));
};
// 屏幕位置必须为整数,允许负坐标以覆盖多显示器;范围与 i32 转换均检查,不能把小数直接截断。
value
.as_i64()
.filter(|number| (-1_000_000..=1_000_000).contains(number))
.and_then(|number| i32::try_from(number).ok())
.ok_or(invalid("screen position exceeds limits"))
}
/// 从源 Value 取得窗口操作需要的数值,保留错误类型诊断。
fn scalar(value: &Value) -> Result<f64, VmError> {
let Value::Number(value) = value else {
return Err(invalid("window size requires numbers"));
@@ -98,6 +120,7 @@ fn scalar(value: &Value) -> Result<f64, VmError> {
Ok(number)
}
#[derive(Debug, Clone, Copy)]
/// 当前宿主 API 的操作分类,分派代码负责参数校验与状态更新。
enum Operation {
Id,
Viewport,
@@ -108,15 +131,18 @@ enum Operation {
Focus,
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
signature: endor_core::CallSignature,
operation: Operation,
desktop: ObjectRef,
}
impl NativeCallable for Method {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<endor_core::CallSignature> {
Some(self.signature.clone())
}
/// 计入 Window 接收者:resize/moveTo 共三参,setTitle 两参,其他查询/关闭/聚焦只要接收者。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(match self.operation {
Operation::Resize | Operation::MoveTo => 3,
@@ -125,6 +151,7 @@ impl NativeCallable for Method {
}))
}
/// 验证窗口资源与关闭状态;查询返回托管值,原生变更生成受世代保护的待执行命令,关闭走资源生命周期。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let Some(Value::Object(owner)) = args.first() else {
return Err(invalid("Window method requires a Window"));
@@ -133,6 +160,7 @@ impl NativeCallable for Method {
.store()
.payload::<ManagedWindow>(*owner, "Window")?
.clone();
// 已关闭窗口仍可查询稳定 ID 与页面集合,其他读取/操作拒绝,避免用旧 viewport 或重开原生资源。
if data.closed && !matches!(self.operation, Operation::Id | Operation::Pages) {
return Err(invalid("native window is closed"));
}
@@ -149,6 +177,7 @@ impl NativeCallable for Method {
Operation::Pages => {
Value::Object(object_list::collection(cx, data.pages, self.desktop)?)
}
// 读取该 Window 自己 desktop 的逻辑视口,封装成 MeasureResult;尚未由原生侧提供尺寸时明确失败。
Operation::Viewport => {
let Value::Object(input) = cx.store().get_attr(data.desktop, "viewport")? else {
return Err(invalid("window viewport is unavailable"));
@@ -163,6 +192,7 @@ impl NativeCallable for Method {
)?)
}
operation => {
// 原生命令虽暂存在内存,也属于变更,不能从只读测量/绘制回调发起窗口操作。
if cx.store().is_read_only() {
return Err(invalid(
"window operations are forbidden during read-only rendering",
@@ -181,6 +211,7 @@ impl NativeCallable for Method {
let Value::Text(title) = &args[1] else {
return Err(invalid("window title requires text"));
};
// 标题限制 UTF-8 字节长度并拒绝控制字符;不会在这里执行标题文本或展开 shell 字符。
if title.len() > 4096 || title.chars().any(char::is_control) {
return Err(invalid("invalid window title"));
}
@@ -196,6 +227,7 @@ impl NativeCallable for Method {
Ok(NativeResult::Return(result))
}
}
/// 把合法窗口操作追加到所属窗口的待发布命令,不能直接在 VM 线程操作 OS。
pub fn enqueue(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -205,6 +237,7 @@ pub fn enqueue(
.store()
.payload::<ManagedWindow>(owner, "Window")?
.clone();
// 命令序列单调递增且检查溢出;同类合并会产生序号空洞,但仍可按序比较先后。
data.sequence = data
.sequence
.checked_add(1)
@@ -219,12 +252,15 @@ pub fn enqueue(
.lock()
.map_err(|_| invalid("window outbox poisoned"))?;
// The heap barrier succeeds before any external transport state changes.
// 必须先成功经过 VM 写屏障,才修改不可回滚的原生 outbox;只读调用不能先留下待发命令再报错。
cx.replace_payload(owner, data)?;
// 同窗口同动作只保留最后请求,其他类别保持先后;此处只入队,GUI 是否执行取决于后续成功发布。
queue.retain(|old| !old.same_kind(&command));
queue.push(command);
Ok(())
}
/// 将页面登记到其窗口,维护托管所有权与页面顺序。
pub fn register_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
@@ -242,6 +278,7 @@ pub fn register_page(
if data.closed {
return Err(invalid("native window is closed"));
}
// 登记幂等,首次同时写 tabs 与 payload;字典键使用页面身份文本而非标题,重命名不改变登记。
if !data.pages.contains(&page) {
data.pages.push(page);
cx.dict_set(
@@ -253,6 +290,7 @@ pub fn register_page(
}
Ok(())
}
/// 从窗口的页面集合撤销指定页面,保持后续展示索引一致。
pub fn unregister_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
@@ -267,6 +305,7 @@ pub fn unregister_page(
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
// 不存在时撤销是空操作;存在时同时删有序列表与 tabs 字典边,不能只隐藏原生标签。
if !data.pages.contains(&page) {
return Ok(());
}
@@ -276,6 +315,7 @@ pub fn unregister_page(
Ok(())
}
/// Discard an unpublished Page registration without invoking guest callbacks.
/// 宿主收尾路径撤销页面登记,用于已经由外层决定的关闭/退役流程。
pub fn unregister_page_host(
vm: &mut Vm,
window: ObjectRef,
@@ -290,6 +330,7 @@ pub fn unregister_page_host(
Ok(())
}
/// 在同一窗口的已登记页面集合内重排标签位置,保留 Page 身份;不负责跨窗口迁移。
pub fn move_page(
cx: &mut NativeContext<'_>,
window: ObjectRef,
@@ -316,6 +357,7 @@ pub fn move_page(
"tab ordering is forbidden during read-only rendering",
));
}
// 只重排当前窗口中的页面,不迁移到另一窗口,也不销毁或重建 Page/Mount;跨窗口迁移由桥接层组合操作。
if from != position {
let page = data.pages.remove(from);
data.pages.insert(position, page);
@@ -323,19 +365,23 @@ pub fn move_page(
}
Ok(())
}
/// 借用一个有效托管窗口的已登记页面集合。
pub fn pages(store: &ObjectStore, window: ObjectRef) -> Result<&[ObjectRef], VmError> {
Ok(&store.payload::<ManagedWindow>(window, "Window")?.pages)
}
/// 取走当前窗口累积的命令,后续由成功发布结果携带到 GUI。
pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result<Vec<Command>, VmError> {
let data = vm.payload::<ManagedWindow>(window, "Window")?;
let mut queue = data
.outbox
.lock()
.map_err(|_| invalid("window outbox poisoned"))?;
// 一次移走全部队列,再按序号排序;仅消费传输状态,不调用 replace_payload 或改变语言依赖版本。
let mut commands = std::mem::take(&mut *queue);
commands.sort_by_key(|command| command.sequence);
Ok(commands)
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
@@ -387,6 +433,7 @@ pub fn install(
vm.attach_type(function, function_type)?;
vm.define_method(definition, name, function)?;
}
// 安装主窗口托管资源,原生 OS 窗口生命周期仍由 GUI 管理;页面集合初始为空。
let window = vm.allocate(ManagedWindow {
id: MAIN_WINDOW,
desktop,
@@ -400,6 +447,7 @@ pub fn install(
roots.push(window);
vm.attach_type(window, definition)?;
vm.set_attr(desktop, "window", Value::Object(window))?;
// 主 desktop 拥有统一窗口注册表,以纯数字 ID 文本索引;注册表让附加窗口在 VM 图内可达。
let windows = vm.new_dict()?;
vm.set_attr(desktop, "windows", Value::Object(windows))?;
vm.dict_set(
@@ -413,6 +461,7 @@ pub fn install(
/// Create a managed native-window resource. The GUI creates the OS resource only
/// after the owning candidate/input publishes its immutable projection.
/// 建立托管窗口、desktop 与页面容器;原生窗口通过命令发布后出现。
pub fn create(
cx: &mut NativeContext<'_>,
master: ObjectRef,
@@ -426,6 +475,7 @@ pub fn create(
}
let desktop = cx.new_object()?;
let tabs = cx.new_dict()?;
// 附加桌面共享主域的同一 types 字典身份,保证跨窗口对象和协议使用同一名义类型体系。
let types = cx.store().get_attr(master, "types")?;
cx.set_attr(desktop, "types", types)?;
cx.set_attr(desktop, "viewport", Value::Nil)?;
@@ -446,9 +496,11 @@ pub fn create(
return Err(invalid("window registry missing"));
};
cx.dict_set(windows, Value::Text(id.to_string()), Value::Object(window))?;
// 返回创建的三个托管身份供桥接层建立 Presentation;此函数不直接申请 OS 资源或发起布局。
Ok((window, desktop, tabs))
}
/// 取消窗口/页面任务并撤销登记,标记 closed 后安排原生关闭;重复关闭直接返回。
pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmError> {
if cx.store().is_read_only() {
return Err(invalid(
@@ -462,23 +514,28 @@ pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmErro
if data.closed {
return Ok(());
}
// 真实关闭边界先取消窗口与所有页面 owner 的任务,再撤销页面登记,防止晚到结果继续更新已关闭资源。
crate::tasks::cancel_owner(cx, window)?;
for page in data.pages.clone() {
crate::tasks::cancel_owner(cx, page)?;
unregister_page(cx, window, page)?;
}
// 循环内 unregister_page 已替换过 Window payload,必须重读最新副本,不能用最初 data 把已移除页面写回。
data = cx
.store()
.payload::<ManagedWindow>(window, "Window")?
.clone();
data.closed = true;
cx.replace_payload(window, data)?;
// 标记托管 closed 后仍可由内部 enqueue 安排原生 Close;成功输入发布前不会直接关闭系统窗口。
enqueue(cx, window, Action::Close)
}
/// Host teardown for a stopped VM: no binding, dispatch or source execution.
/// 撤销已失效窗口的托管资源关系,不重新执行源关闭请求。
pub fn retire(vm: &mut Vm, window: ObjectRef) -> Result<(), VmError> {
let mut data = vm.payload::<ManagedWindow>(window, "Window")?.clone();
// 故障域的宿主退役仅断开页面拥有边并标 closed,不跑用户回调、不重新生成源关闭命令。
vm.dict_clear(data.tabs)?;
data.pages.clear();
data.closed = true;
+140
View File
@@ -0,0 +1,140 @@
//! 有限布局盒子的尺寸策略。区分 auto、固定值和 fill,并处理 min/max 对约束与最终尺寸的限制。
//! margin、border、padding 的空间分配由布局层结合本模块完成;场景世界范围不被当成无限布局盒子。
//!
//! Native border-box sizing. An unbounded axis cannot resolve `fill`.
use crate::{layout::Size, ui_bridge::MAX_DIMENSION};
use anyhow::Result;
use endor_core::{Value, VmError};
#[derive(Debug, Clone, Copy, Default, PartialEq)]
/// 一个布局轴的尺寸策略:随内容、固定值或占用有限可用空间。
pub enum Dimension {
#[default]
Auto,
Fixed(f32),
Fill,
}
impl Dimension {
/// 把内部尺寸策略投影为相应的源属性表示。
pub fn value(self) -> Value {
match self {
Self::Auto => Value::Text("auto".into()),
Self::Fill => Value::Text("fill".into()),
Self::Fixed(value) => Value::Number((value as f64).into()),
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(value: &Value) -> Result<Self, VmError> {
match value {
Value::Text(text) if text == "auto" => Ok(Self::Auto),
Value::Text(text) if text == "fill" => Ok(Self::Fill),
Value::Number(_) => Ok(Self::Fixed(crate::scroll::coordinate(value).and_then(
|n| {
if n >= 0.0 {
Ok(n)
} else {
Err(VmError::InvalidArgument(
"box dimensions must be nonnegative",
))
}
},
)?)),
_ => Err(VmError::InvalidArgument(
"box dimension expects auto, fill or a nonnegative number",
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
/// border-box 尺寸策略与双轴上下限,实际求解仍要结合父级约束。
pub struct Sizing {
pub width: Dimension,
pub height: Dimension,
pub min_width: f32,
pub min_height: f32,
pub max_width: f32,
pub max_height: f32,
}
impl Default for Sizing {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
width: Dimension::Auto,
height: Dimension::Auto,
min_width: 0.0,
min_height: 0.0,
max_width: MAX_DIMENSION as f32,
max_height: MAX_DIMENSION as f32,
}
}
}
impl Sizing {
/// Constrain descendants using the actual finite box before wrapping text.
/// 将 [最小宽, 最小高, 最大宽, 最大高] 收窄,供子内容测量和文本换行使用。
/// 固定尺寸限制最大可用空间;最后缩小最小约束以保持 min <= max。这里不直接计算最终盒子。
pub fn available(self, mut constraints: [f32; 4]) -> [f32; 4] {
// 先套自身 max,再应用 Fixed;Auto/Fill 在测量阶段保留父级允许的空间。
constraints[2] = constraints[2].min(self.max_width);
constraints[3] = constraints[3].min(self.max_height);
if let Dimension::Fixed(width) = self.width {
constraints[2] = constraints[2].min(width.max(self.min_width));
}
if let Dimension::Fixed(height) = self.height {
constraints[3] = constraints[3].min(height.max(self.min_height));
}
constraints[0] = constraints[0].min(constraints[2]);
constraints[1] = constraints[1].min(constraints[3]);
constraints
}
/// 结合内容期望尺寸、轴策略和父级约束求最终盒子尺寸;无界轴不能解析 fill。
pub fn resolve(self, intrinsic: Size, constraints: [f32; 4]) -> Result<Size> {
/// 针对单个尺寸轴应用约束与边界规则,供双轴尺寸求解复用。
fn axis(
policy: Dimension,
intrinsic: f32,
min: f32,
max: f32,
available: f32,
) -> Result<f32> {
// 先按策略选候选值:Auto 用内容测量值,Fixed 用指定值,Fill 用父级分配上限。
let value = match policy {
Dimension::Auto => intrinsic,
Dimension::Fixed(value) => value,
Dimension::Fill => {
// MAX_DIMENSION 充当无界哨兵;无界空间没有可填满的有限尺寸,必须拒绝。
if available >= MAX_DIMENSION as f32 {
return Err(VmError::InvalidArgument(
"fill requires a finite parent allocation on this axis",
)
.into());
}
available
}
};
// 先满足自身 min/max,再服从父级上限;父级空间比 min 小时,父级上限优先。
Ok(value.clamp(min, max).min(available))
}
Ok(Size {
width: axis(
self.width,
intrinsic.width,
self.min_width,
self.max_width,
constraints[2],
)?,
height: axis(
self.height,
intrinsic.height,
self.min_height,
self.max_height,
constraints[3],
)?,
})
}
}
+93
View File
@@ -1,7 +1,11 @@
//! 面向设备像素的绘制指令集合。它承载矩形、文字、裁剪与 3D 表面等原语,不直接操作 VM。
//! 平移和缩放统一作用于命令几何;文字样式和裁剪也要使用一致的设备比例。
//!
use crate::geometry::{Point, Rect};
use crate::text::TextStyle;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// 设备绘制使用的 RGBA 字节通道,区别于源代码的颜色文本。
pub struct Color {
pub red: u8,
pub green: u8,
@@ -10,6 +14,7 @@ pub struct Color {
}
impl Color {
/// 从 RGB 通道构造不透明颜色值。
pub const fn rgb(red: u8, green: u8, blue: u8) -> Self {
Self {
red,
@@ -19,6 +24,7 @@ impl Color {
}
}
/// 从 RGBA 通道构造带透明度的颜色值。
pub const fn rgba(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
Self {
red,
@@ -30,27 +36,40 @@ impl Color {
}
#[derive(Clone, Debug)]
/// 原生画布最终顺序执行的绘制原语,已经使用对应设备比例。
pub enum DrawCommand {
// 在 rect 内绘制不可变 3D 视图;平移/缩放改变视口矩形,不改写共享场景或相机。
Scene3D {
rect: Rect,
view: std::sync::Arc<crate::scene3d::View>,
},
// 清除整个目标缓冲;不携带几何,因此列表平移和缩放不会改变它。
Clear(Color),
// 将矩形压入裁剪栈;后续命令受裁剪约束,必须由对应 PopClip 恢复。
PushClip(Rect),
// 恢复上层裁剪。合并列表保持原顺序,调用方需保证裁剪栈配对。
PopClip,
// 填充圆角矩形;半径与矩形尺寸在设备缩放时使用同一比例。
FillRoundedRect {
rect: Rect,
radius: f32,
color: Color,
},
// 描边圆角矩形;描边宽度也是长度,必须随几何一起缩放。
StrokeRoundedRect {
rect: Rect,
radius: f32,
width: f32,
color: Color,
},
// 在有限矩形内进行文本布局;缩放同时作用于矩形和文字样式,保持换行比例。
WrappedText {
rect: Rect,
value: String,
style: TextStyle,
color: Color,
},
// 以原点定位文本,文本布局使用 TextStyle;与 WrappedText 不同,不额外携带有限文本框。
Text {
origin: Point,
value: String,
@@ -59,20 +78,91 @@ pub enum DrawCommand {
},
}
impl DrawCommand {
/// 返回平移后的绘制命令,并同步移动命令中的相关几何。
pub fn translated(mut self, delta: Point) -> Self {
// 平移只改变位置,保留尺寸、圆角、文字样式和颜色;消费 self 后返回修改后的命令。
match &mut self {
Self::WrappedText { rect, .. }
| Self::Scene3D { rect, .. }
| Self::PushClip(rect)
| Self::FillRoundedRect { rect, .. }
| Self::StrokeRoundedRect { rect, .. } => {
rect.x += delta.x;
rect.y += delta.y;
}
Self::Text { origin, .. } => {
origin.x += delta.x;
origin.y += delta.y;
}
_ => {}
}
self
}
}
#[derive(Clone, Debug, Default)]
/// 有序绘制原语容器;命令顺序同时约束叠放和裁剪栈行为。
pub struct DisplayList {
commands: Vec<DrawCommand>,
}
impl DisplayList {
/// 按设备比例缩放命令,文字、边框和矩形不能分别使用不一致比例。
pub fn scale(&mut self, scale: f32) {
// 原地累计缩放:重复调用会相乘,并非设置绝对缩放值。调用方负责提供有效设备比例。
for command in &mut self.commands {
// 先处理各变体独有的长度(字体、圆角、描边),再把公共矩形交给下方统一缩放。
let rect = match command {
DrawCommand::WrappedText { rect, style, .. } => {
*style = style.scaled(scale);
Some(rect)
}
DrawCommand::PushClip(rect) => Some(rect),
DrawCommand::Scene3D { rect, .. } => Some(rect),
DrawCommand::FillRoundedRect { rect, radius, .. } => {
*radius *= scale;
Some(rect)
}
DrawCommand::StrokeRoundedRect {
rect,
radius,
width,
..
} => {
*radius *= scale;
*width *= scale;
Some(rect)
}
DrawCommand::Text { origin, style, .. } => {
origin.x *= scale;
origin.y *= scale;
*style = style.scaled(scale);
None
}
_ => None,
};
// 矩形的原点和宽高一起缩放,保证裁剪范围、文本框与实际绘制内容对齐。
if let Some(rect) = rect {
rect.x *= scale;
rect.y *= scale;
rect.width *= scale;
rect.height *= scale;
}
}
}
/// 按绘制顺序追加一条原语,后加入的内容在合成顺序中位于后面。
pub fn push(&mut self, command: DrawCommand) {
self.commands.push(command);
}
/// 平移当前显示列表中的命令,供整块页面或管理面板放入窗口坐标。
// 与单条 translated 相同,但直接修改整个列表;Clear/PopClip 没有位置需要移动。
pub fn translate(&mut self, delta: Point) {
for command in &mut self.commands {
match command {
DrawCommand::WrappedText { rect, .. }
| DrawCommand::Scene3D { rect, .. }
| DrawCommand::PushClip(rect)
| DrawCommand::FillRoundedRect { rect, .. }
| DrawCommand::StrokeRoundedRect { rect, .. } => {
@@ -88,10 +178,13 @@ impl DisplayList {
}
}
/// 借用按绘制顺序排列的原生原语,不复制命令集合。
pub fn commands(&self) -> &[DrawCommand] {
&self.commands
}
/// 按原顺序合并另一组绘制命令,不在这里重排层次。
// 消费另一个列表并移动其命令,不复制字符串或场景数据,也不在连接处补裁剪指令。
pub fn extend(&mut self, other: DisplayList) {
self.commands.extend(other.commands);
}
+10 -1
View File
@@ -1,16 +1,21 @@
#[derive(Clone, Copy, Debug, Default, PartialEq)]
//! 共享的二维点和矩形值。命中判断在调用方完成坐标系转换后使用,矩形本身不隐式处理相机。
//!
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
/// 二维坐标值;具体处于逻辑、局部还是设备空间由调用上下文决定。
pub struct Point {
pub x: f32,
pub y: f32,
}
impl Point {
/// 直接保存二维坐标;不缩放、不舍入,也不检查有限性,调用方负责坐标空间和输入校验。
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
/// 轴对齐矩形,保存原点和宽高,用于布局、裁剪与命中。
pub struct Rect {
pub x: f32,
pub y: f32,
@@ -19,6 +24,7 @@ pub struct Rect {
}
impl Rect {
/// 直接保存左上角和宽高;不归一化负尺寸,布局调用方需先产生合法边界。
pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
Self {
x,
@@ -28,6 +34,7 @@ impl Rect {
}
}
/// 用闭区间比较判断点是否命中,左右上下边缘都计入;调用前需将点转换到矩形所在空间。
pub fn contains(self, point: Point) -> bool {
point.x >= self.x
&& point.x <= self.x + self.width
@@ -41,7 +48,9 @@ mod tests {
use super::*;
#[test]
/// 矩形左上/右下边界均包含,刚越过左边界的点排除,固定基础命中边界策略。
fn rectangle_contains_edges_but_not_outside_points() {
// 同时验证左上和右下边界被包含,以及仅越过左边界的小偏差也会被排除。
let rect = Rect::new(10.0, 20.0, 30.0, 40.0);
assert!(rect.contains(Point::new(10.0, 20.0)));
assert!(rect.contains(Point::new(40.0, 60.0)));
+619 -50
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -1,12 +1,33 @@
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
//! 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。
//!
// 目录确定模块归属;main 中的 crate 根别名保持内部调用路径稳定。
/// 计算边框、内外边距和内容区域,并应用容器主轴/交叉轴分配规则。
pub(crate) mod box_model;
/// 表达与转换绘图命令,集中处理显示列表裁剪和坐标变换。
pub(crate) mod display_list;
/// 提供点、矩形等基础几何运算,供布局、命中和绘制共享。
pub(crate) mod geometry;
/// 实现原生布局求解、约束分配及可重用的尺寸/几何数据结构。
pub(crate) mod layout;
/// 适配原生视觉方法的测量、排列与绘制行为,供语言桥接和纯布局流程使用。
pub(crate) mod native_visual;
/// 描述整页表面模式、平移缩放和视口到世界坐标转换。
pub(crate) mod page;
/// 组合页面、侧栏和开发工具的整窗显示列表,并返回宿主输入所需布局。
pub(crate) mod scene;
/// 完成三维投影、射线拾取、裁剪与软件深度光栅化;子模块拆分数学、空间索引和世界 UI 面板。
pub(crate) mod scene3d;
/// 计算每个出现位置的滚动范围、裁剪和滚轮目标,嵌套滚动按可消费方向选择。
pub(crate) mod scroll;
/// 将完整显示列表写入像素缓冲;处理二维命令以及嵌套场景表面的最终合成。
pub(crate) mod software_renderer;
/// 在受控只读上下文调用源 hitTest,结合出现位置与可见性判断输入目标。
pub(crate) mod source_hit;
/// 用可恢复 VM 调用执行源 measure/arrange,追踪读依赖并迭代到几何稳定后产出草稿。
pub(crate) mod source_layout;
/// 分片执行源 paint 并验证/解码返回命令;未完成的绘制不能当作完整帧发布。
pub(crate) mod source_paint;
/// 使用系统字体塑形、换行和字形缓存,统一测量与像素绘制的文字尺寸。
pub(crate) mod text;
/// 处理二维场景相机、局部视口投影和空间查询,供绘制与命中使用相同变换。
pub(crate) mod viewport;
+80 -6
View File
@@ -1,3 +1,6 @@
//! 让原生组件也通过 Endor 的 measure/arrange/paint 协议被调用。
//! 将 Constraints、Size、Bounds 与 PaintCommand 在托管值和 Rust 值之间转换;原生方法与源覆盖方法共用调用契约。
//!
//! Native visual protocol entry points. Values are ordinary managed Endor
//! objects; geometry is scoped to a live occurrence, never stored on a Block.
use crate::{
@@ -12,9 +15,11 @@ use endor_core::{
NativeContext, NativeResult, ObjectRef, ObjectStore, Value, Vm, VmError,
};
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
fn object(value: &Value) -> Result<ObjectRef, VmError> {
if let Value::Object(object) = value {
Ok(*object)
@@ -22,6 +27,7 @@ fn object(value: &Value) -> Result<ObjectRef, VmError> {
Err(invalid("visual protocol requires objects"))
}
}
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
pub(crate) fn number(
cx: &NativeContext<'_>,
object: ObjectRef,
@@ -35,20 +41,25 @@ pub(crate) fn number(
.to_string()
.parse::<f32>()
.map_err(|_| invalid("invalid visual number"))?;
// 所有协议数值须有限且位于世界尺寸预算内;signed 区分坐标(可负)与长度/约束(非负)。
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 || (!signed && value < 0.0) {
return Err(invalid("visual number exceeds bounds"));
}
Ok(value)
}
/// 将原生布局计算结果封装为协议需要的托管 Value。
pub(crate) fn value(value: f32) -> Value {
// 原生 f32 提升到 f64 包装为 Endor Number;本函数不单独校验,调用路径必须已保证输入合法。
Value::Number((value as f64).into())
}
/// 新建指定名义类型的托管记录,并逐项设置字段,作为视觉协议的结果返回。
pub(crate) fn record(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
kind: &str,
fields: Vec<(&str, Value)>,
) -> Result<ObjectRef, VmError> {
// 先赋精确名义类型,再初始化字段;只读协议可创建结果对象,但不能借机写入旧业务图。
let object = cx.new_object()?;
attach_desktop_type(cx, object, kind, desktop)?;
for (key, value) in fields {
@@ -56,7 +67,9 @@ pub(crate) fn record(
}
Ok(object)
}
/// 以最大宽高作视口执行原生布局并提取根尺寸;最小/最大最终钳制由 Measure 分支负责。
fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size, VmError> {
// 无 staged 子测量结果时,使用有限最大约束运行原生布局取得根尺寸;最终 min/max 钳制在调用方 Measure 分支。
let frame = layout::frame_with_viewport(
node,
Size {
@@ -75,6 +88,7 @@ fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size,
})
}
#[derive(Debug, Clone, Copy)]
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
enum Query {
Measure,
Arrange,
@@ -82,18 +96,21 @@ enum Query {
HitTest,
}
#[derive(Debug)]
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
struct Method {
query: Query,
desktop: ObjectRef,
signature: CallSignature,
}
/// Only this actual implementation can use the matching intrinsic fast path.
/// 判断方法是否为匹配协议的宿主原生实现,决定能否安全使用快速路径。
pub(crate) fn is_intrinsic(
store: &ObjectStore,
function: ObjectRef,
name: &str,
desktop: ObjectRef,
) -> Result<bool, VmError> {
// 快速路径仅认可实际 ContextNativeFunction 的 Method 实现,函数名相同或签名相似的源函数不能冒充。
let Some(function) = store
.object(function)?
.payload()
@@ -106,6 +123,7 @@ pub(crate) fn is_intrinsic(
return Ok(false);
};
// Per-window desktops share the same nominal registry and intrinsic bodies.
// 同一 VM 不同窗口 desktop 可共享 types 字典,所以以注册表身份允许复用原生方法体。
let same_registry = method.desktop == desktop
|| matches!(
(store.get_attr(method.desktop, "types")?, store.get_attr(desktop, "types")?),
@@ -122,25 +140,31 @@ pub(crate) fn is_intrinsic(
}
impl NativeCallable for Method {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<CallSignature> {
Some(self.signature.clone())
}
/// 接收三项底层参数:Block 接收者、具体 Mount、Constraints/Bounds/Point 协议输入。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(3))
}
/// 检查 Block/Mount 归属与有效性,再按 measure/arrange/paint/hitTest 执行原生视觉协议并返回受检查的托管值。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
// 三个实际参数含接收者 Block、具体 Mount 和协议输入;几何属于 Mount,不能仅凭 Block 身份查询。
let [owner, context, input] = args else {
return Err(invalid("visual method expects a Mount and an input value"));
};
let owner = object(owner)?;
let mount = cx.store().payload::<Mount>(object(context)?, "Mount")?;
// 默认协议也校验上下文归属,防止调用者把另一个出现的几何套到当前组件。
if !mount.mounted || mount.block != owner {
return Err(invalid(
"visual context is detached or belongs to another Block",
));
}
let geometry = mount.geometry.clone();
// 布局阶段从临时 Context 取当前节点、孩子实测尺寸与首选尺寸;克隆派生数据后释放锁再进行计算。
let staged = mount
.layout
.as_ref()
@@ -156,6 +180,7 @@ impl NativeCallable for Method {
})
.transpose()?;
let input = object(input)?;
// 默认 hitTest 检查局部点在已提交盒内,采用左上包含、右下排除;布局阶段几何不稳定,禁止调用。
if matches!(self.query, Query::HitTest) {
if staged.is_some() {
return Err(invalid("hitTest is unavailable during layout"));
@@ -166,11 +191,13 @@ impl NativeCallable for Method {
x >= 0.0 && y >= 0.0 && x < geometry.width && y < geometry.height,
)));
}
// 存在布局 Context 但尚无当前节点,说明准备顺序不完整,不能回退读取旧快照伪装成当前数据。
if matches!(&staged, Some((None, _, _))) {
return Err(invalid(
"layout context is not prepared for this occurrence",
));
}
// 准备好则复用本轮不可变节点,非 staged 调用才从托管 Block 提取视觉快照。
let node = if let Some((Some(node), _, _)) = &staged {
node.clone()
} else {
@@ -182,9 +209,11 @@ impl NativeCallable for Method {
let min_h = number(cx, input, "minHeight", false)?;
let max_w = number(cx, input, "maxWidth", false)?;
let max_h = number(cx, input, "maxHeight", false)?;
// 最小值不可超过最大值,否则后面的 clamp 会无意义甚至失败;各数值已通过非负有限检查。
if min_w > max_w || min_h > max_h {
return Err(invalid("minimum exceeds maximum constraint"));
}
// 混合源/原生布局使用已经测好的子尺寸,避免重复执行子树;独立调用则走完整原生测量。
let size = if let Some((_, children, _)) = &staged {
layout::measure_native(&node, children.clone(), [min_w, min_h, max_w, max_h])
.map_err(|_| invalid("native measurement exceeds layout limits"))?
@@ -196,46 +225,58 @@ impl NativeCallable for Method {
self.desktop,
"MeasureResult",
vec![
// MeasureResult 最终明确落在传入 min/max 内,原生内容自然大小不能突破源协议约束。
("width", value(size.width.clamp(min_w, max_w))),
("height", value(size.height.clamp(min_h, max_h))),
],
)?)
}
Query::Arrange => {
let x = number(cx, input, "x", false)?;
let y = number(cx, input, "y", false)?;
let x = number(cx, input, "x", true)?;
let y = number(cx, input, "y", true)?;
let width = number(cx, input, "width", false)?;
let height = number(cx, input, "height", false)?;
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("visual bounds exceed limits"));
}
// Arrange 优先使用本轮子测量;没有 staged 时按孩子的实际分配约束逐个测量后再安排。
let children = if let Some((_, children, _)) = &staged {
children.clone()
} else {
node.children()
.iter()
.map(|child| {
.enumerate()
.map(|(child_index, child)| {
size(
child,
layout::arranged_child_constraints(
layout::occurrence_constraints(
&node,
[0.0, 0.0, width, height],
child,
layout::arranged_child_constraints(
&node,
[0.0, 0.0, width, height],
Size { width, height },
),
Size { width, height },
child_index,
),
)
})
.collect::<Result<Vec<_>, _>>()?
};
// preferred 保留弹性分配所需首选大小,与已受约束的实测 children 区分;无 staged 时两者相同。
let preferred = staged
.as_ref()
.map_or(children.as_slice(), |(_, _, preferred)| {
preferred.as_slice()
});
// 先依据父盒分配 flex 尺寸,再由 arrange_native 求位置,避免先定位再改变孩子大小导致重叠。
let children =
layout::flex_sizes(&node, Size { width, height }, &children, preferred);
let points = layout::arrange_native(&node, x, y, Size { width, height }, &children)
.map_err(|_| invalid("native arrangement exceeds limits"))?;
let mut result = Vec::new();
// 每个返回 Bounds 附稳定 slot,与 children 当前顺序分开;调用方可按 slot 校验组成身份。
for ((slot, size), (x, y)) in node.slots.iter().zip(children).zip(points) {
let bounds = record(
cx,
@@ -251,6 +292,7 @@ impl NativeCallable for Method {
cx.set_attr(bounds, "slot", Value::Number((*slot as i64).into()))?;
result.push(bounds);
}
// Arrangement 返回受控 ObjectList,不返回宿主 Vec 或任意形状的源对象集合。
let result = crate::object_list::collection(cx, result, self.desktop)?;
Value::Object(record(
cx,
@@ -268,8 +310,27 @@ impl NativeCallable for Method {
return Err(invalid("visual bounds exceed limits"));
}
let mut commands = Vec::new();
if let Some(command) = layout::paint_native(&node, x, y, Size { width, height }) {
// 将 Rust 默认绘制命令反向编码成源 PaintCommand,允许源覆盖方法调用基类 paint 后继续组合。
for command in layout::paint_commands(&node, x, y, Size { width, height }) {
let fields = match command {
RenderCommand::Scene3D {
x,
y,
width,
height,
view,
} => {
// 3D 通过不可变 PaintView payload 暴露场景快照,不把运行中 VM 的场景对象交给后续绘制线程。
let snapshot = cx.allocate(crate::scene3d::PaintView(view))?;
vec![
("kind", Value::Text("scene3d".into())),
("x", value(x)),
("y", value(y)),
("width", value(width)),
("height", value(height)),
("scene", Value::Object(snapshot)),
]
}
RenderCommand::Text {
x,
y,
@@ -286,6 +347,7 @@ impl NativeCallable for Method {
("size", Value::Number((size as i64).into())),
("color", Value::Text(color)),
];
// 可选文本换行边界成对写入;缺省时两个字段都不创建,与源 paint 解码契约保持一致。
if let Some([width, height]) = wrap_bounds {
fields.push(("wrapWidth", value(width)));
fields.push(("wrapHeight", value(height)));
@@ -308,6 +370,7 @@ impl NativeCallable for Method {
("radius", value(radius)),
("color", Value::Text(color)),
],
// 当前反向编码支持明确列出的命令;新增 RenderCommand 变体时须同步此转换,不能静默丢弃。
_ => return Err(invalid("unsupported native paint command")),
};
let command = record(cx, self.desktop, "PaintCommand", fields)?;
@@ -328,15 +391,18 @@ impl NativeCallable for Method {
}
#[derive(Debug)]
/// Point 的矩形/圆形命中辅助方法,保存结果类型和具体几何判定种类。
struct PointMethod {
expected: ObjectRef,
circle: bool,
signature: CallSignature,
}
impl NativeCallable for PointMethod {
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
fn signature(&self) -> Option<CallSignature> {
Some(self.signature.clone())
}
/// 圆形测试接收 Point 加圆心/半径共四项;矩形测试接收 Point 和 Bounds 共两项。
fn arity(&self) -> Option<endor_core::CallArity> {
Some(endor_core::CallArity::exact(if self.circle {
4
@@ -345,10 +411,12 @@ impl NativeCallable for PointMethod {
}))
}
/// 先验证 Point 名义类型与有限坐标,再分别计算圆内距离或 Bounds 包含关系,返回 Bool。
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let Some(Value::Object(point)) = args.first() else {
return Err(invalid("Point method requires a Point"));
};
// Point 辅助方法验证名义接收者,不能只要有 x/y 字段就接受普通对象。
if !cx.store().is_instance(*point, self.expected)? {
return Err(invalid("Point method requires a Point"));
}
@@ -364,6 +432,7 @@ impl NativeCallable for PointMethod {
else {
return Err(invalid("inCircle requires center x/y and radius"));
};
// 圆心和半径按 f64 解析及求平方距离,在有界数值内避免不必要的 f32 精度损失。
let parse = |value: &endor_core::EndorNumber| -> Result<f64, VmError> {
let value = value
.to_string()
@@ -377,6 +446,7 @@ impl NativeCallable for PointMethod {
let dx = x as f64 - parse(cx_value)?;
let dy = y as f64 - parse(cy_value)?;
let radius = parse(radius)?;
// 半径不能负;零半径只包含圆心,边界使用 <= 因此圆周也命中。
if radius < 0.0 {
return Err(invalid("circle radius must be nonnegative"));
}
@@ -389,12 +459,14 @@ impl NativeCallable for PointMethod {
let top = number(cx, *bounds, "y", true)?;
let width = number(cx, *bounds, "width", false)?;
let height = number(cx, *bounds, "height", false)?;
// 矩形辅助同样使用半开右/下边界;这与 geometry::Rect 的闭区间 contains 不同,调用方应留意语义。
x >= left && y >= top && x < left + width && y < top + height
};
Ok(NativeResult::Return(Value::Bool(inside)))
}
}
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
@@ -413,6 +485,7 @@ pub fn install(
let measure = nominal("MeasureResult")?;
let arrangement = nominal("Arrangement")?;
let display = nominal("DisplayList")?;
// 把四个默认方法安装到 Block,源子类可覆盖;所有视觉协议声明 ReadOnly,允许派生结果分配而不修改模型。
for (name, query, input, returns) in [
("measure", Query::Measure, constraints, measure),
("arrange", Query::Arrange, bounds, arrangement),
@@ -441,6 +514,7 @@ pub fn install(
&vm.dict_get(registry, &Value::Text("Point".into()))?
.ok_or(invalid("missing Point type"))?,
)?;
// Point.inside 接收 Bounds,inCircle 接收圆心 x/y 和半径;签名都包含隐式 Point receiver。
for (name, circle) in [("inside", false), ("inCircle", true)] {
let function = vm.allocate(ContextNativeFunction::new(
name,
+224
View File
@@ -0,0 +1,224 @@
//! 旧 Page.mode 的兼容空间策略,以及页面级的正逆相机变换。
//! 新组合应优先使用普通布局、ScrollView 和独立场景视口;保留这里是为了兼容已有 window/vertical/horizontal/canvas 页面。
//!
//! Page space is independent of the native window. The camera is applied once
//! for painting and inverted once for input; layout and Mounts stay in world units.
use crate::{geometry::Point, layout::Size};
use endor_core::VmError;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
/// 旧 Page 的有限或自由空间模式,新场景视口不依赖它表达布局方向。
pub enum Mode {
#[default]
// 双轴被原生视口限制;兼容相机固定在原点且比例为 1。
Window,
// 宽度固定,高度允许展开到布局上限,只沿 y 方向导航。
Vertical,
// 高度固定,宽度允许展开到布局上限,只沿 x 方向导航。
Horizontal,
// 双轴自由布局并允许平移缩放;相机不按内容边缘钳制。
Canvas,
}
impl Mode {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
match self {
Self::Window => "window",
Self::Vertical => "vertical",
Self::Horizontal => "horizontal",
Self::Canvas => "canvas",
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(name: &str) -> Result<Self, VmError> {
match name {
"window" => Ok(Self::Window),
"vertical" => Ok(Self::Vertical),
"horizontal" => Ok(Self::Horizontal),
"canvas" => Ok(Self::Canvas),
_ => Err(VmError::InvalidArgument(
"Page.mode expects window, vertical, horizontal or canvas",
)),
}
}
/// 为旧页面模式生成有限轴和可延伸轴的根约束。
pub fn constraints(self, viewport: Option<Size>) -> [f32; 4] {
// 未收到原生视口尺寸时采用布局默认根约束;不能凭空建立固定宽高。
let Some(size) = viewport else {
return crate::layout::root_constraints(None);
};
let limit = crate::ui_bridge::MAX_DIMENSION as f32;
// 数组采用 [最小宽, 最小高, 最大宽, 最大高];竖/横页面仍至少占满当前视口。
match self {
Self::Window => [size.width, size.height, size.width, size.height],
Self::Vertical => [size.width, size.height, size.width, limit],
Self::Horizontal => [size.width, size.height, limit, size.height],
Self::Canvas => [0.0, 0.0, limit, limit],
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
/// 页面级兼容相机状态和有限内容范围,支持绘制与命中正逆变换。
pub struct Surface {
pub mode: Mode,
// 发布给原生侧的页面身份文本,不是托管强引用。
pub page: String,
// 视图左上角对应的世界坐标;绘制时先减 pan 再乘 zoom。
pub pan: Point,
#[serde(default = "unit")]
pub zoom: f32,
// 布局产出的内容宽高,用于有限模式钳制;不决定 Canvas 的导航边界。
pub extent: Size,
}
/// 生成页面相机计算所需的单位比例或边界默认值。
fn unit() -> f32 {
1.0
}
impl Default for Surface {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
mode: Mode::Window,
page: String::new(),
pan: Point::default(),
zoom: 1.0,
extent: Size::default(),
}
}
}
impl Surface {
/// 指定页面模式并显式使用单位缩放,其他字段采用空页面默认值;无需分配 VM 或原生窗口资源。
pub fn new(mode: Mode) -> Self {
Self {
mode,
zoom: 1.0,
..Self::default()
}
}
/// 把页面视图坐标反算为旧 Page 相机下的世界坐标。
// 输入在视图空间,先除缩放再加世界偏移;调用方需保证 zoom 有限且大于零。
pub fn to_world(&self, point: Point) -> Point {
Point::new(
point.x / self.zoom + self.pan.x,
point.y / self.zoom + self.pan.y,
)
}
/// 把旧页面世界坐标变换到可见视图坐标。
// 输入在世界空间,先减相机偏移再缩放,恰与 to_world 互逆。
pub fn to_view(&self, point: Point) -> Point {
Point::new(
(point.x - self.pan.x) * self.zoom,
(point.y - self.pan.y) * self.zoom,
)
}
/// 判断给定点是否位于目标范围;需要相机转换的类型会先转换再判断。
pub fn contains(&self, point: [f32; 2], viewport: Option<Size>) -> bool {
// 传入的数组是世界坐标。转换后使用左/上包含、右/下排除的视口边界;
// 未知视口时仍排除负坐标,但不限制右/下界。
let point = self.to_view(Point::new(point[0], point[1]));
point.x >= 0.0
&& point.y >= 0.0
&& viewport.is_none_or(|size| point.x < size.width && point.y < size.height)
}
/// 只对有限页面按实际内容范围钳制偏移;自由画布不按内容边缘停止导航。
pub fn clamp(&mut self, viewport: Size) {
// Window 重置所有导航;单轴模式清除另一轴并恢复单位缩放。
// 内容小于视口时 max(0) 令可滚动范围归零,避免 clamp 出现反向上下限。
match self.mode {
Mode::Window => {
self.pan = Point::default();
self.zoom = 1.0;
}
Mode::Vertical => {
self.zoom = 1.0;
self.pan.x = 0.0;
self.pan.y = self
.pan
.y
.clamp(0.0, (self.extent.height - viewport.height).max(0.0));
}
Mode::Horizontal => {
self.zoom = 1.0;
self.pan.y = 0.0;
self.pan.x = self
.pan
.x
.clamp(0.0, (self.extent.width - viewport.width).max(0.0));
}
Mode::Canvas => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// 检查页面相机的往返变换,缩放时反求 pan 使指针下世界点固定,并按视口验证世界点可见性。
fn camera_inverse_and_cursor_anchored_zoom_preserve_world_position() {
// 先验证相机正逆变换,再改变 zoom 并反求 pan,保证指针下的世界点不发生漂移。
let mut view = Surface::new(Mode::Canvas);
view.pan = Point::new(-240.0, 120.0);
view.zoom = 0.75;
let pointer = Point::new(300.0, 180.0);
let world = view.to_world(pointer);
assert_eq!(view.to_view(world), pointer);
view.zoom = 2.0;
view.pan = Point::new(
world.x - pointer.x / view.zoom,
world.y - pointer.y / view.zoom,
);
assert_eq!(view.to_view(world), pointer);
assert!(view.contains(
[world.x, world.y],
Some(Size {
width: 500.0,
height: 400.0
})
));
assert!(!view.contains(
[-1000.0, -1000.0],
Some(Size {
width: 500.0,
height: 400.0
})
));
}
#[test]
/// 纵向页面将过大偏移夹到内容减视口范围;内容缩短到视口以内后偏移必须归零,横向偏移也禁用。
fn finite_pages_clamp_after_content_shrinks() {
// 先将 900 的旧偏移钳到 800-600=200,再缩短内容至视口以内,确认偏移回零。
let mut view = Surface::new(Mode::Vertical);
view.pan = Point::new(25.0, 900.0);
view.extent = Size {
width: 400.0,
height: 800.0,
};
view.clamp(Size {
width: 400.0,
height: 600.0,
});
assert_eq!(view.pan, Point::new(0.0, 200.0));
view.extent.height = 100.0;
view.clamp(Size {
width: 400.0,
height: 600.0,
});
assert_eq!(view.pan.y, 0.0);
}
}
+52 -52
View File
@@ -1,15 +1,21 @@
//! 合成整个原生窗口的显示列表:页面画面、宿主侧栏和开发工具绘制。
//! 窗口逻辑尺寸与设备像素比例在这里衔接。页面内容先应用其视角并裁剪,宿主控件的区域和命中布局另外返回。
//!
use crate::{
activity::Activity,
developer_tools::{self, DeveloperToolsLayout, Workspace as DeveloperToolsState},
display_list::DisplayList,
display_list::{DisplayList, DrawCommand},
document::RenderDocument,
geometry::{Point, Rect},
};
#[derive(Clone, Debug, Default)]
/// 宿主管理区域的几何布局,供窗口事件分辨页面与工具命中。
pub struct SceneLayout {
pub developer_tools: DeveloperToolsLayout,
}
/// 组合页面、开发工具和侧栏的设备像素显示列表,同时返回供命中使用的宿主布局。
pub fn build_scene(
width: u32,
height: u32,
@@ -17,8 +23,9 @@ pub fn build_scene(
document: &RenderDocument,
developer_tools_state: &mut DeveloperToolsState,
activity: &Activity,
tab_chrome: crate::tabs::Chrome,
sidebar: &mut crate::sidebar::State,
) -> DisplayScene {
// 活动标签的 developer_tool 标记决定管理页类型,不靠当前窗口标题猜测;同步工具状态后再合成。
let tool = document
.frame
.tabs
@@ -27,69 +34,61 @@ pub fn build_scene(
.and_then(|tab| tab.developer_tool.as_deref());
developer_tools_state.select(tool);
developer_tools_state.document_updated(document);
let mut display_list = document.display_list(scale).unwrap_or_else(|_| {
let left = sidebar.left();
let surface = &document.frame.surface;
// width/height 为设备像素,sidebar.left 为逻辑单位,先乘 scale 得到页面可用裁剪区域。
let content = Rect::new(
left * scale,
0.0,
(width as f32 - left * scale).max(0.0),
height as f32,
);
// 管理页使用单位页面缩放,避免业务 Page 相机改变宿主工具字号;普通页叠加旧 Surface zoom。
let zoom = if tool.is_some() { 1.0 } else { surface.zoom };
// 显示列表转换失败时降级为空默认文档;这只保护原生绘制,不把无效命令伪装成成功执行。
let mut page = document.display_list(scale * zoom).unwrap_or_else(|_| {
RenderDocument::default()
.display_list(scale)
.expect("default document is valid")
});
let offset = crate::tabs::height(&document.frame.tabs);
let inset = crate::tabs::INSET;
let left = crate::tabs::RAIL;
let content = crate::geometry::Rect::new(
left * scale,
offset * scale,
(width as f32 - (left + inset) * scale).max(0.0),
(height as f32 - (offset + inset) * scale).max(0.0),
);
display_list.translate(crate::geometry::Point::new(left * scale, offset * scale));
let mut framed = DisplayList::default();
use crate::display_list::DrawCommand;
framed.push(DrawCommand::Clear(crate::system_ui::theme::BAR));
framed.push(DrawCommand::PushClip(content));
for command in display_list.commands() {
if let DrawCommand::Clear(color) = command {
framed.push(DrawCommand::FillRoundedRect {
rect: content,
radius: 10.0 * scale,
color: *color,
});
} else {
framed.push(command.clone());
// 页面显示列表先已乘设备比例/zoom,再加入侧栏偏移和世界 pan 的设备像素平移,保持缩放顺序一致。
page.translate(Point::new(
(left - surface.pan.x * zoom) * scale,
-surface.pan.y * zoom * scale,
));
let mut display_list = DisplayList::default();
// 全窗只做一次 Clear,后续跳过页面列表自身 Clear,防止其覆盖侧栏或突破页面裁剪。
display_list.push(DrawCommand::Clear(
crate::document::parse_color(&document.frame.background)
.unwrap_or(crate::system_ui::theme::BG),
));
display_list.push(DrawCommand::PushClip(content));
for command in page.commands() {
if !matches!(command, DrawCommand::Clear(_)) {
display_list.push(command.clone());
}
}
framed.push(DrawCommand::PopClip);
let mut display_list = framed;
crate::tabs::draw(
tab_chrome.offset,
&mut display_list,
&document.frame.tabs,
width as f32 / scale,
scale,
tab_chrome.pointer,
&tab_chrome.visual,
);
if let Some(index) = tab_chrome.insertion {
crate::tabs::draw_insertion(
tab_chrome.offset,
&mut display_list,
&document.frame.tabs,
width as f32 / scale,
index,
scale,
);
}
display_list.push(DrawCommand::PopClip);
let (mut developer_list, mut developer_layout) = developer_tools::build(
width.saturating_sub(((left + inset) * scale) as u32),
height.saturating_sub(((offset + inset) * scale) as u32),
width.saturating_sub((left * scale) as u32),
height,
scale,
document,
developer_tools_state,
activity,
);
developer_list.translate(crate::geometry::Point::new(left * scale, offset * scale));
developer_layout.translate(left, offset);
// 工具命令在设备空间平移,交互布局在逻辑空间平移,两者比例不同但实际对齐同一页面起点。
developer_list.translate(Point::new(left * scale, 0.0));
developer_layout.translate(left, 0.0);
display_list.extend(developer_list);
// 侧栏最后覆盖合成,内部按逻辑尺寸绘制后统一设备缩放,不受页面相机影响。
let mut chrome = sidebar.draw(
&document.frame.tabs,
height as f32 / scale,
developer_tools_state.accent,
);
chrome.scale(scale);
display_list.extend(chrome);
DisplayScene {
display_list,
layout: SceneLayout {
@@ -98,6 +97,7 @@ pub fn build_scene(
}
}
/// 整窗合成结果:显示列表与同帧管理交互几何一起返回。
pub struct DisplayScene {
pub display_list: DisplayList,
pub layout: SceneLayout,
+746
View File
@@ -0,0 +1,746 @@
//! 有界 CPU 3D 渲染器。使用相同摄像机与世界变换进行视锥裁剪、深度测试、纹理采样和射线拾取。
//! 不可变 View 可以缓存光栅结果;相机变化会产生新视图。当前不是 GPU 后端,像素和光栅工作预算用于限制分配与循环工作量。
//! 面板 UI 先渲染为有限纹理,再以透视校正 UV 映射进世界。与相机相关的投影宽高比由具体视口提供。
//!
//! Software 3D surfaces share the application's existing CPU compositor. Mesh
//! visibility and picking use the same camera and transformed world triangles.
pub mod math;
pub mod panel;
pub mod spatial;
use math::{Camera, Matrix, Projection, Vec3};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub const MAX_TRIANGLES: usize = 65_536;
pub const MAX_VERTICES: usize = 32_768;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 不可变顶点、三角形和可选 UV;多个网格可共享同一数值缓冲。
pub struct Geometry {
pub vertices: Vec<Vec3>,
pub triangles: Vec<[u32; 3]>,
#[serde(default)]
pub uv: Vec<[f32; 2]>,
}
impl Geometry {
/// 检查顶点与三角数量、索引范围、坐标有效性及逐顶点 UV;允许缺省 UV,不在此限制 UV 到单位区间。
pub fn valid(&self) -> bool {
// 检查顶点/三角形资源量和所有索引范围;UV 可省略,否则必须逐顶点对应且每个分量有限。
// 不在这里强制 UV 位于 [0,1],纹理采样阶段再钳制。
self.vertices.len() <= MAX_VERTICES
&& (self.uv.is_empty()
|| self.uv.len() == self.vertices.len()
&& self.uv.iter().flatten().all(|value| value.is_finite()))
&& self.triangles.len() <= MAX_TRIANGLES
&& self.vertices.iter().all(|point| point.valid())
&& self
.triangles
.iter()
.flatten()
.all(|&index| (index as usize) < self.vertices.len())
}
/// 生成以原点为中心的盒体顶点和三角形索引;位置和旋转由网格世界矩阵另行描述。
pub fn cuboid(width: f32, height: f32, depth: f32) -> Self {
// 八个角点共享位置,每个面拆两个三角形;尺寸有效性由构造调用边界检查,cuboid 本身只生成数值。
let (x, y, z) = (width / 2., height / 2., depth / 2.);
Self {
uv: vec![],
vertices: vec![
Vec3::new(-x, -y, -z),
Vec3::new(x, -y, -z),
Vec3::new(x, y, -z),
Vec3::new(-x, y, -z),
Vec3::new(-x, -y, z),
Vec3::new(x, -y, z),
Vec3::new(x, y, z),
Vec3::new(-x, y, z),
],
triangles: vec![
[0, 2, 1],
[0, 3, 2],
[4, 5, 6],
[4, 6, 7],
[0, 1, 5],
[0, 5, 4],
[3, 7, 6],
[3, 6, 2],
[0, 4, 7],
[0, 7, 3],
[1, 2, 6],
[1, 6, 5],
],
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 一次场景实例:几何、世界矩阵、颜色与可选 UI 面板,身份用于拾取回关联。
pub struct Mesh {
// 纯文本业务关联身份,用于拾取返回;不作为渲染线程可操作的托管引用。
pub identity: String,
pub geometry: Arc<Geometry>,
pub world: Matrix,
pub color: [f32; 3],
pub panel: Option<Panel>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 面板内普通 UI 的绘制命令及其裁剪/二维变换,逻辑排版不随外部相机改变。
pub struct UiSurface {
pub commands: Vec<crate::document::RenderCommand>,
pub clips: Vec<Option<[f32; 4]>>,
pub views: Vec<Option<crate::viewport::Transform>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 世界平面的有限 UI 纹理描述,包含像素尺寸、背景与内部显示内容。
pub struct Panel {
pub key: u64,
pub width: u32,
pub height: u32,
pub background: [f32; 3],
pub surface: Option<Arc<UiSurface>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 方向光的原生数值,方向和强度在场景快照阶段校验。
pub struct Light {
pub direction: Vec3,
pub color: [f32; 3],
pub intensity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
/// 不含摄像机的世界实例和光照集合;BVH 是可丢弃的派生缓存。
pub struct Scene {
pub meshes: Vec<Mesh>,
pub lights: Vec<Light>,
pub ambient: f32,
pub background: [f32; 3],
#[serde(skip)]
// 序列化忽略的世界 BVH,反序列化后可按场景网格重建;相机改变不要求重建世界空间索引。
pub acceleration: Arc<spatial::Index>,
}
impl PartialEq for Scene {
/// 比较会影响场景结果的数据;加速索引属于派生缓存,不参与场景语义相等。
fn eq(&self, other: &Self) -> bool {
self.meshes == other.meshes
&& self.lights == other.lights
&& self.ambient == other.ambient
&& self.background == other.background
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// 显式相机与共享世界的组合,一个 Scene 可由多个独立 View 观察。
pub struct View {
pub scene: Arc<Scene>,
pub camera: Camera,
}
#[derive(Debug)]
/// 只读绘制结果使用的名义包装,让源 PaintCommand 可以携带不可变 3D 快照。
pub struct PaintView(pub Arc<View>);
impl endor_core::ObjectPayload for PaintView {
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
fn as_any(&self) -> &dyn std::any::Any {
self
}
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
fn kind_name(&self) -> &'static str {
"immutable-3d-paint"
}
}
#[derive(Debug, Clone, PartialEq)]
/// 射线与实际三角形的精确交点,含实例身份、距离、法线和重心坐标。
pub struct Hit {
pub instance: usize,
pub identity: String,
pub point: Vec3,
pub normal: Vec3,
pub distance: f32,
pub triangle: usize,
pub barycentric: [f32; 3],
}
impl View {
/// 递归验证相机、材质、灯光、网格/面板表面及全场景资源预算;世界 UI 嵌套最多八层。
pub fn valid(&self) -> bool {
thread_local! { static DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) }; }
// 验证可能递归进入面板内的 3D;作用域结束时归还深度,错误返回也不泄漏计数。
struct Guard;
impl Drop for Guard {
/// 退出当前嵌套校验层时递减线程局部深度;短路失败与正常返回都恢复同一计数。
fn drop(&mut self) {
DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
if !DEPTH.with(|depth| {
if depth.get() >= 8 {
false
} else {
depth.set(depth.get() + 1);
true
}
}) {
return false;
}
// 深度检查通过才建立 Guard,之后任一短路校验失败都会恢复计数;上限覆盖嵌套面板中的 3D 命令。
let _guard = Guard;
let color_valid = |color: [f32; 3]| {
color
.iter()
.all(|value| value.is_finite() && (0.0..=1.0).contains(value))
};
// 同时限制实例数、灯光数、累计顶点数和三角形数,单个 Geometry 合法不代表整个 Scene 未超预算。
self.camera.valid()
&& self.scene.ambient.is_finite()
&& (0.0..=1.0).contains(&self.scene.ambient)
&& color_valid(self.scene.background)
&& self.scene.meshes.len() <= 4096
&& self.scene.lights.len() <= 32
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.vertices.len())
.sum::<usize>()
<= 262_144
&& self
.scene
.meshes
.iter()
.map(|mesh| mesh.geometry.triangles.len())
.sum::<usize>()
<= MAX_TRIANGLES
&& self.scene.meshes.iter().all(|mesh| {
// 矩阵系数有限之外还验证每个变换后顶点范围,避免局部几何合法但累计世界变换产生无限坐标。
mesh.geometry.valid()
&& mesh.world.0.iter().all(|v| v.is_finite())
&& color_valid(mesh.color)
&& mesh
.geometry
.vertices
.iter()
.all(|point| mesh.world.point(*point).valid())
// 面板纹理限制在 1..2048 每轴,内部命令/视图/裁剪表等长并递归校验绘制;多一个命令额度留给装配焦点框。
&& mesh.panel.as_ref().is_none_or(|panel| {
panel.width > 0
&& panel.height > 0
&& panel.width <= 2048
&& panel.height <= 2048
&& color_valid(panel.background)
&& panel.surface.as_ref().is_none_or(|surface| {
surface.commands.len() <= 10001
&& surface.commands.len() == surface.views.len()
&& surface.commands.len() == surface.clips.len()
&& surface.views.iter().flatten().all(|view| view.valid())
&& surface.clips.iter().flatten().all(|clip| {
clip.iter().all(|value| value.is_finite())
&& clip[2] >= 0.
&& clip[3] >= 0.
})
&& surface
.commands
.iter()
.all(|command| command.validate().is_ok())
})
})
})
&& self.scene.lights.iter().all(|light| {
color_valid(light.color)
&& light.direction.valid()
// 方向光必须近似单位向量,确保点积仅反映夹角而非把方向模长重复乘入光照强度。
&& (light.direction.length() - 1.).abs() < 0.0001
&& light.intensity.is_finite()
&& (0.0..=32.0).contains(&light.intensity)
})
}
/// 按包围盒候选发射射线,再检查三角形;距离相同时保留与绘制顺序一致的实例。
pub fn pick(&self, u: f32, v: f32, aspect: f32) -> Option<Hit> {
// 拾取只接受有效相机、视口内归一化点及正有限宽高比;不在此完整重验几何,调用方应先验证 View。
if !self.camera.valid()
|| !(0.0..=1.0).contains(&u)
|| !(0.0..=1.0).contains(&v)
|| !aspect.is_finite()
|| aspect <= 0.
{
return None;
}
let ray = self.camera.ray(u, v, aspect)?;
let basis = self.camera.basis()?;
let mut hit: Option<Hit> = None;
let temporary;
// 无缓存或网格数失配时本次临时重建;数量相同的内容变更须由快照层生成新索引,不靠 matches 检测。
let index = if self.scene.acceleration.matches(self.scene.meshes.len()) {
&*self.scene.acceleration
} else {
temporary = spatial::Index::build(&self.scene.meshes);
&temporary
};
for (entry, instance) in index.ray(ray) {
// BVH 候选按包围盒进入距离排序;后续盒子起点已比最近真实交点远时,可安全停止查询。
if hit.as_ref().is_some_and(|hit| entry > hit.distance) {
break;
}
let mesh = &self.scene.meshes[instance];
for (triangle, indices) in mesh.geometry.triangles.iter().enumerate() {
let [a, b, c] =
indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
let Some((distance, barycentric)) = ray.triangle(a, b, c) else {
continue;
};
let point = ray.origin + ray.direction * distance;
// near/far 限制相机前向深度,而不是到相机的欧氏距离;离轴射线同样按视锥范围筛选。
let depth = self.camera.view(basis, point).z;
if depth < self.camera.near
|| depth > self.camera.far
|| hit.as_ref().is_some_and(|hit| {
hit.distance < distance
|| hit.distance == distance && hit.instance <= instance
})
{
continue;
}
// 使用变换后的世界顶点计算面法线,支持非均匀缩放;退化三角形没有可靠法线,不作为命中。
let Some(normal) = (b - a).cross(c - a).normalized() else {
continue;
};
// 同距离时保留较早实例,与绘制严格小于深度比较一致;同实例后续同距三角形也不会覆盖前者。
hit = Some(Hit {
instance,
identity: mesh.identity.clone(),
point,
normal,
distance,
triangle,
barycentric,
});
}
}
hit
}
}
/// 把有限颜色通道转换成最终像素使用的字节。
fn channel(value: f32) -> u32 {
(value.clamp(0., 1.) * 255.).round() as u32
}
/// 将三个颜色通道打包为软件像素缓冲使用的 RGB 值。
pub fn rgb(color: [f32; 3]) -> u32 {
channel(color[0]) << 16 | channel(color[1]) << 8 | channel(color[2])
}
/// 某个不可变视图在特定分辨率和宽高比下的光栅结果。
struct CachedSurface {
view: Arc<View>,
width: u32,
height: u32,
aspect: f32,
pixels: Arc<[u32]>,
}
#[derive(Default)]
/// 限制条目数和总像素数的表面缓存;不承担场景业务所有权。
struct SurfaceCache {
entries: std::collections::VecDeque<CachedSurface>,
pixels: usize,
}
/// 按不可变视图身份、分辨率和投影比例查光栅缓存;未命中时在锁外绘制,支持嵌套世界 UI。
pub fn surface(
view: &Arc<View>,
width: u32,
height: u32,
aspect: f32,
) -> Result<Arc<[u32]>, &'static str> {
// 全局缓存以 View Arc 身份+输出尺寸+aspect 为键;值相等但不同 Arc 的视图不会自动命中。
static CACHE: std::sync::OnceLock<std::sync::Mutex<SurfaceCache>> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(Default::default);
{
let cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
if let Some(entry) = cache.entries.iter().find(|entry| {
Arc::ptr_eq(&entry.view, view)
&& entry.width == width
&& entry.height == height
&& entry.aspect == aspect
}) {
return Ok(entry.pixels.clone());
}
}
// Do not hold a cache lock across rendering: world UI can contain another
// 3D viewport and recurse through the compositor.
let pixels: Arc<[u32]> = render_aspect(view, width, height, aspect)?.into();
let mut cache = cache.lock().map_err(|_| "3D surface cache poisoned")?;
// 按插入顺序从队首淘汰,不在命中时搬到队尾;最多 8 项、累计 8388608 像素,渲染单表面另有限额。
while cache.entries.len() >= 8 || cache.pixels + pixels.len() > 8_388_608 {
let Some(entry) = cache.entries.pop_front() else {
break;
};
cache.pixels -= entry.pixels.len();
}
cache.pixels += pixels.len();
cache.entries.push_back(CachedSurface {
view: view.clone(),
width,
height,
aspect,
pixels: pixels.clone(),
});
Ok(pixels)
}
/// Homogeneous frustum planes expressed in view space. Clipping before division
/// handles triangles crossing the near plane without huge screen coordinates.
#[derive(Clone, Copy)]
/// 裁剪过程中的相机空间顶点,位置与 UV 必须共同插值。
struct Vertex {
position: Vec3,
uv: [f32; 2],
}
impl Vertex {
/// 按裁剪交点比例同时插值位置和 UV,保持新顶点的几何与纹理坐标一致。
fn lerp(self, other: Self, t: f32) -> Self {
Self {
position: self.position + (other.position - self.position) * t,
uv: [
self.uv[0] + (other.uv[0] - self.uv[0]) * t,
self.uv[1] + (other.uv[1] - self.uv[1]) * t,
],
}
}
}
/// 生成相机空间六个视锥平面,供三角形裁剪与 BVH 可见性查询共用。
fn frustum_planes(camera: Camera, aspect: f32) -> [[f32; 4]; 6] {
// 平面内侧满足 ax+by+cz+d>=0。透视侧平面随 z 展开,正交侧平面是固定 x/y 边界。
let (x, y, slope) = match camera.projection {
Projection::Perspective { fov } => {
let y = (fov.to_radians() / 2.).tan();
(y * aspect, y, 1.)
}
Projection::Orthographic { height } => (height * aspect / 2., height / 2., 0.),
};
[
[0., 0., 1., -camera.near],
[0., 0., -1., camera.far],
[1., 0., x * slope, x * (1. - slope)],
[-1., 0., x * slope, x * (1. - slope)],
[0., 1., y * slope, y * (1. - slope)],
[0., -1., y * slope, y * (1. - slope)],
]
}
/// 在透视除法前逐平面裁剪多边形,并同步插值 UV,避免跨近面顶点投影到极大坐标。
fn clip(mut polygon: Vec<Vertex>, camera: Camera, aspect: f32) -> Vec<Vertex> {
for [a, b, c, d] in frustum_planes(camera, aspect) {
if polygon.is_empty() {
break;
}
let distance = |vertex: Vertex| {
let p = vertex.position;
a * p.x + b * p.y + c * p.z + d
};
let mut output = Vec::new();
// 从闭多边形最后一点连到第一点开始,逐边做 Sutherland–Hodgman 裁剪,不遗漏首尾边。
let mut previous = *polygon.last().unwrap();
let mut before = distance(previous);
for current in polygon {
let after = distance(current);
// 边跨平面时按带符号距离求交点比例,同步插值相机位置和 UV;内侧端点另行保留。
if (before >= 0.) != (after >= 0.) {
output.push(previous.lerp(current, before / (before - after)));
}
if after >= 0. {
output.push(current);
}
previous = current;
before = after;
}
polygon = output;
}
polygon
}
#[cfg(test)]
/// 把当前绘制输入转换为输出表面;分配、裁剪和预算由本实现检查。
pub fn render(view: &View, width: u32, height: u32) -> Result<Vec<u32>, &'static str> {
render_aspect(view, width, height, width as f32 / height as f32)
}
/// 先校验并预留颜色/深度缓冲预算,再剔除、裁剪、投影和光栅化三角形;超预算返回错误。
fn render_aspect(
view: &View,
width: u32,
height: u32,
aspect: f32,
) -> Result<Vec<u32>, &'static str> {
let _scope = crate::software_renderer::RenderScope::enter()?;
// 颜色与深度各占一份面积,既有每表面硬上限,也在下方计入递归累计像素预算。
let area = width as usize * height as usize;
// Offscreen targets have a hard per-surface resource bound.
if area > 4_194_304
|| width == 0
|| height == 0
|| !aspect.is_finite()
|| aspect <= 0.
|| !view.valid()
{
return Err("invalid 3D surface or resource budget exceeded");
}
// 先申请两份工作量再分配缓冲,深度初始化正无穷,首个有效面可写入。
crate::software_renderer::reserve_pixels(area.saturating_mul(2))?;
let mut pixels = vec![rgb(view.scene.background); area];
let mut depths = vec![f32::INFINITY; area];
let camera = view.camera;
let basis = camera.basis().unwrap();
let perspective = matches!(camera.projection, Projection::Perspective { .. });
// 点已经经过视锥裁剪,才做透视除法;屏幕 y 向下,保留相机 z 用于后续深度/UV 校正。
let project = |point: Vec3| {
let half = match camera.projection {
Projection::Perspective { fov } => point.z * (fov.to_radians() / 2.).tan(),
Projection::Orthographic { height } => height / 2.,
};
Vec3::new(
(point.x / (half * aspect) + 1.) * width as f32 / 2.,
(1. - point.y / half) * height as f32 / 2.,
point.z,
)
};
let mut work = 0usize;
let temporary;
let index = if view.scene.acceleration.matches(view.scene.meshes.len()) {
&*view.scene.acceleration
} else {
temporary = spatial::Index::build(&view.scene.meshes);
&temporary
};
// 宽阶段只筛选候选:离屏网格不进入后续纹理分配和逐三角形处理。
for instance in index.visible(camera, aspect) {
let mesh = &view.scene.meshes[instance];
// 只为宽阶段可见的面板生成普通 UI 纹理;同一 mesh 的所有三角形复用本次纹理。
let texture = mesh.panel.as_ref().map(panel::texture).transpose()?;
for indices in &mesh.geometry.triangles {
let [a, b, c] = indices.map(|i| mesh.world.point(mesh.geometry.vertices[i as usize]));
let Some(normal) = (b - a).cross(c - a).normalized() else {
continue;
};
// 每三角形使用平面法线做 Lambert 漫反射,环境光为基础,方向光逐 RGB 累加;背光点积钳零。
let mut light = [view.scene.ambient; 3];
for source in &view.scene.lights {
let intensity = normal.dot(source.direction * -1.).max(0.) * source.intensity;
for (channel, color) in light.iter_mut().zip(source.color) {
*channel += intensity * color;
}
}
// 材质色乘累计光照后转 RGB,通道最终钳制;UI 面板纹理分支直接采纹理,不再乘这份网格光照。
let color = rgb(std::array::from_fn(|i| mesh.color[i] * light[i]));
// 在透视除法之前裁剪,并插入具有正确 UV 的交点;不能先投影再裁剪近面。
let polygon = clip(
indices
.map(|index| Vertex {
position: camera.view(
basis,
mesh.world.point(mesh.geometry.vertices[index as usize]),
),
// 没有 UV 的顶点用 [0,0];普通无纹理网格不依赖它,带纹理面板由构造器提供四角 UV。
uv: mesh
.geometry
.uv
.get(index as usize)
.copied()
.unwrap_or([0., 0.]),
})
.to_vec(),
camera,
aspect,
);
if polygon.len() < 3 {
continue;
}
// 裁剪后可能多于三个顶点,用扇形重新三角化,保持同一平面的纹理参数。
for index in 1..polygon.len() - 1 {
let vertices = [polygon[0], polygon[index], polygon[index + 1]];
let [a, b, c] = vertices.map(|vertex| project(vertex.position));
let min_x = a.x.min(b.x).min(c.x).floor().max(0.) as u32;
let max_x = a.x.max(b.x).max(c.x).ceil().min(width as f32) as u32;
let min_y = a.y.min(b.y).min(c.y).floor().max(0.) as u32;
let max_y = a.y.max(b.y).max(c.y).ceil().min(height as f32) as u32;
// 按屏幕包围矩形面积计工作,而非仅成功写像素数;大量重叠/细长三角形也不能绕过循环预算。
work += max_x.saturating_sub(min_x) as usize * max_y.saturating_sub(min_y) as usize;
if work > 32_000_000 {
return Err("3D raster work budget exceeded");
}
let edge = |a: Vec3, b: Vec3, x: f32, y: f32| {
(x - a.x) * (b.y - a.y) - (y - a.y) * (b.x - a.x)
};
// 有向面积允许任意顶点绕序,除相同 area 得到一致重心权重;近零面积跳过,不做背面剔除。
let area = edge(a, b, c.x, c.y);
if area.abs() < 1e-6 {
continue;
}
for y in min_y..max_y {
for x in min_x..max_x {
let px = x as f32 + 0.5;
let py = y as f32 + 0.5;
// 在像素中心求三个屏幕重心权重,负值容忍 1e-5 舍入误差,明显在外则不做深度和纹理计算。
let weights = [
edge(b, c, px, py) / area,
edge(c, a, px, py) / area,
edge(a, b, px, py) / area,
];
if weights.iter().any(|weight| *weight < -1e-5) {
continue;
}
// 透视投影下使用倒深度插值,正交投影下可直接线性插值。
let depth = if perspective {
1. / (weights[0] / a.z + weights[1] / b.z + weights[2] / c.z)
} else {
weights[0] * a.z + weights[1] * b.z + weights[2] * c.z
};
let index = y as usize * width as usize + x as usize;
// 只让最近表面写像素;等深度保留先绘制实例,与拾取的排序约定一致。
if depth < depths[index] {
depths[index] = depth;
pixels[index] =
if let (Some(texture), Some(panel)) = (&texture, &mesh.panel) {
// UV 也要除以深度再插值,否则倾斜面板会出现纹理拉伸失真。
let interpolated = |axis| {
if perspective {
depth
* (weights[0] * vertices[0].uv[axis] / a.z
+ weights[1] * vertices[1].uv[axis] / b.z
+ weights[2] * vertices[2].uv[axis] / c.z)
} else {
weights[0] * vertices[0].uv[axis]
+ weights[1] * vertices[1].uv[axis]
+ weights[2] * vertices[2].uv[axis]
}
};
// 透视校正 UV 后做最近邻采样,钳到 [0,1] 并限制到最后一个像素,避免 UV=1 恰好越界。
let u = (interpolated(0).clamp(0., 1.) * panel.width as f32)
.min((panel.width - 1) as f32)
as usize;
let v = (interpolated(1).clamp(0., 1.) * panel.height as f32)
.min((panel.height - 1) as f32)
as usize;
texture[v * panel.width as usize + u]
} else {
color
};
}
}
}
}
}
}
Ok(pixels)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// 构造99个远离视锥的网格,宽阶段只保留正中实例;有/无预存BVH的精确拾取身份与距离必须一致。
fn spatial_index_culls_offscreen_meshes_and_preserves_ray_pick() {
let mut view = view(Geometry::cuboid(1., 1., 1.));
let scene = Arc::make_mut(&mut view.scene);
let mesh = scene.meshes[0].clone();
for index in 1..100 {
let mut far = mesh.clone();
far.identity = format!("far{index}");
far.world = Matrix::trs(
Vec3::new(index as f32 * 100., 0., 0.),
Vec3::default(),
Vec3::new(1., 1., 1.),
);
scene.meshes.push(far);
}
scene.acceleration = Arc::new(spatial::Index::build(&scene.meshes));
// 远处实例应被宽阶段剔除;随后比较带缓存与临时重建索引的精确拾取身份/距离,防止加速改变结果。
assert_eq!(scene.acceleration.visible(view.camera, 1.), vec![0]);
let ray = view.camera.ray(0.5, 0.5, 1.).unwrap();
assert_eq!(scene.acceleration.ray(ray).len(), 1);
let indexed = view.pick(0.5, 0.5, 1.).unwrap();
let mut plain = view.clone();
Arc::make_mut(&mut plain.scene).acceleration = Arc::default();
let fallback = plain.pick(0.5, 0.5, 1.).unwrap();
assert_eq!(indexed.identity, fallback.identity);
assert_eq!(indexed.distance, fallback.distance);
}
/// 构造测试最小视图:默认相机、原点红网格、纯环境光与白背景,不依赖业务VM或外部光源。
fn view(geometry: Geometry) -> View {
View {
camera: Camera::default(),
scene: Arc::new(Scene {
meshes: vec![Mesh {
panel: None,
identity: "mesh".into(),
geometry: Arc::new(geometry),
world: Matrix::default(),
color: [1., 0., 0.],
}],
lights: vec![],
ambient: 1.,
background: [1., 1., 1.],
acceleration: Arc::default(),
}),
}
}
#[test]
/// 跨越近裁剪面的三角形仍应产生像素,射线命中受同一 near/far 约束;缩短远面后命中消失。
fn triangles_crossing_near_plane_are_clipped_and_pick_uses_the_same_range() {
let mut view = view(Geometry {
uv: vec![],
vertices: vec![
Vec3::new(-1., -1., 0.5),
Vec3::new(1., -1., 2.),
Vec3::new(0., 1., 2.),
],
triangles: vec![[0, 1, 2]],
});
view.camera.position = Vec3::default();
view.camera.target = Vec3::new(0., 0., 1.);
view.camera.near = 1.;
view.camera.far = 10.;
view.camera.projection = Projection::Perspective { fov: 90. };
// 三角形跨近面仍应产生可见红像素,拾取也必须尊重相同 near/far;缩短 far 后命中消失。
let pixels = render(&view, 64, 64).unwrap();
assert!(pixels.contains(&0xFF0000));
let hit = view.pick(0.5, 0.5, 1.).unwrap();
assert!(hit.point.z >= view.camera.near && hit.point.z <= view.camera.far);
view.camera.far = 1.1;
assert!(view.pick(0.5, 0.5, 1.).is_none());
}
#[test]
/// 验证局部TRS先于父变换作用于点;正交投影不同水平采样的射线方向相同但原点按可见宽度平移。
fn group_transform_order_and_orthographic_rays_are_consistent() {
let local = Matrix::trs(
Vec3::new(1., 0., 0.),
Vec3::new(0., 90., 0.),
Vec3::new(1., 1., 1.),
);
let parent = Matrix::trs(
Vec3::new(10., 0., 0.),
Vec3::default(),
Vec3::new(1., 1., 1.),
);
let transformed = parent.compose(local).point(Vec3::new(0., 0., 1.));
assert!((transformed.x - 12.).abs() < 1e-5 && transformed.z.abs() < 1e-5);
let camera = Camera {
projection: Projection::Orthographic { height: 4. },
..Camera::default()
};
let left = camera.ray(0.25, 0.5, 1.).unwrap();
let right = camera.ray(0.75, 0.5, 1.).unwrap();
assert_eq!(left.direction, right.direction);
assert_eq!(right.origin.x - left.origin.x, 2.);
}
#[test]
/// 同一不可变视图/尺寸/aspect复用像素Arc;改变aspect重绘,超像素预算或退化相机必须拒绝。
fn surface_cache_reuses_immutable_frames_and_rejects_invalid_resources() {
let view = Arc::new(view(Geometry::cuboid(1., 1., 1.)));
let first = surface(&view, 64, 64, 1.).unwrap();
let second = surface(&view, 64, 64, 1.).unwrap();
// 同一不可变视图/尺寸/aspect 返回同像素 Arc;仅改变 aspect 即使像素尺寸不变也必须重绘。
assert!(Arc::ptr_eq(&first, &second));
let wide = surface(&view, 64, 64, 2.).unwrap();
assert!(!Arc::ptr_eq(&first, &wide));
assert_ne!(first, wide);
assert!(render(&view, 4000, 4000).is_err());
let mut invalid = (*view).clone();
invalid.camera.target = invalid.camera.position;
assert!(!invalid.valid());
assert!(render(&invalid, 64, 64).is_err());
}
}
+305
View File
@@ -0,0 +1,305 @@
//! 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。
//! 矩阵组合顺序决定局部变换如何进入父坐标系;接近退化的逆矩阵、法线和射线使用 Option 拒绝无效计算。
//!
use serde::{Deserialize, Serialize};
use std::ops::{Add, Mul, Sub};
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
/// 三维数学向量;构造不校验分量,需在输入边界显式调用 valid,不是托管 Vec3 对象。
pub struct Vec3 {
pub x: f32,
pub y: f32,
pub z: f32,
}
impl Vec3 {
/// 直接保存三个分量,不隐式归一化或拒绝非有限值。
pub const fn new(x: f32, y: f32, z: f32) -> Self {
Self { x, y, z }
}
/// 计算向量点积,用于方向投影、光照和相交判断。
pub fn dot(self, b: Self) -> f32 {
self.x * b.x + self.y * b.y + self.z * b.z
}
/// 计算右手叉积,用于摄像机基向量和三角形法线。
pub fn cross(self, b: Self) -> Self {
Self::new(
self.y * b.z - self.z * b.y,
self.z * b.x - self.x * b.z,
self.x * b.y - self.y * b.x,
)
}
/// 计算向量长度;归一化前还需检查是否接近零。
pub fn length(self) -> f32 {
self.dot(self).sqrt()
}
/// 仅在长度有效且不退化时返回单位向量,避免除零产生非有限结果。
pub fn normalized(self) -> Option<Self> {
// 先求模长,拒绝非有限值和长度不大于 1e-6 的向量;返回 None 让相机/法线调用方停止退化计算。
let length = self.length();
(length.is_finite() && length > 1e-6).then(|| self * (1.0 / length))
}
/// 要求三维向量各分量有限、绝对值不超过一百万;构造和算术运算不会自动调用此检查。
pub fn valid(self) -> bool {
// 构造函数与算术运算不会自动验证;此入口显式要求每个分量有限且绝对值至多一百万。
[self.x, self.y, self.z]
.iter()
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
}
}
impl Add for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
fn add(self, b: Self) -> Self {
Self::new(self.x + b.x, self.y + b.y, self.z + b.z)
}
}
impl Sub for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
fn sub(self, b: Self) -> Self {
Self::new(self.x - b.x, self.y - b.y, self.z - b.z)
}
}
impl Mul<f32> for Vec3 {
type Output = Self;
/// 按分量执行向量加减或标量乘法,保持数学运算不依赖 VM 状态。
fn mul(self, b: f32) -> Self {
Self::new(self.x * b, self.y * b, self.z * b)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 仿射 4×4 变换矩阵,支持局部到世界的组合与求逆。
pub struct Matrix(pub [f32; 16]);
impl Default for Matrix {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self([
1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
])
}
}
impl Matrix {
/// 求仿射世界矩阵的逆;退化或不可逆时返回空值。
pub fn inverse(self) -> Option<Self> {
// 这里按仿射矩阵处理:最后一行假设为 [0,0,0,1],只逆左上 3×3,不实现一般透视矩阵求逆。
let m = self.0;
let determinant = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[1] * (m[4] * m[10] - m[6] * m[8])
+ m[2] * (m[4] * m[9] - m[5] * m[8]);
// 接近零行列式意味着某方向被压扁或数值不稳定;不能继续除以 determinant。
if !determinant.is_finite() || determinant.abs() < 1e-9 {
return None;
}
// 用伴随矩阵除行列式得到线性部分逆矩阵;先把逆平移留为零。
let d = 1. / determinant;
let mut out = Self([
(m[5] * m[10] - m[6] * m[9]) * d,
(m[2] * m[9] - m[1] * m[10]) * d,
(m[1] * m[6] - m[2] * m[5]) * d,
0.,
(m[6] * m[8] - m[4] * m[10]) * d,
(m[0] * m[10] - m[2] * m[8]) * d,
(m[2] * m[4] - m[0] * m[6]) * d,
0.,
(m[4] * m[9] - m[5] * m[8]) * d,
(m[1] * m[8] - m[0] * m[9]) * d,
(m[0] * m[5] - m[1] * m[4]) * d,
0.,
0.,
0.,
0.,
1.,
]);
// 仿射逆平移为 -A⁻¹t。此时 out 平移为零,所以 point 只应用刚求得的线性逆变换。
let translation = out.point(Vec3::new(m[3], m[7], m[11])) * -1.;
out.0[3] = translation.x;
out.0[7] = translation.y;
out.0[11] = translation.z;
Some(out)
}
/// 组合父子仿射变换,调用顺序决定局部坐标最终落在哪个世界位置。
pub fn compose(self, local: Self) -> Self {
// 行主序存储,点视作列向量;self.compose(local) = self × local,即先应用 local 再应用 self。
let mut out = [0.; 16];
for row in 0..4 {
for col in 0..4 {
out[row * 4 + col] = (0..4)
.map(|k| self.0[row * 4 + k] * local.0[k * 4 + col])
.sum();
}
}
Self(out)
}
/// 对一个位置应用矩阵,包括平移项;不要用它代替纯方向变换。
pub fn point(self, p: Vec3) -> Vec3 {
Vec3::new(
self.0[0] * p.x + self.0[1] * p.y + self.0[2] * p.z + self.0[3],
self.0[4] * p.x + self.0[5] * p.y + self.0[6] * p.z + self.0[7],
self.0[8] * p.x + self.0[9] * p.y + self.0[10] * p.z + self.0[11],
)
}
/// 由平移、角度旋转和缩放构造局部仿射矩阵。
pub fn trs(position: Vec3, rotation: Vec3, scale: Vec3) -> Self {
// 输入欧拉角单位为度,先转换为弧度。组合为 T × Rz × Ry × Rx × S,列分别乘对应缩放轴。
let (sx, cx) = rotation.x.to_radians().sin_cos();
let (sy, cy) = rotation.y.to_radians().sin_cos();
let (sz, cz) = rotation.z.to_radians().sin_cos();
// Local X, then Y, then Z rotation; scale first, translation last.
Self([
cz * cy * scale.x,
(cz * sy * sx - sz * cx) * scale.y,
(cz * sy * cx + sz * sx) * scale.z,
position.x,
sz * cy * scale.x,
(sz * sy * sx + cz * cx) * scale.y,
(sz * sy * cx - cz * sx) * scale.z,
position.y,
-sy * scale.x,
cy * sx * scale.y,
cy * cx * scale.z,
position.z,
0.,
0.,
0.,
1.,
])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 投影策略:透视视场角或正交可见高度。
pub enum Projection {
// fov 表示垂直视场角(度),水平范围再乘视口宽高比。
Perspective { fov: f32 },
// height 是相机空间可见总高度,与深度无关;宽度为 height × aspect。
Orthographic { height: f32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
/// 原生摄像机描述,包括观察基、近远面和投影参数;宽高比由视口提供。
pub struct Camera {
pub position: Vec3,
pub target: Vec3,
pub up: Vec3,
pub near: f32,
pub far: f32,
pub projection: Projection,
}
impl Default for Camera {
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
fn default() -> Self {
Self {
position: Vec3::new(0., 0., 5.),
target: Vec3::default(),
up: Vec3::new(0., 1., 0.),
near: 0.1,
far: 1000.,
projection: Projection::Perspective { fov: 50. },
}
}
}
#[derive(Debug, Clone, Copy)]
/// 摄像机的右、上、前单位基向量。
pub struct Basis {
pub right: Vec3,
pub up: Vec3,
pub forward: Vec3,
}
impl Camera {
/// 从观察位置、目标和上方向建立正交相机基;方向退化时拒绝。
pub fn basis(self) -> Option<Basis> {
// forward 指向观察目标;up 输入只提供大致上方向,叉积后重新正交化。
// 位置等于目标、up 为零或与前向平行时,normalized 返回 None。
let forward = (self.target - self.position).normalized()?;
let right = forward.cross(self.up).normalized()?;
let up = right.cross(forward).normalized()?;
Some(Basis { right, up, forward })
}
/// 验证相机向量/观察基、裁剪面次序和投影参数;退化方向、非法视场角或正交高度均返回 false。
pub fn valid(self) -> bool {
// 除了向量范围,还要求有效观察基、0 < near < far,以及合法视场角或正交高度。
self.position.valid()
&& self.target.valid()
&& self.up.valid()
&& self.basis().is_some()
&& self.near.is_finite()
&& self.far.is_finite()
&& self.near > 0.
&& self.far > self.near
&& self.far <= 1_000_000.
&& match self.projection {
Projection::Perspective { fov } => fov.is_finite() && (1.0..=175.0).contains(&fov),
Projection::Orthographic { height } => {
height.is_finite() && height > 0. && height <= 1_000_000.
}
}
}
/// 把世界点投影到相机的右/上/前基向量坐标。
pub fn view(self, basis: Basis, p: Vec3) -> Vec3 {
// 先把世界点相对化,再点乘正交基;这里前方 z 为正,与某些图形 API 的负 z 约定不同。
let p = p - self.position;
Vec3::new(p.dot(basis.right), p.dot(basis.up), p.dot(basis.forward))
}
/// 从视口归一化坐标生成透视或正交射线;宽高比由具体视口提供。
pub fn ray(self, u: f32, v: f32, aspect: f32) -> Option<Ray> {
let basis = self.basis()?;
// u/v 通常是视口内 [0,1],映射到 [-1,1] 并翻转屏幕 y。
// 此函数不钳制输入,捕获拖动可以从视口外继续生成射线;aspect 由调用方保证有效。
let (x, y) = (u * 2. - 1., 1. - v * 2.);
match self.projection {
Projection::Perspective { fov } => {
// 透视射线共用相机原点,方向沿前向加上视平面偏移,最后归一化。
let half = (fov.to_radians() / 2.).tan();
Some(Ray {
origin: self.position,
direction: (basis.forward
+ basis.right * (x * half * aspect)
+ basis.up * (y * half))
.normalized()?,
})
}
// 正交射线方向都为 forward,只改变起点在相机右/上平面中的位置。
Projection::Orthographic { height } => Some(Ray {
origin: self.position
+ basis.right * (x * height * aspect / 2.)
+ basis.up * (y * height / 2.),
direction: basis.forward,
}),
}
}
}
#[derive(Debug, Clone, Copy)]
/// 世界坐标中的射线原点与方向,供网格和面板求交。
pub struct Ray {
pub origin: Vec3,
pub direction: Vec3,
}
impl Ray {
/// 求射线与三角形相交距离和重心坐标,拒绝退化三角形及射线起点后方的交点。
pub fn triangle(self, a: Vec3, b: Vec3, c: Vec3) -> Option<(f32, [f32; 3])> {
// Möller–Trumbore 求交:使用两条三角形边与叉积解重心系数,不先构造平面方程。
let ab = b - a;
let ac = c - a;
let p = self.direction.cross(ac);
let determinant = ab.dot(p);
// 近零表示射线与面平行或三角形退化;保留正负行列式,因此不做背面剔除。
if determinant.abs() < 1e-7 {
return None;
}
let inverse = 1.0 / determinant;
let t = self.origin - a;
// u 是顶点 b 的重心权重,必须位于 [0,1];v 是 c 的权重,a 的权重由 1-u-v 得到。
let u = t.dot(p) * inverse;
if !(0.0..=1.0).contains(&u) {
return None;
}
let q = t.cross(ab);
let v = self.direction.dot(q) * inverse;
if v < 0. || u + v > 1. {
return None;
}
// 返回的是射线参数 t,方向为单位向量时才等于世界距离;负值在射线起点后方,被拒绝。
let distance = ac.dot(q) * inverse;
(distance >= 0.).then_some((distance, [1. - u - v, u, v]))
}
}
+292
View File
@@ -0,0 +1,292 @@
//! 连接 3D 平面与普通 UI 的坐标和绘制桥梁。
//! Mapping 沿嵌套面板逐层把页面指针变成纹理坐标;可见命中还须检查最近射线交点,防止点穿遮挡物。
//! compose 把各 Mount 的绘制命令按所属面板分组并递归装配;texture 再调用普通软件渲染器绘制面板内容。
//!
//! Perspective UI mapping. The same world plane supplies texture coordinates
//! during rendering and inverse pointer coordinates during event dispatch.
use super::{
View,
math::{Projection, Vec3},
};
use crate::{
geometry::{Point, Rect},
viewport::Transform,
};
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
/// 一次面板出现的正逆投影信息,可以链接父面板形成嵌套映射。
pub struct Mapping {
// 包含本面板的 3D 视口 Mount ID,与 key 一起区分同一业务场景的多次出现。
pub owner: u64,
// 面板在场景中的稳定标识,不能仅凭 Block 身份选择某个具体面板出现。
pub key: u64,
pub view: Arc<View>,
pub rect: [f32; 4],
// 外层二维相机变换;逆映射先处理它,再进入 3D 摄像机射线计算。
pub outer: Transform,
pub clip: Option<[f32; 4]>,
// 嵌套面板的外层链;反投影从父到子,正投影从子到父。
pub parent: Option<Arc<Mapping>>,
}
impl Mapping {
/// 沿父面板逆投影到当前平面的 UI 像素;要求可见时使用最近射线命中拒绝被遮挡的目标。
pub fn texture_point(&self, point: Point, clipped: bool) -> Option<Point> {
// 先把最外层窗口坐标逐层反投影为父面板像素,再检查当前面板所属空间的裁剪。
let point = if let Some(parent) = &self.parent {
parent.texture_point(point, clipped)?
} else {
point
};
if clipped
&& self
.clip
.is_some_and(|clip| !Rect::new(clip[0], clip[1], clip[2], clip[3]).contains(point))
{
return None;
}
let point = self.outer.inverse(point);
let [x, y, width, height] = self.rect;
if width <= 0. || height <= 0. {
return None;
}
// rect 是 3D 视口在当前二维空间的位置,归一化坐标传给该视口相机;不是面板纹理坐标。
let (u, v) = ((point.x - x) / width, (point.y - y) / height);
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
.as_ref()
.is_some_and(|panel| panel.key == self.key)
})?;
let panel = mesh.panel.as_ref()?;
if clipped {
// 指针落在平面投影内还不够:最近三角形必须属于本面板,不能点穿前方网格。
let hit = self.view.pick(u, v, width / height)?;
if self.view.scene.meshes[hit.instance]
.panel
.as_ref()
.is_none_or(|panel| panel.key != self.key)
{
return None;
}
}
// 捕获拖动可以不要求指针仍位于面板内,但仍使用原面板平面的连续逆映射。
let ray = self.view.camera.ray(u, v, width / height)?;
let center = mesh.world.point(Vec3::default());
let normal = (mesh.world.point(Vec3::new(1., 0., 0.)) - center)
.cross(mesh.world.point(Vec3::new(0., 1., 0.)) - center);
// 用世界变换后的局部 x/y 轴叉积求平面法线,支持旋转和非均匀缩放;平行射线无交点。
let denominator = normal.dot(ray.direction);
if denominator.abs() < 1e-7 {
return None;
}
// 解射线与无限平面交点,拒绝非有限及相机后方结果;clipped=false 时允许点落在面板边界外。
let distance = (center - ray.origin).dot(normal) / denominator;
if !distance.is_finite() || distance <= 0. {
return None;
}
// 把世界交点乘逆 world 回到面板局部坐标,逆矩阵退化时拒绝。
let local = mesh
.world
.inverse()?
.point(ray.origin + ray.direction * distance);
// 面板网格约定以局部原点为中心;x 向右、y 向上,转换为纹理左上原点、y 向下。
let world_width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
let world_height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
let point = Point::new(
(local.x / world_width + 0.5) * panel.width as f32,
(0.5 - local.y / world_height) * panel.height as f32,
);
(point.x.is_finite() && point.y.is_finite()).then_some(point)
}
/// 把当前面板的 UI 像素正向投影到外层页面空间,供坐标查询和交互测试使用。
pub fn project_point(&self, point: Point) -> Option<Point> {
let mesh = self.view.scene.meshes.iter().find(|mesh| {
mesh.panel
.as_ref()
.is_some_and(|panel| panel.key == self.key)
})?;
let panel = mesh.panel.as_ref()?;
let width = mesh.geometry.vertices[1].x - mesh.geometry.vertices[0].x;
let height = mesh.geometry.vertices[0].y - mesh.geometry.vertices[2].y;
// 正向路径与 texture_point 对称:纹理像素先转局部平面点,再进入世界空间。
let world = mesh.world.point(Vec3::new(
(point.x / panel.width as f32 - 0.5) * width,
(0.5 - point.y / panel.height as f32) * height,
0.,
));
let camera = self.view.camera;
let local = camera.view(camera.basis()?, world);
// 点在近远面之外时无可用投影;此方法仅算位置,不检查前方遮挡或二维 clip。
if local.z <= camera.near || local.z >= camera.far {
return None;
}
let [x, y, w, h] = self.rect;
// 透视半高随相机深度增长,正交半高恒定;横向除数还包含视口宽高比。
let half = match camera.projection {
Projection::Perspective { fov } => local.z * (fov.to_radians() / 2.).tan(),
Projection::Orthographic { height } => height / 2.,
};
let point = self.outer.point(Point::new(
x + (local.x / (half * w / h) + 1.) * w / 2.,
y + (1. - local.y / half) * h / 2.,
));
// 当前投影得到的是父面板纹理空间;继续向外层正投影,直到窗口页面坐标。
if let Some(parent) = &self.parent {
parent.project_point(point)
} else {
Some(point)
}
}
}
#[derive(Clone)]
/// 归属明确的一条内部 UI 绘制命令,装配时还保留裁剪、变换和 owner。
struct Entry {
command: crate::document::RenderCommand,
clip: Option<[f32; 4]>,
view: Option<Transform>,
owner: u64,
}
/// Collect a panel's ordinary paint subtree into its texture. Nested 3D
/// commands are assembled recursively before their containing texture.
/// 按面板所属视口和稳定 key 分组绘制命令,递归将内部 UI 装配进 3D 面板表面。
pub fn compose(frame: &mut crate::document::Frame) -> anyhow::Result<()> {
use crate::document::RenderCommand;
use std::collections::{HashMap, HashSet};
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 分组键包含视口 owner 和面板 key,防止同一场景在两个视口出现时互相混入绘制命令。
let mut groups = HashMap::<(u64, u64), Vec<Entry>>::new();
let mut roots = Vec::new();
// 每条命令都有出现归属;据此分流为窗口根命令或某个世界面板的内部命令。
for (index, command) in frame.commands.iter().enumerate() {
let owner = frame.command_mounts[index];
let mount = mounts[&owner];
let entry = Entry {
command: command.clone(),
clip: frame.command_clips[index],
view: frame.command_views[index],
owner,
};
if let Some(panel) = &mount.panel {
groups
.entry((panel.owner, panel.key))
.or_default()
.push(entry);
} else {
roots.push(entry);
}
}
// 面板内焦点框也必须作为纹理内容绘制,不能留在窗口根层,否则会脱离世界面板位置。
if let Some(mount) = frame.focus.and_then(|id| mounts.get(&id))
&& let Some(panel) = &mount.panel
{
groups
.entry((panel.owner, panel.key))
.or_default()
.push(Entry {
command: RenderCommand::StrokeRoundedRect {
x: mount.x,
y: mount.y,
width: mount.width,
height: mount.height,
radius: 7.,
stroke_width: 2.,
color: "#4F6FDB".into(),
},
clip: mount.clip,
view: mount.view,
owner: mount.id,
});
}
/// 递归装配一个绘制条目包含的世界 UI,检查嵌套环与深度,再生成独立不可变视图。
fn assemble(
entry: Entry,
groups: &HashMap<(u64, u64), Vec<Entry>>,
path: &mut HashSet<u64>,
depth: usize,
) -> anyhow::Result<Entry> {
let mut entry = entry;
if let RenderCommand::Scene3D { view, .. } = &mut entry.command
&& view.scene.meshes.iter().any(|mesh| mesh.panel.is_some())
{
// 只有含面板的 Scene3D 命令才递归装配;path 跟踪当前祖先出现,限制深度小于 8 并拒绝环。
anyhow::ensure!(
depth < 8 && path.insert(entry.owner),
"3D UI paint nesting cycle or depth budget exceeded"
);
// 只构造新的原生快照,不写回源 Scene3D;同一业务场景仍可被其他视口共享。
let mut scene = (*view.scene).clone();
for mesh in &mut scene.meshes {
if let Some(panel) = &mut mesh.panel {
let mut surface = super::UiSurface {
commands: Vec::new(),
clips: Vec::new(),
views: Vec::new(),
};
// 按照原命令顺序递归装配面板内容;嵌套 3D 子命令先完成,父面板才取得完整表面。
for child in groups.get(&(entry.owner, panel.key)).into_iter().flatten() {
let child = assemble(child.clone(), groups, path, depth + 1)?;
surface.commands.push(child.command);
surface.clips.push(child.clip);
surface.views.push(child.view);
}
panel.surface = Some(Arc::new(surface));
}
}
*view = Arc::new(View {
scene: Arc::new(scene),
camera: view.camera,
});
// 退出当前分支时撤销祖先标记,允许同一身份在其他独立分支中出现而不误判成环。
path.remove(&entry.owner);
}
Ok(entry)
}
let mut path = HashSet::new();
// 面板内部命令已经移入 UiSurface,根帧只保留外层命令;四张平行元数据表同步重建。
frame.commands.clear();
frame.command_clips.clear();
frame.command_views.clear();
frame.command_mounts.clear();
for entry in roots {
let entry = assemble(entry, &groups, &mut path, 0)?;
frame.commands.push(entry.command);
frame.command_clips.push(entry.clip);
frame.command_views.push(entry.view);
frame.command_mounts.push(entry.owner);
}
Ok(())
}
/// 为一个面板分配有限像素表面,并调用普通 UI 软件渲染;当前实现不会按面板内容独立缓存纹理。
pub fn texture(panel: &super::Panel) -> Result<Vec<u32>, &'static str> {
// 先向统一像素预算申请有限纹理面积,再分配背景缓冲;嵌套面板同样受该预算约束。
crate::software_renderer::reserve_pixels(panel.width as usize * panel.height as usize)?;
let mut pixels =
vec![super::rgb(panel.background); panel.width as usize * panel.height as usize];
if let Some(surface) = &panel.surface {
// 将普通 UI 表面包装为临时文档复用现有显示列表和软件渲染路径;比例 1 对应纹理像素。
let document = crate::document::RenderDocument {
frame: crate::document::Frame {
background: format!("#{:06X}", super::rgb(panel.background)),
commands: surface.commands.clone(),
command_views: surface.views.clone(),
command_clips: surface.clips.clone(),
..Default::default()
},
..Default::default()
};
let list = document
.display_list(1.)
.map_err(|_| "invalid 3D UI texture commands")?;
crate::software_renderer::SoftwareRenderer::render(
&list,
&mut pixels,
panel.width,
panel.height,
)?;
}
Ok(pixels)
}
+285
View File
@@ -0,0 +1,285 @@
//! 不可变世界空间 BVH。用网格变换后的包围盒快速筛选视锥与射线候选。
//! 空间索引只做宽阶段筛选;最终拾取仍检查真实三角形。相机变化可复用索引,几何或变换变化需要新索引。
//!
//! Immutable world-space BVH. Camera changes reuse it; it contains no VM data.
use super::{
Mesh,
math::{Basis, Camera, Ray, Vec3},
};
#[derive(Debug, Clone, Copy)]
/// 变换后网格的世界空间 AABB,做保守筛选而不是精确表面描述。
pub struct Bounds {
pub min: Vec3,
pub max: Vec3,
}
impl Bounds {
/// 变换网格顶点并求世界 AABB,作为空间索引的保守外包围。
fn mesh(mesh: &Mesh) -> Option<Self> {
let mut points = mesh
.geometry
.vertices
.iter()
.map(|point| mesh.world.point(*point));
// 空网格没有包围盒,返回 None 后不会进入树;首顶点初始化上下界,避免把原点误算进边界。
let first = points.next()?;
Some(points.fold(
Self {
min: first,
max: first,
},
|bounds, point| Self {
min: Vec3::new(
bounds.min.x.min(point.x),
bounds.min.y.min(point.y),
bounds.min.z.min(point.z),
),
max: Vec3::new(
bounds.max.x.max(point.x),
bounds.max.y.max(point.y),
bounds.max.z.max(point.z),
),
},
))
}
/// 求两个包围盒的最小共同外包围,供 BVH 父节点使用。
fn union(self, other: Self) -> Self {
Self {
min: Vec3::new(
self.min.x.min(other.min.x),
self.min.y.min(other.min.y),
self.min.z.min(other.min.z),
),
max: Vec3::new(
self.max.x.max(other.max.x),
self.max.y.max(other.max.y),
self.max.z.max(other.max.z),
),
}
}
/// 取指定轴中心位置,供最长轴排序和分割使用。
fn center(self, axis: usize) -> f32 {
let min = [self.min.x, self.min.y, self.min.z];
let max = [self.max.x, self.max.y, self.max.z];
(min[axis] + max[axis]) / 2.
}
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
fn ray(self, ray: Ray) -> Option<f32> {
// slab 算法逐轴交区间;near 从零开始,只保留射线前方,起点在盒内时返回零。
let mut near = 0f32;
let mut far = f32::INFINITY;
for (origin, direction, min, max) in [
(ray.origin.x, ray.direction.x, self.min.x, self.max.x),
(ray.origin.y, ray.direction.y, self.min.y, self.max.y),
(ray.origin.z, ray.direction.z, self.min.z, self.max.z),
] {
// 近乎平行该轴时不做除法;起点在 slab 外即可拒绝,在 slab 内则该轴不收窄距离。
if direction.abs() < 1e-12 {
if origin < min || origin > max {
return None;
}
continue;
}
let a = (min - origin) / direction;
let b = (max - origin) / direction;
// 方向可为负,先排序该轴进入/退出距离,再与之前各轴区间求交。
near = near.max(a.min(b));
far = far.min(a.max(b));
if far < near {
return None;
}
}
Some(near)
}
/// 检查八角是否全落在任一视锥平面外侧;返回保守可见性,不代表真实三角形可见。
fn visible(self, camera: Camera, basis: Basis, planes: &[[f32; 4]; 6]) -> bool {
// 用三位索引枚举 AABB 八角,再转到相机空间,使角点与视锥平面处于同一坐标系。
let corners: [Vec3; 8] = std::array::from_fn(|index| {
camera.view(
basis,
Vec3::new(
if index & 1 == 0 {
self.min.x
} else {
self.max.x
},
if index & 2 == 0 {
self.min.y
} else {
self.max.y
},
if index & 4 == 0 {
self.min.z
} else {
self.max.z
},
),
)
});
// 只有某个平面外侧包含全部八角时才剔除;盒与视锥可能无真实相交也会保守保留。
!planes.iter().any(|[a, b, c, d]| {
corners
.iter()
.all(|point| a * point.x + b * point.y + c * point.z + d < 0.)
})
}
}
#[derive(Debug)]
/// BVH 叶或分支;叶保存实例索引,分支保存两个子节点索引。
enum Node {
Leaf {
bounds: Bounds,
items: Vec<usize>,
},
Branch {
bounds: Bounds,
left: usize,
right: usize,
},
}
impl Node {
/// 读取叶/分支统一的包围盒,隐藏节点种类差异。
fn bounds(&self) -> Bounds {
match self {
Self::Leaf { bounds, .. } | Self::Branch { bounds, .. } => *bounds,
}
}
}
#[derive(Debug, Default)]
/// 由不可变场景建立的包围盒树,供相机视锥和射线查询复用。
pub struct Index {
bounds: Vec<Option<Bounds>>,
nodes: Vec<Node>,
root: Option<usize>,
}
impl Index {
/// 用变换后的网格顶点建立世界 AABB 与 BVH;忽略空网格,树内仅保存原生几何下标。
pub fn build(meshes: &[Mesh]) -> Self {
// 保留与原网格下标等长的包围盒数组;items 仅收集非空网格,最终候选仍指向原场景下标。
let bounds: Vec<_> = meshes.iter().map(Bounds::mesh).collect();
let mut items: Vec<_> = bounds
.iter()
.enumerate()
.filter_map(|(index, bounds)| bounds.map(|_| index))
.collect();
let mut index = Self {
bounds,
nodes: Vec::new(),
root: None,
};
if !items.is_empty() {
index.root = Some(index.split(&mut items));
}
index
}
/// 按包围盒最长轴的中心排序并递归二分,叶节点保留少量网格候选。
fn split(&mut self, items: &mut [usize]) -> usize {
let bounds = items
.iter()
.map(|&index| self.bounds[index].unwrap())
.reduce(Bounds::union)
.unwrap();
// 先占位父节点,递归子树会扩展 nodes;返回后按稳定下标替换父占位,不保留可失效的引用。
let index = self.nodes.len();
self.nodes.push(Node::Leaf {
bounds,
items: Vec::new(),
});
// 至多四项成为叶;更大集合沿包围盒最长轴排序后二等分,控制树深而非按三角形拆分。
if items.len() <= 4 {
self.nodes[index] = Node::Leaf {
bounds,
items: items.to_vec(),
};
} else {
let extent = bounds.max - bounds.min;
let axis = if extent.x >= extent.y && extent.x >= extent.z {
0
} else if extent.y >= extent.z {
1
} else {
2
};
items.sort_unstable_by(|&a, &b| {
self.bounds[a]
.unwrap()
.center(axis)
.total_cmp(&self.bounds[b].unwrap().center(axis))
});
// 两半均非空,即使所有中心重合也能递减集合规模,保证递归终止。
let mid = items.len() / 2;
let (left, right) = items.split_at_mut(mid);
let left = self.split(left);
let right = self.split(right);
self.nodes[index] = Node::Branch {
bounds,
left,
right,
};
}
index
}
/// 检查索引覆盖的网格数量是否与场景相符;缺失缓存时允许重建。
// 这里只校验数量,不验证顶点或 world 变换;调用方必须在几何改变后重建索引。
pub fn matches(&self, meshes: usize) -> bool {
self.bounds.len() == meshes
}
/// 用包围盒筛出射线候选;索引查询按进入距离排序,精确三角形求交在调用方完成。
pub fn ray(&self, ray: Ray) -> Vec<(f32, usize)> {
// 空树的栈为空;显式栈避免查询递归,先测分支包围盒,未命中时整棵子树跳过。
let mut stack: Vec<_> = self.root.into_iter().collect();
let mut output = Vec::new();
while let Some(index) = stack.pop() {
if self.nodes[index].bounds().ray(ray).is_none() {
continue;
}
match &self.nodes[index] {
Node::Leaf { items, .. } => {
for &item in items {
if let Some(distance) = self.bounds[item].unwrap().ray(ray) {
output.push((distance, item));
}
}
}
Node::Branch { left, right, .. } => {
stack.push(*left);
stack.push(*right);
}
}
}
// 距离是盒进入距离而非三角形命中距离;同距按场景下标排序,精确拾取仍由调用方完成。
output.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
output
}
/// 用相机六平面保守判断包围盒可见性,返回保持原场景顺序的候选。
pub fn visible(&self, camera: Camera, aspect: f32) -> Vec<usize> {
// 相机无法建立有效正交基时没有可靠视锥,返回空候选,不继续使用无效投影。
let Some(basis) = camera.basis() else {
return Vec::new();
};
let planes = super::frustum_planes(camera, aspect);
let mut stack: Vec<_> = self.root.into_iter().collect();
let mut output = Vec::new();
while let Some(index) = stack.pop() {
if !self.nodes[index].bounds().visible(camera, basis, &planes) {
continue;
}
match &self.nodes[index] {
Node::Leaf { items, .. } => output.extend(
items
.iter()
.copied()
.filter(|item| self.bounds[*item].unwrap().visible(camera, basis, &planes)),
),
Node::Branch { left, right, .. } => {
stack.push(*left);
stack.push(*right);
}
}
}
// BVH 遍历顺序与场景顺序不同;恢复原下标顺序以保持后续绘制及同深度处理稳定。
output.sort_unstable();
output
}
}
+90 -13
View File
@@ -1,3 +1,6 @@
//! 逐 Mount 的滚动状态、裁剪和输入目标选择。
//! 共享组件的不同出现保留独立偏移。嵌套滚动先选择可接收位移的内层,偏移在实际内容范围内钳制。
//!
//! Per-occurrence scroll state and a single geometry/clip projection shared by
//! native paint, source paint and input. No business object is copied.
use crate::{
@@ -12,6 +15,7 @@ use endor_core::{NativeContext, ObjectRef, Value, Vm, VmError, WeakObjectRef};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
/// 滚动容器允许响应的轴,不决定其业务内容组成方向。
pub enum Axis {
#[default]
Both,
@@ -19,6 +23,7 @@ pub enum Axis {
Horizontal,
}
impl Axis {
/// 返回内部枚举或对象对应的稳定源名称。
pub fn name(self) -> &'static str {
match self {
Self::Both => "both",
@@ -26,6 +31,7 @@ impl Axis {
Self::Horizontal => "horizontal",
}
}
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
pub fn parse(name: &str) -> Result<Self, VmError> {
match name {
"both" => Ok(Self::Both),
@@ -36,15 +42,20 @@ impl Axis {
)),
}
}
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
pub fn horizontal(self) -> bool {
self != Self::Vertical
}
/// 判断滚动轴配置是否允许该方向移动,避免同时把禁用轴纳入滚动范围。
pub fn vertical(self) -> bool {
self != Self::Horizontal
}
}
/// 求两个裁剪矩形的交集,空区域仍保持合法非负尺寸。
fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
// 输入采用 [左, 上, 宽, 高]。交集左上取较大值,右下取较小值;
// 右/下边界再钳到左/上边界,保证完全错开时输出零面积而非负尺寸。
let x = a[0].max(b[0]);
let y = a[1].max(b[1]);
[
@@ -54,7 +65,10 @@ fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
(a[1] + a[3]).min(b[1] + b[3]).max(y) - y,
]
}
/// 沿出现树累计滚动偏移和裁剪;遇到独立场景视口时按其有限边界重新建立投影范围。
pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame) -> Result<()> {
// 第一阶段只建立查找表,不改坐标:Block ID 查语义节点,Mount ID 查帧中的出现。
// 同一个 Block 可以多次出现;滚动偏移后面按 Mount 读取,不能存入这个 Block 表。
let mut nodes = HashMap::new();
let mut work = vec![root];
while let Some(node) = work.pop() {
@@ -72,21 +86,34 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
.iter()
.map(|m| matches!(nodes[m.block_id.as_str()].kind, UiKind::Scroll { .. }))
.collect();
// extent 保存尚未滚动的内容右下边界。滚动容器先扣除自身右/下 inset,
// 后续传播子内容后再加回 padding,避免把空容器的内边距算成可滚动溢出。
let mut extent: Vec<_> = frame
.mounts
.iter()
.map(|m| {
let padding = match nodes[m.block_id.as_str()].kind {
UiKind::Scroll { padding, .. } => padding,
_ => 0.0,
let node = nodes[m.block_id.as_str()];
let inset = if matches!(node.kind, UiKind::Scroll { .. }) {
layout::insets(node)
} else {
layout::Insets::default()
};
(m.x + m.width - padding, m.y + m.height - padding)
(m.x + m.width - inset.right, m.y + m.height - inset.bottom)
})
.collect();
// Mount 按父先子后排列,反向扫描才能先汇总子树,再将边界向父级传播。
for i in (0..frame.mounts.len()).rev() {
let m = &frame.mounts[i];
if let Some(parent) = m.parent {
let parent = indices[&parent];
if matches!(
nodes[frame.mounts[parent].block_id.as_str()].kind,
UiKind::CanvasViewport { .. } | UiKind::Viewport3D { .. }
) {
// 场景世界内部的坐标范围不扩大外部有限布局盒子的滚动范围。
continue;
}
// 子滚动容器向外只贡献自己的视口;它内部的溢出由自己的偏移处理。
let e = if scrolling[i] {
(m.x + m.width, m.y + m.height)
} else {
@@ -96,38 +123,56 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
extent[parent].1 = extent[parent].1.max(e.1);
}
}
// 第二阶段从父到子投影。上下文保存祖先累计位移和裁剪,而非当前节点本身的偏移。
// 重建绘制命令及其平行元数据,保证显示、命中与裁剪使用同一套投影坐标。
let mut contexts = HashMap::<u64, (f32, f32, Option<[f32; 4]>)>::new();
frame.commands.clear();
frame.command_clips.clear();
frame.command_mounts.clear();
frame.command_views.clear();
for (i, m) in frame.mounts.iter_mut().enumerate() {
let (dx, dy, clip) = m
.parent
.and_then(|id| contexts.get(&id).copied())
.unwrap_or_default();
let padding = match nodes[m.block_id.as_str()].kind {
UiKind::Scroll { padding, .. } => padding,
_ => 0.0,
};
let node = nodes[m.block_id.as_str()];
let padding = layout::insets(node);
let axis = match nodes[m.block_id.as_str()].kind {
UiKind::Scroll { axis, .. } => axis,
_ => Axis::Both,
};
// limits 在未改写的布局坐标里计算;禁用轴直接设零,内容比视口小时也为零。
let limits = [
if axis.horizontal() {
(extent[i].0 + padding - m.x - m.width).max(0.0)
(extent[i].0 + padding.right - m.x - m.width).max(0.0)
} else {
0.0
},
if axis.vertical() {
(extent[i].1 + padding - m.y - m.height).max(0.0)
(extent[i].1 + padding.bottom - m.y - m.height).max(0.0)
} else {
0.0
},
];
// 当前容器跟随祖先滚动;它自己的 scroll_offset 只影响子节点,容器边框保持原位。
m.x -= dx;
m.y -= dy;
m.clip = clip;
let mut child = (dx, dy, clip);
// 普通裁剪使用边框内侧矩形,并与祖先裁剪求交,不能扩大祖先允许的区域。
if node.clip_children {
let border = node.border;
let viewport = [
m.x + border.left.min(m.width),
m.y + border.top.min(m.height),
(m.width - border.horizontal()).max(0.0),
(m.height - border.vertical()).max(0.0),
];
child.2 = Some(
clip.map(|clip| intersection(clip, viewport))
.unwrap_or(viewport),
);
}
if scrolling[i] {
let weak = table
.active
@@ -136,11 +181,14 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
let owner = vm
.upgrade(*weak)?
.ok_or_else(|| anyhow::anyhow!("expired scroll Mount"))?;
// weak upgrade 临时保活 Mount。先保存 payload 读取结果再 release,
// 这样读取失败也会先释放强引用,然后由下方 offset? 传播错误。
let offset = vm
.payload::<Mount>(owner, "Mount")
.map(|mount| mount.scroll_offset);
vm.release(owner)?;
let offset = offset?;
// 内容缩短时旧偏移可能超界;此处只钳制帧投影,不改写托管 Mount 的原偏移。
let x = offset[0].clamp(0.0, limits[0]);
let y = offset[1].clamp(0.0, limits[1]);
m.scroll = Some([x, y, limits[0], limits[1]]);
@@ -149,13 +197,19 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
dx + x,
dy + y,
Some(
clip.map(|clip| intersection(clip, viewport))
child
.2
.map(|clip| intersection(clip, viewport))
.unwrap_or(viewport),
),
);
}
// 3D 子场景有独立投影空间,不能继续叠加外层二维滚动坐标和裁剪。
if matches!(node.kind, UiKind::Viewport3D { .. }) {
child = (0., 0., None);
}
contexts.insert(m.id, child);
if let Some(command) = layout::paint_native(
for command in layout::paint_commands(
nodes[m.block_id.as_str()],
m.x,
m.y,
@@ -166,6 +220,7 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
) {
frame.commands.push(command);
frame.command_clips.push(m.clip);
frame.command_mounts.push(m.id);
}
}
if frame.commands.len() > 10_000 {
@@ -174,12 +229,14 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
Ok(())
}
/// 把源滚动值解码为有限坐标,阻止非数或越界值进入投影。
pub fn coordinate(value: &Value) -> Result<f32, VmError> {
let Value::Number(value) = value else {
return Err(VmError::InvalidArgument(
"scroll coordinates must be numbers",
));
};
// 通过 Number 文本表示转为 f32,再检查有限性及绝对值上限;允许负数以表达相对滚动。
let n = value
.to_string()
.parse::<f32>()
@@ -189,6 +246,7 @@ pub fn coordinate(value: &Value) -> Result<f32, VmError> {
}
Ok(n)
}
/// 更新指定出现的滚动偏移,并钳制到本轴实际可滚动的内容范围。
pub fn set(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -196,6 +254,7 @@ pub fn set(
point: [f32; 2],
relative: bool,
) -> Result<(), VmError> {
// 只允许已提交且仍挂接的出现接受滚动;布局过程中的临时 Mount 不具备稳定滚动边界。
let mut mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
if !mount.mounted || mount.layout.is_some() {
return Err(VmError::InvalidArgument(
@@ -218,10 +277,12 @@ pub fn set(
let desktop = cx
.upgrade(desktop)?
.ok_or(VmError::InvalidArgument("Mount window expired"))?;
// Check the normal mutation barrier even when the clamped result is equal.
// 即使钳制后数值未变,也走正常写屏障,使只读调用不能借助无效滚动绕过变更限制。
// scrollRequested 通知后续刷新重做投影,而不是在这里直接重建布局或绘制。
cx.replace_payload(owner, mount)?;
cx.set_attr(desktop, "scrollRequested", Value::Bool(true))
}
/// 沿祖先滚动容器调整偏移,让目标出现进入可见范围。
pub fn into_view(
cx: &mut NativeContext<'_>,
owner: ObjectRef,
@@ -233,6 +294,7 @@ pub fn into_view(
"scrollIntoView requires committed attached Mount",
));
}
// rect 随祖先滚动调整,表示目标在当前投影中的可见需求;从最近父容器逐层向外处理。
let mut rect = [
mount.geometry.x,
mount.geometry.y,
@@ -246,7 +308,17 @@ pub fn into_view(
};
let mount = cx.store().payload::<Mount>(owner, "Mount")?.clone();
parent = mount.parent;
// 跨越独立场景边界时,外层只能滚动整个场景视口,不能使用内部世界坐标定位目标。
if mount.geometry.canvas_viewport || mount.geometry.scene3d_viewport {
rect = [
mount.geometry.x,
mount.geometry.y,
mount.geometry.width,
mount.geometry.height,
];
}
if let Some(offset) = mount.geometry.scroll {
// 目标起点在视口前则向前对齐;否则只补足末端溢出,已完全可见时返回零。
let delta = |start: f32, length: f32, viewport: f32, size: f32| {
if start < viewport {
start - viewport
@@ -258,6 +330,7 @@ pub fn into_view(
let dy = delta(rect[1], rect[3], mount.geometry.y, mount.geometry.height);
let x = (offset[0] + dx).clamp(0.0, offset[2]);
let y = (offset[1] + dy).clamp(0.0, offset[3]);
// 下一层祖先看到的目标位置必须扣掉本层实际移动量,不能扣未钳制的请求量。
rect[0] -= x - offset[0];
rect[1] -= y - offset[1];
if x != offset[0] || y != offset[1] {
@@ -267,14 +340,18 @@ pub fn into_view(
}
Ok(())
}
/// 按逆出现顺序选择指针处可处理当前滚动方向的最内层容器。
pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option<u64> {
let indices: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
// 逆序先取最上层命中的出现,再沿其祖先寻找能消费位移的滚动容器。
// 不搜索被覆盖的兄弟容器,内层到边界后才交给外层滚动。
let mut current = frame
.mounts
.iter()
.rev()
.find(|mount| mount.contains(point));
while let Some(mount) = current {
// 只有至少一轴钳制后真正改变偏移才接收此次输入;没有任何容器可移动则返回 None。
if mount.scroll.is_some_and(|s| {
(s[0] + delta[0]).clamp(0.0, s[2]) != s[0] || (s[1] + delta[1]).clamp(0.0, s[3]) != s[1]
}) {
+150 -9
View File
@@ -1,13 +1,69 @@
//! 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。
//! RenderScope 约束递归深度;累计像素预算限制嵌套 3D 和世界 UI 的中间表面分配。错误向上传递,不把半幅结果当成成功帧呈现。
//!
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
text::{self, TextStyle},
};
/// 显示列表的 CPU 执行入口,错误在原生 present 前向上传递。
pub struct SoftwareRenderer;
// 预算按渲染线程维护,递归 3D/面板渲染共用同一组计数;不同线程之间不共享额度。
thread_local! {
static RENDER_DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) };
static PIXEL_UNITS:std::cell::Cell<usize>=const { std::cell::Cell::new(0) };
}
/// 递归渲染的 RAII 深度保护,最外层还重置累计像素工作预算。
pub(crate) struct RenderScope;
impl RenderScope {
/// 进入一次嵌套渲染并检查深度上限;最外层调用重新开始累计像素预算。
pub fn enter() -> Result<Self, &'static str> {
RENDER_DEPTH.with(|depth| {
let current = depth.get();
// 拒绝第 19 层进入,不先增加深度;成功返回的 RenderScope 析构时才对应减一。
if current >= 18 {
return Err("render nesting budget exceeded");
}
// 只有最外层渲染开始才清累计像素数;递归进入不能借此重置预算。
if current == 0 {
PIXEL_UNITS.with(|pixels| pixels.set(0));
}
depth.set(current + 1);
Ok(Self)
})
}
}
impl Drop for RenderScope {
/// 退出一次像素渲染作用域时归还嵌套深度;像素工作量仍按本帧累计,不因退出内层而返还。
fn drop(&mut self) {
RENDER_DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
/// 累加本帧中间表面工作量,超过预算立即返回错误。
pub(crate) fn reserve_pixels(units: usize) -> Result<(), &'static str> {
PIXEL_UNITS.with(|pixels| {
// 累计申请工作量而非当前存活像素;释放一个中间表面不会返还额度,饱和加法避免溢出绕过限制。
let next = pixels.get().saturating_add(units);
if next > 16_777_216 {
return Err("nested render pixel budget exceeded");
}
pixels.set(next);
Ok(())
})
}
impl SoftwareRenderer {
pub fn render(list: &DisplayList, target: &mut [u32], width: u32, height: u32) {
/// 顺序执行显示列表并维护裁剪栈;3D 和面板可递归进入同一渲染预算域。
pub fn render(
list: &DisplayList,
target: &mut [u32],
width: u32,
height: u32,
) -> Result<(), &'static str> {
// RAII 保护让成功、提前返回和错误传播都恢复深度;target 长度须由调用方保证至少 width×height。
let _scope = RenderScope::enter()?;
let mut canvas = Canvas {
pixels: target,
width,
@@ -18,7 +74,45 @@ impl SoftwareRenderer {
for command in list.commands() {
match command {
DrawCommand::Scene3D { rect, view } => {
// 先将 3D 矩形与当前裁剪和目标尺寸相交;完全不可见时连中间表面都不生成。
let left = rect.x.max(canvas.clip.x).max(0.).floor() as u32;
let top = rect.y.max(canvas.clip.y).max(0.).floor() as u32;
let right = (rect.x + rect.width)
.min(canvas.clip.x + canvas.clip.width)
.min(width as f32)
.ceil()
.max(0.) as u32;
let bottom = (rect.y + rect.height)
.min(canvas.clip.y + canvas.clip.height)
.min(height as f32)
.ceil()
.max(0.) as u32;
if left >= right || top >= bottom || rect.width <= 0. || rect.height <= 0. {
continue;
}
// 3D 中间表面的最长边限制到约 2048 像素,再采样到请求尺寸;相机宽高比仍用原 rect,避免改变透视比例。
let lod = (2048. / rect.width.max(rect.height)).min(1.);
let w = (rect.width * lod).ceil().max(1.) as u32;
let h = (rect.height * lod).ceil().max(1.) as u32;
let surface = crate::scene3d::surface(view, w, h, rect.width / rect.height)?;
for y in top..bottom {
for x in left..right {
// 按目标像素中心反算纹理坐标并钳到有效表面边界;只复制可见区域,不循环整个放大矩形。
let u = (((x as f32 + 0.5 - rect.x) / rect.width) * w as f32)
.clamp(0., (w - 1) as f32)
as u32;
let v = (((y as f32 + 0.5 - rect.y) / rect.height) * h as f32)
.clamp(0., (h - 1) as f32)
as u32;
// 3D 表面是最终 RGB 像素,直接覆盖目标;不走二维 source alpha 混合。
canvas.pixels[y as usize * width as usize + x as usize] =
surface[v as usize * w as usize + u as usize];
}
}
}
DrawCommand::PushClip(rect) => {
// 先保存旧裁剪,再求交;嵌套 PushClip 只能收窄,矩形分离时宽高归零。
clips.push(canvas.clip);
let x = canvas.clip.x.max(rect.x);
let y = canvas.clip.y.max(rect.y);
@@ -36,6 +130,7 @@ impl SoftwareRenderer {
);
}
DrawCommand::PopClip => {
// 多余 PopClip 在当前实现中被忽略;合法列表仍应由生成层保证配对。
if let Some(clip) = clips.pop() {
canvas.clip = clip;
}
@@ -66,9 +161,11 @@ impl SoftwareRenderer {
} => canvas.draw_text(*origin, value, *style, *color),
}
}
Ok(())
}
}
/// 借用目标像素切片并维护当前裁剪;图形/文字受裁剪,Clear 明确填满整个目标。
struct Canvas<'a> {
pixels: &'a mut [u32],
width: u32,
@@ -77,12 +174,16 @@ struct Canvas<'a> {
}
impl Canvas<'_> {
/// 用不透明 RGB 填满目标切片;此命令忽略当前裁剪和输入颜色 alpha。
fn clear(&mut self, color: Color) {
// Clear 填整个提供的目标切片,不受当前 clip 约束;pack 忽略 alpha,清屏色作为不透明 RGB。
self.pixels.fill(pack(color));
}
/// 只遍历裁剪后的矩形像素,按圆角覆盖决定填充区域。
fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) {
let bounds = self.clamped_bounds(rect);
// 半径钳到非负且不超过半宽/半高;后续用像素中心判定圆角内外,不做边缘面积覆盖积分。
let radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
for y in bounds.1..bounds.3 {
for x in bounds.0..bounds.2 {
@@ -93,8 +194,10 @@ impl Canvas<'_> {
}
}
/// 用外轮廓与内轮廓的差绘制边框,避免把边框当成整个矩形填充。
fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, width: f32, color: Color) {
let bounds = self.clamped_bounds(rect);
// 描边请求先至少一单位再限制为半宽/半高;inner 从四边缩入,最终绘制外轮廓减内轮廓。
let width = width.max(1.0).min(rect.width / 2.0).min(rect.height / 2.0);
let outer_radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
let inner = Rect::new(
@@ -118,18 +221,28 @@ impl Canvas<'_> {
}
}
/// 把文字原点转换为局部裁剪区域,交给有界字形绘制,再混合到目标缓冲。
fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) {
// 将文字原点对齐整数像素;转换 clip 到字形局部空间,回调再加原点写回目标。
let x = origin.x.round() as i32;
let y = origin.y.round() as i32;
text::draw(value, style, color, |dx, dy, pixel| {
let clip = Rect::new(
self.clip.x - x as f32,
self.clip.y - y as f32,
self.clip.width,
self.clip.height,
);
text::draw_clipped(value, style, color, None, clip, |dx, dy, pixel| {
self.blend_pixel(x + dx, y + dy, pixel)
});
}
/// 把段落矩形与当前裁剪相交,按受限宽度绘制,再恢复外层裁剪。
fn draw_wrapped_text(&mut self, rect: Rect, value: &str, style: TextStyle, color: Color) {
if rect.width <= 0.0 || rect.height <= 0.0 {
return;
}
// 段落文本临时收窄 clip 到文本框;下方调用完成后恢复 old,后续命令不能继承段落私有裁剪。
let old = self.clip;
let x = old.x.max(rect.x);
let y = old.y.max(rect.y);
@@ -141,13 +254,27 @@ impl Canvas<'_> {
);
let left = rect.x.round() as i32;
let top = rect.y.round() as i32;
text::draw_wrapped(value, style, color, rect.width, |dx, dy, pixel| {
self.blend_pixel(left + dx, top + dy, pixel)
});
let clip = Rect::new(
self.clip.x - left as f32,
self.clip.y - top as f32,
self.clip.width,
self.clip.height,
);
text::draw_clipped(
value,
style,
color,
// 换行按完整段落宽度排版,而不是按被祖先裁剪后剩余的可见宽度重新排版。
Some(rect.width),
clip,
|dx, dy, pixel| self.blend_pixel(left + dx, top + dy, pixel),
);
self.clip = old;
}
/// 把连续矩形转换成目标缓冲内的整数像素范围,避免在不可见区域进行光栅循环。
fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) {
// 左/上 floor,右/下 ceil 形成保守循环范围;blend_pixel 还会按当前 clip 检查实际整数像素。
(
rect.x.max(self.clip.x).max(0.0).floor() as i32,
rect.y.max(self.clip.y).max(0.0).floor() as i32,
@@ -162,7 +289,9 @@ impl Canvas<'_> {
)
}
/// 在画布与当前裁剪范围内进行透明度混合,防止文字/图形写出目标缓冲。
fn blend_pixel(&mut self, x: i32, y: i32, source: Color) {
// 拒绝画布外、裁剪外与全透明像素;只有通过后才计算线性下标,避免负坐标转 usize。
if x < 0
|| y < 0
|| x >= self.width as i32
@@ -177,6 +306,7 @@ impl Canvas<'_> {
}
let index = y as usize * self.width as usize + x as usize;
// 全不透明像素可直接打包覆盖,省去目的通道解码与乘除法。
if source.alpha == 255 {
self.pixels[index] = pack(source);
return;
@@ -186,6 +316,7 @@ impl Canvas<'_> {
let dst_red = (destination >> 16) & 0xff;
let dst_green = (destination >> 8) & 0xff;
let dst_blue = destination & 0xff;
// 非预乘源颜色按 source*alpha + destination*(1-alpha) 合成,目标不存 alpha,逐通道整除 255。
let alpha = source.alpha as u32;
let inverse = 255 - alpha;
let red = (source.red as u32 * alpha + dst_red * inverse) / 255;
@@ -195,6 +326,7 @@ impl Canvas<'_> {
}
}
/// 测试一个像素采样点是否位于圆角矩形内,角区域使用圆弧距离。
fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
if rect.width <= 0.0 || rect.height <= 0.0 {
return false;
@@ -206,6 +338,8 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
// Clamp in local coordinates: adding a large origin to a shrinking tab's
// half-width can otherwise round the lower bound above the upper bound.
let radius = radius.min(rect.width / 2.0).min(rect.height / 2.0);
// 在局部坐标钳到圆角中心可达矩形,距离向量对应最近角圆心;中心带距离为零。
// 先减大原点可降低浮点舍入导致 clamp 上下界反转的风险。
let local_x = x - rect.x;
let local_y = y - rect.y;
let dx = local_x - local_x.clamp(radius, rect.width - radius);
@@ -213,7 +347,9 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
dx * dx + dy * dy <= radius * radius
}
/// 将颜色通道打包成目标缓冲的整数像素表示。
fn pack(color: Color) -> u32 {
// 像素格式为 0x00RRGGBB,高字节不存透明度;透明度已在混合阶段折算进 RGB。
(color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32
}
@@ -222,6 +358,7 @@ mod tests {
use super::*;
#[test]
/// 画长文字进入嵌套裁剪,逐像素检查多行与裁剪交集;PopClip后的蓝矩形验证后续命令不沿用旧clip。
fn wrapped_text_uses_multiple_lines_respects_nested_clips_and_restores_the_clip() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(255, 255, 255)));
@@ -245,7 +382,8 @@ mod tests {
color: Color::rgb(0, 0, 255),
});
let mut pixels = vec![0; 80 * 60];
SoftwareRenderer::render(&list, &mut pixels, 80, 60);
SoftwareRenderer::render(&list, &mut pixels, 80, 60).unwrap();
// 确认文字确实换成多行且每个非背景文字像素都落在嵌套裁剪交集内,蓝色矩形验证裁剪已恢复。
let mut second_line = false;
for y in 0..60 {
for x in 0..80 {
@@ -261,6 +399,7 @@ mod tests {
}
#[test]
/// 在黑底4x4缓冲画2x2红矩形,核对内部像素变红、外部仍黑,覆盖最小坐标/打包路径。
fn renderer_changes_pixels_inside_a_rectangle() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
@@ -270,12 +409,13 @@ mod tests {
color: Color::rgb(255, 0, 0),
});
let mut pixels = vec![0; 16];
SoftwareRenderer::render(&list, &mut pixels, 4, 4);
SoftwareRenderer::render(&list, &mut pixels, 4, 4).unwrap();
assert_eq!(pixels[5], 0x00ff0000);
assert_eq!(pixels[0], 0);
}
#[test]
/// 半透明白字叠黑底必须含中间抗锯齿值且通道不超128;alpha=0的文字完全不改像素。
fn text_edges_are_antialiased_and_respect_opacity() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
@@ -286,8 +426,9 @@ mod tests {
color: Color::rgba(255, 255, 255, 128),
});
let mut pixels = vec![0; 256 * 48];
SoftwareRenderer::render(&list, &mut pixels, 256, 48);
SoftwareRenderer::render(&list, &mut pixels, 256, 48).unwrap();
assert!(pixels.iter().any(|pixel| *pixel > 0));
// 白字 alpha=128 叠黑底,任何通道都不应超过 128;中间覆盖值证明字形边缘仍有抗锯齿。
assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128));
assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255))));
@@ -299,7 +440,7 @@ mod tests {
color: Color::rgba(255, 255, 255, 0),
});
let mut pixels = vec![0; 256 * 48];
SoftwareRenderer::render(&invisible, &mut pixels, 256, 48);
SoftwareRenderer::render(&invisible, &mut pixels, 256, 48).unwrap();
assert!(pixels.iter().all(|pixel| *pixel == 0));
}
}

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