Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d29a1be329 | |||
| 723e41706b | |||
| caea8c62a2 | |||
| 0b1394e407 | |||
| 1fedd21d3c | |||
| 534f6e03c1 | |||
| 4c3fc8b5dd | |||
| 741977c3fb | |||
| 32d5da5528 | |||
| 15fc01542c | |||
| ad2c01bc29 | |||
| 040b779f5f | |||
| 32d7ada3ab | |||
| 525097a0b2 | |||
| dff84f2b27 | |||
| 7007835541 | |||
| 82f9256a84 | |||
| 1e57e908d4 |
+1
-1
@@ -12,7 +12,7 @@ compiler = { path = "vendor/aster/crates/compiler" }
|
||||
cosmic-text = "0.19"
|
||||
endor_core = { package = "core", path = "vendor/aster/crates/core" }
|
||||
frontend = { path = "vendor/aster/crates/frontend" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0"
|
||||
softbuffer = "0.4.8"
|
||||
tiny_http = "0.12"
|
||||
|
||||
@@ -171,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.
|
||||
@@ -235,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.
|
||||
|
||||
@@ -249,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).
|
||||
|
||||
@@ -259,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).
|
||||
|
||||
@@ -275,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.
|
||||
@@ -607,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.
|
||||
|
||||
@@ -7,7 +7,7 @@ Tessera/
|
||||
├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果
|
||||
├── src/
|
||||
│ ├── main.rs 组合入口、事件循环和共享发布状态
|
||||
│ ├── shell/ 原生窗口、标签、开发工具、管理控件
|
||||
│ ├── shell/ 原生窗口、可收起侧栏、开发工具、管理控件
|
||||
│ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务
|
||||
│ ├── model/ 托管对象、挂载、绑定、依赖和发布数据
|
||||
│ ├── render/ 布局、绘制、命中、字体与软件光栅化
|
||||
@@ -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 |
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
|
||||
## 当前本地开发方式(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。
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Tessera 中文源码注释与阅读索引
|
||||
|
||||
本轮只为 Tessera 主项目补充注释,Aster 子模块固定版本与内容均沿用主线。注释按模块职责、类型/函数入口、关键字段、执行阶段和失败/取消路径组织;没有改变接口、算法、常量或资源预算。
|
||||
|
||||
## 建议阅读顺序
|
||||
|
||||
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 逐文件说明
|
||||
|
||||
每行说明该文件本次新增注释解释的职责和目的。生产函数/方法均增加中文入口说明;复杂状态机另外补充阶段和不变量说明。集成测试文件增加整体阅读与检查点说明,测试程序文本和源码范围断言保持原样。
|
||||
|
||||
| 文件 | 注释解释的职责与目的 |
|
||||
| --- | --- |
|
||||
| [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) | 运行时集成回归与示例验收。测试经真实 Aster 前端、编译器、VM 和展示协议执行,不是手工伪造布局结果。 |
|
||||
| [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) | 说明本地构建前提、锁定依赖、临时文件替换和 bundle 元数据,帮助理解每个打包步骤。 |
|
||||
| [scripts/render-uml.mjs](../scripts/render-uml.mjs) | 说明独立文档依赖、DOM/Canvas 适配、错误图检查与 SVG/PNG 产物生成,避免混淆运行依赖与文档工具。 |
|
||||
| [src/README.md](../src/README.md) | 增加本注释索引入口,便于从源码目录开始阅读。 |
|
||||
| [docs/code-comments.md](code-comments.md) | 本逐文件说明与阅读顺序,覆盖本轮提交的文件及共同边界。 |
|
||||
|
||||
## 核对方式
|
||||
|
||||
检查差异仅新增独立注释行和注释相关空白,并与主线去除注释后的文本对比;同时运行格式、完整 GUI 回归、Clippy 和包含私有项的 rustdoc。脚本使用语法检查,不执行文档渲染或应用打包。具体检查结果在 PR 中记录。
|
||||
@@ -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/语言依赖。
|
||||
@@ -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。
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Containers compose independently of Page and of any camera.
|
||||
shared = TextBlock("同一个对象可以有多个 Mount。每个位置分别测量、换行和对齐。", 2)
|
||||
shared.boxWidth = "fill"
|
||||
shared.marginBottom = 8
|
||||
|
||||
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"
|
||||
|
||||
right = Column(16, 12, TextBlock("共享内容,独立排版", 2), shared)
|
||||
right.boxWidth = "fill"
|
||||
right.maxWidth = 240
|
||||
right.crossAlign = "end"
|
||||
|
||||
content = Row(24, 24, left, right)
|
||||
content.boxWidth = 760
|
||||
content.crossAlign = "start"
|
||||
badge = TextBlock("Endorphin · layout")
|
||||
badge.marginRight = 12
|
||||
badge.marginBottom = 12
|
||||
badge.alignSelf = "end"
|
||||
surface = Overlay(0, 0, content, badge)
|
||||
surface.boxWidth = 800
|
||||
surface.boxHeight = 400
|
||||
surface.mainAlign = "end"
|
||||
surface.overflow = "clip"
|
||||
|
||||
page = Page()
|
||||
page.title = "嵌套盒模型"
|
||||
page.render(surface)
|
||||
@@ -0,0 +1,28 @@
|
||||
// The scene has world positions; each finite viewport has its own camera.
|
||||
count = ui.state(0)
|
||||
increment = Button("共享计数 +1")
|
||||
increment.onClick(count.increment)
|
||||
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"
|
||||
|
||||
inner = CanvasViewport(200, 100, Scene2D(RectBlock(120, 60, 8, "#D4E6D9")))
|
||||
inner.x = 260
|
||||
inner.y = 40
|
||||
world = Scene2D(card, inner)
|
||||
left = CanvasViewport(460, 320, world)
|
||||
left.camera.x = -100
|
||||
right = CanvasViewport(320, 320, world)
|
||||
right.camera.x = -80
|
||||
right.camera.zoom = 0.65
|
||||
// Set right.camera = left.camera to share a view explicitly.
|
||||
|
||||
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))
|
||||
@@ -0,0 +1,46 @@
|
||||
class SceneActions {
|
||||
view: Viewport3D
|
||||
cube: Mesh3D
|
||||
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
|
||||
}
|
||||
function rotate(self: SceneActions): Unit { self.cube.rotate(0, 15, 0) }
|
||||
function select(self: SceneActions, event: Event): Unit { event.hit.mesh.color = "#6B9B81" }
|
||||
}
|
||||
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)
|
||||
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
|
||||
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
|
||||
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 自己排版。")))
|
||||
@@ -0,0 +1,83 @@
|
||||
// Paste the complete file into Console in Script mode, then Run.
|
||||
// All numbers, progress values and activity entries below are demo data.
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
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")
|
||||
)
|
||||
)
|
||||
|
||||
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")
|
||||
))
|
||||
@@ -0,0 +1,56 @@
|
||||
// A normal Endor UI tree rendered on a transformed plane in the 3D world.
|
||||
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
|
||||
}
|
||||
function turn(self: WorldActions): Unit { self.panel.rotate(0, 15, 0) }
|
||||
function fit(self: WorldActions): Unit { self.canvas.mounts().at(0).fitView() }
|
||||
}
|
||||
count = ui.state(0)
|
||||
increment = Button("点击:世界里的真实按钮")
|
||||
increment.onClick(count.increment)
|
||||
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))
|
||||
ui_root = Column(8, 8, TextBlock("世界空间 UI", 2), ui.textOf(count, "value", 2), increment, canvas)
|
||||
ui_root.crossAlign = "stretch"
|
||||
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
|
||||
scene = Scene3D(panel, cube, floor, DirectionalLight(-1, -2, -3, "#FFFFFF", 0.9))
|
||||
scene.ambient = 0.35
|
||||
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)
|
||||
@@ -1,18 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Native macOS build and app bundle. No Docker, mirror configuration, or tests.
|
||||
set -eu
|
||||
# 此入口只能在 macOS 使用;构建授权由调用方确认,脚本不自动执行测试或部署。
|
||||
if [ "$(uname -s)" != Darwin ]; then
|
||||
echo "Build this app bundle on macOS." >&2
|
||||
exit 1
|
||||
fi
|
||||
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
# 锁定依赖并生成 release 可执行文件;附加参数原样交给 Cargo。
|
||||
cargo build --release --locked "$@"
|
||||
version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ { print $2; exit }' Cargo.toml)
|
||||
test -n "$version"
|
||||
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">
|
||||
|
||||
@@ -7,6 +7,7 @@ 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');
|
||||
const require = createRequire(path.join(path.resolve(tools), 'package.json'));
|
||||
const { createCanvas } = require('@napi-rs/canvas');
|
||||
@@ -23,6 +24,7 @@ const document = new DOMImplementation().createDocument(null, 'root', null);
|
||||
const element = document.createElement.bind(document);
|
||||
const elementNS = document.createElementNS.bind(document);
|
||||
function measurementCanvas() {
|
||||
// PlantUML 的文本布局需要真实字体宽高,统一用本机 Canvas 测量而非固定字符宽度。
|
||||
const canvas = createCanvas(1, 1);
|
||||
const context = canvas.getContext('2d');
|
||||
const measure = context.measureText.bind(context);
|
||||
@@ -41,6 +43,7 @@ function measurementCanvas() {
|
||||
}
|
||||
document.createElement = tag => tag === 'canvas' ? measurementCanvas() : element(tag);
|
||||
document.createElementNS = (namespace, tag) => {
|
||||
// 为无浏览器 DOM 补齐样式与 getBBox,满足官方渲染引擎的 SVG 文本布局需求。
|
||||
const node = elementNS(namespace, tag);
|
||||
node.style = {};
|
||||
node.getBBox = () => {
|
||||
@@ -65,6 +68,7 @@ const source = path.join(root, 'docs/architecture/uml');
|
||||
const output = path.join(source, 'rendered');
|
||||
await mkdir(output, { recursive: true });
|
||||
for (const filename of (await readdir(source)).filter(name => name.endsWith('.puml')).sort()) {
|
||||
// 每张图独立渲染并检查错误文本;错误图不能被当作正常产物覆盖已有预览。
|
||||
const text = await readFile(path.join(source, filename), 'utf8');
|
||||
const rendered = await new Promise((resolve, reject) => renderToString(text.split('\n'), resolve, reject));
|
||||
const xml = new DOMParser().parseFromString(rendered, 'image/svg+xml');
|
||||
@@ -77,6 +81,7 @@ for (const filename of (await readdir(source)).filter(name => name.endsWith('.pu
|
||||
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.
|
||||
|
||||
+7
-2
@@ -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,6 +83,7 @@
|
||||
- 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;失败和清理语义见架构文档。
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
//! 语言桥接模块入口。集中导出 Endor 类型和宿主实现之间的适配层。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
pub(crate) mod ui_bridge;
|
||||
|
||||
+218
-20
@@ -1,7 +1,12 @@
|
||||
//! Endor 与 Tessera 的展示事务协调层:维护每个窗口的 Page、Mount、布局缓存和发布版本。
|
||||
//! Session 保存托管对象的展示上下文;跨线程发布的是不可变 UiNode/Frame,而不是可以在窗口线程执行的 VM。
|
||||
//! 刷新按 prepare → layout → paint → commit 推进。挂起工作必须恢复或显式取消;失败不能把未完成的几何和生命周期状态留作已发布结果。
|
||||
//!
|
||||
//! Tessera's native visual protocols over Aster's managed object graph.
|
||||
mod bootstrap;
|
||||
mod calls;
|
||||
mod developer_pages;
|
||||
mod page_input;
|
||||
mod signatures;
|
||||
mod tree;
|
||||
|
||||
@@ -37,11 +42,31 @@ pub const TYPE_NAMES: &[&str] = &[
|
||||
"Block",
|
||||
"ContainerBlock",
|
||||
"ScrollBlock",
|
||||
"ScrollView",
|
||||
"TextBlock",
|
||||
"RectBlock",
|
||||
"Spacer",
|
||||
"Row",
|
||||
"Column",
|
||||
"Overlay",
|
||||
"Scene2D",
|
||||
"CanvasViewport",
|
||||
"Camera2D",
|
||||
"RayHit3D",
|
||||
"Panel3D",
|
||||
"CameraGesture",
|
||||
"Vec3",
|
||||
"Triangle3D",
|
||||
"Geometry3D",
|
||||
"Mesh3D",
|
||||
"Box3D",
|
||||
"Group3D",
|
||||
"Scene3D",
|
||||
"DirectionalLight",
|
||||
"PerspectiveCamera",
|
||||
"OrthographicCamera",
|
||||
"OrbitController",
|
||||
"Viewport3D",
|
||||
"Button",
|
||||
"Page",
|
||||
"Window",
|
||||
@@ -69,6 +94,7 @@ pub const QUERY_NAMES: &[&str] = &[
|
||||
pub use tree::{UiKind, UiNode};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 整个 VM 的桥接会话。节点索引跨窗口共享,presentations 按窗口隔离派生状态;current 决定方法当前访问哪份展示上下文。
|
||||
struct Session {
|
||||
// Weak visual-object registry belongs to the VM, not a particular window.
|
||||
nodes: Vec<WeakObjectRef>,
|
||||
@@ -88,6 +114,7 @@ struct Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// 收集所有窗口的私有 desktop 身份,用于排除宿主记账读取造成的无关布局失效。
|
||||
fn host_desktops(&self) -> std::collections::HashSet<ObjectRef> {
|
||||
self.presentations
|
||||
.values()
|
||||
@@ -98,6 +125,7 @@ impl Session {
|
||||
|
||||
impl std::ops::Deref for Session {
|
||||
type Target = crate::presentation::Presentation;
|
||||
/// 将包装类型的只读访问转交给内部当前上下文。
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.presentations
|
||||
.get(&self.current)
|
||||
@@ -105,6 +133,7 @@ impl std::ops::Deref for Session {
|
||||
}
|
||||
}
|
||||
impl std::ops::DerefMut for Session {
|
||||
/// 将包装类型的可变访问转交给内部当前上下文。
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
self.presentations
|
||||
.get_mut(&self.current)
|
||||
@@ -114,6 +143,7 @@ impl std::ops::DerefMut for Session {
|
||||
type Shared = Arc<Mutex<Session>>;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// ui/组件宿主入口的操作枚举;统一由调用适配器分派,不直接代表已经发生的业务变更。
|
||||
enum Operation {
|
||||
IsInstance,
|
||||
Implement,
|
||||
@@ -142,6 +172,15 @@ enum Operation {
|
||||
Scroll,
|
||||
Row,
|
||||
Column,
|
||||
Overlay,
|
||||
Scene2D,
|
||||
CanvasViewport,
|
||||
Camera2D,
|
||||
SceneObject(crate::scene3d_model::Constructor),
|
||||
Viewport3D,
|
||||
CameraControl,
|
||||
Translate3D,
|
||||
Rotate3D,
|
||||
Button,
|
||||
Page,
|
||||
SetText,
|
||||
@@ -172,6 +211,7 @@ enum Operation {
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(self) -> endor_core::CallArity {
|
||||
use Operation::*;
|
||||
let (minimum, maximum) = match self {
|
||||
@@ -181,11 +221,18 @@ impl Operation {
|
||||
Increment => (0, 0),
|
||||
TextOf => (2, 4),
|
||||
Scroll => (3, 3),
|
||||
CanvasViewport => (3, 3),
|
||||
Viewport3D => (3, 3),
|
||||
SceneObject(kind) => kind.arity(),
|
||||
CameraControl => (2, 2),
|
||||
Translate3D | Rotate3D => (3, 3),
|
||||
Camera2D => (0, 3),
|
||||
Scene2D => (0, MAX_UI_NODES),
|
||||
Text => (1, 3),
|
||||
Rect => (2, 4),
|
||||
MoveTab | MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove
|
||||
| Render | Navigate | OnClick | Present => (1, 1),
|
||||
Row | Column => (2, MAX_UI_NODES + 2),
|
||||
Row | Column | Overlay => (2, MAX_UI_NODES + 2),
|
||||
Button => (1, 1),
|
||||
Insert | Replace | Move => (2, 2),
|
||||
Page | Unmount | Close | Activate | Back | Forward | Children | NewWindow
|
||||
@@ -201,6 +248,7 @@ impl Operation {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 将一个宿主 Operation 及会话环境封装成 VM 可调用对象。
|
||||
struct Function {
|
||||
signature: Option<CallSignature>,
|
||||
operation: Operation,
|
||||
@@ -212,6 +260,7 @@ struct Function {
|
||||
/// One shared implementation on the nominal type. The VM binds receivers;
|
||||
/// individual Blocks no longer allocate owner-capturing method closures.
|
||||
#[derive(Debug)]
|
||||
/// 类型查询/能力方法的原生包装,保存调用所需的声明与会话。
|
||||
struct TypeMethod {
|
||||
signature: CallSignature,
|
||||
operation: Operation,
|
||||
@@ -219,6 +268,7 @@ struct TypeMethod {
|
||||
session: Shared,
|
||||
}
|
||||
|
||||
/// 判断方法是否仍为宿主提供的原生事件实现,区分它与用户源覆盖。
|
||||
pub(crate) fn is_intrinsic_event(
|
||||
store: &endor_core::ObjectStore,
|
||||
function: ObjectRef,
|
||||
@@ -237,9 +287,11 @@ pub(crate) fn is_intrinsic_event(
|
||||
}
|
||||
|
||||
impl NativeCallable for TypeMethod {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
let arity = self.operation.arity();
|
||||
Some(endor_core::CallArity::range(
|
||||
@@ -248,6 +300,7 @@ impl NativeCallable for TypeMethod {
|
||||
))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let owner = object(args.first())?;
|
||||
if !cx.store().is_instance(owner, self.expected)? {
|
||||
@@ -267,6 +320,7 @@ impl NativeCallable for TypeMethod {
|
||||
/// The Type dispatcher passes the requested subclass explicitly. Native
|
||||
/// constructor options precede the subclass's inherited/own field arguments.
|
||||
#[derive(Debug)]
|
||||
/// 为源代码调用原生类型构造器提供受检查的分配入口。
|
||||
struct NativeFactory {
|
||||
signature: Option<CallSignature>,
|
||||
options: Option<CallSignature>,
|
||||
@@ -275,9 +329,11 @@ struct NativeFactory {
|
||||
}
|
||||
|
||||
impl NativeCallable for NativeFactory {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
self.signature.clone()
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::range(
|
||||
self.operation.arity().required + 1,
|
||||
@@ -285,6 +341,7 @@ impl NativeCallable for NativeFactory {
|
||||
))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let requested = object(args.first())?;
|
||||
let fields = cx
|
||||
@@ -330,21 +387,25 @@ impl NativeCallable for NativeFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
|
||||
fn object(value: Option<&Value>) -> Result<ObjectRef, VmError> {
|
||||
match value {
|
||||
Some(Value::Object(value)) => Ok(*value),
|
||||
_ => Err(invalid("expected an object")),
|
||||
}
|
||||
}
|
||||
/// 把当前源值或文本选择解码成此调用需要的文本表示。
|
||||
fn text(value: Option<&Value>) -> Result<String, VmError> {
|
||||
match value {
|
||||
Some(Value::Text(value)) => Ok(value.clone()),
|
||||
_ => Err(invalid("expected text")),
|
||||
}
|
||||
}
|
||||
/// 读取整数参数,拒绝非整数或无法在目标范围表示的值。
|
||||
fn integer(value: Option<&Value>) -> Result<u64, VmError> {
|
||||
match value {
|
||||
Some(Value::Number(value)) => value
|
||||
@@ -354,6 +415,7 @@ fn integer(value: Option<&Value>) -> Result<u64, VmError> {
|
||||
_ => Err(invalid("expected a nonnegative integer")),
|
||||
}
|
||||
}
|
||||
/// 解析颜色值并检查其满足当前原生绘制约定。
|
||||
fn color(value: Option<&Value>, default: &str) -> Result<String, VmError> {
|
||||
let value = value
|
||||
.map(|v| text(Some(v)))
|
||||
@@ -362,6 +424,7 @@ fn color(value: Option<&Value>, default: &str) -> Result<String, VmError> {
|
||||
parse_color(&value).map_err(|_| invalid("color must use #RRGGBB or #RRGGBBAA"))?;
|
||||
Ok(value)
|
||||
}
|
||||
/// 读取布局尺寸参数,检查非负、有限和最大尺寸限制。
|
||||
fn dimension(value: Option<&Value>) -> Result<f32, VmError> {
|
||||
let Some(Value::Number(number)) = value else {
|
||||
return Err(invalid("expected a finite nonnegative dimension"));
|
||||
@@ -377,6 +440,7 @@ fn dimension(value: Option<&Value>) -> Result<f32, VmError> {
|
||||
Ok(value as f32)
|
||||
}
|
||||
|
||||
/// 为一个托管对象安装绑定到当前会话的原生方法,声明调用名与具体 Operation。
|
||||
fn bind(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -403,6 +467,7 @@ fn bind(
|
||||
cx.set_attr(owner, name, Value::Object(function))
|
||||
}
|
||||
|
||||
/// 使用默认原生类型创建 Block;需要源子类身份的构造走 new_typed_block。
|
||||
fn new_block(
|
||||
cx: &mut NativeContext<'_>,
|
||||
data: Data,
|
||||
@@ -411,6 +476,33 @@ fn new_block(
|
||||
new_typed_block(cx, data, session, None)
|
||||
}
|
||||
|
||||
/// 分配有名义类型和默认位置/缩放的独立相机,供新视口拥有自己的导航状态。
|
||||
fn new_camera2d(
|
||||
cx: &mut NativeContext<'_>,
|
||||
session: &Shared,
|
||||
requested: Option<ObjectRef>,
|
||||
args: &[Value],
|
||||
) -> Result<ObjectRef, VmError> {
|
||||
let desktop = session
|
||||
.lock()
|
||||
.map_err(|_| invalid("UI session poisoned"))?
|
||||
.desktop;
|
||||
let definition = requested.unwrap_or(desktop_type(cx.store(), "Camera2D", desktop)?);
|
||||
let camera = cx.allocate(crate::viewport::Camera2D)?;
|
||||
cx.attach_type(camera, definition)?;
|
||||
for (index, name, fallback) in [(0, "x", 0.0), (1, "y", 0.0), (2, "zoom", 1.0)] {
|
||||
let value = args
|
||||
.get(index)
|
||||
.map(crate::scroll::coordinate)
|
||||
.transpose()?
|
||||
.unwrap_or(fallback);
|
||||
cx.set_attr(camera, name, crate::native_visual::value(value))?;
|
||||
}
|
||||
crate::viewport::camera(cx.store(), camera)?;
|
||||
Ok(camera)
|
||||
}
|
||||
|
||||
/// 创建原生 Block 并附加请求的 Endor 类型;先验证继承关系,再登记到会话节点索引。
|
||||
fn new_typed_block(
|
||||
cx: &mut NativeContext<'_>,
|
||||
data: Data,
|
||||
@@ -424,6 +516,10 @@ fn new_typed_block(
|
||||
Kind::Scroll { .. } => "ScrollBlock",
|
||||
Kind::Row => "Row",
|
||||
Kind::Column => "Column",
|
||||
Kind::Overlay => "Overlay",
|
||||
Kind::Scene2D => "Scene2D",
|
||||
Kind::CanvasViewport { .. } => "CanvasViewport",
|
||||
Kind::Viewport3D { .. } => "Viewport3D",
|
||||
Kind::Button => "Button",
|
||||
Kind::Page => "Page",
|
||||
};
|
||||
@@ -462,6 +558,7 @@ fn new_typed_block(
|
||||
Ok(block)
|
||||
}
|
||||
|
||||
/// 在当前 VM 中建立窗口、desktop 和独立 Presentation,实际 OS 窗口等命令发布后再创建。
|
||||
fn create_window(
|
||||
cx: &mut NativeContext<'_>,
|
||||
state: &mut Session,
|
||||
@@ -491,6 +588,7 @@ fn create_window(
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// 从页面登记信息取得所属窗口,保持 Page 的归属与原生资源身份一致。
|
||||
fn page_window(
|
||||
store: &endor_core::ObjectStore,
|
||||
state: &Session,
|
||||
@@ -504,6 +602,7 @@ fn page_window(
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
/// 撤销页面在展示会话中的登记,配合模型层处理活动页面与生命周期。
|
||||
fn remove_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
view: &mut crate::presentation::Presentation,
|
||||
@@ -522,6 +621,7 @@ fn remove_page(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 在指定窗口安排页面展示,并维护目标 Presentation 的活动/待发布状态。
|
||||
fn show_in(
|
||||
cx: &mut NativeContext<'_>,
|
||||
page: ObjectRef,
|
||||
@@ -572,20 +672,24 @@ fn show_in(
|
||||
view.pending = Some(Some(page));
|
||||
Ok(())
|
||||
}
|
||||
/// 在当前窗口安排页面展示,实际几何和画面由后续刷新提交。
|
||||
fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> {
|
||||
show_in(cx, page, session, None)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 接收源 present 结果并将其转换成宿主可展示内容的原生续接步骤。
|
||||
struct PresentResult {
|
||||
open: bool,
|
||||
session: Shared,
|
||||
}
|
||||
impl NativeCallable for PresentResult {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(root)] = args else {
|
||||
return Err(invalid("Presentable.present must return a Block"));
|
||||
@@ -610,6 +714,7 @@ impl NativeCallable for PresentResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 Presentable 能力把任意源值转换成可展示内容,避免只允许某个原生 Block 类型。
|
||||
fn present_value(
|
||||
cx: &mut NativeContext<'_>,
|
||||
value: &Value,
|
||||
@@ -652,12 +757,15 @@ fn present_value(
|
||||
}
|
||||
|
||||
#[must_use = "pending layout must be resumed on its owning VM"]
|
||||
/// 桥接布局的挂起/完成结果;PendingLayout 必须与其窗口上下文一起继续处理。
|
||||
pub enum LayoutProgress {
|
||||
Pending(Box<PendingLayout>),
|
||||
Complete(Box<crate::document::Frame>),
|
||||
}
|
||||
|
||||
/// 一次未提交布局的上下文:目标窗口、根、布局/缓存、延后钩子以及用于失败恢复的检查点。
|
||||
struct LayoutContext {
|
||||
surface: crate::page::Surface,
|
||||
root: Arc<UiNode>,
|
||||
tabs: Vec<crate::tabs::Tab>,
|
||||
full_viewport: Option<crate::layout::Size>,
|
||||
@@ -667,17 +775,20 @@ struct LayoutContext {
|
||||
checkpoint: crate::mount::Checkpoint,
|
||||
}
|
||||
|
||||
/// 挂起布局的拥有者。续体与上下文必须一起保留并最终恢复或取消。
|
||||
pub struct PendingLayout {
|
||||
work: crate::source_layout::Work,
|
||||
context: LayoutContext,
|
||||
}
|
||||
|
||||
/// 运行时持有的桥接能力句柄,通过共享 Session 协调托管对象与每窗口展示。
|
||||
pub struct UiBridge {
|
||||
pub ui: ObjectRef,
|
||||
session: Shared,
|
||||
tasks: crate::tasks::Scheduler,
|
||||
}
|
||||
|
||||
/// 解析对象的方法并在同一 VM 中调用,使源覆盖与原生成员共用方法查找规则。
|
||||
fn invoke_member(
|
||||
vm: &mut Vm,
|
||||
owner: ObjectRef,
|
||||
@@ -703,6 +814,7 @@ fn invoke_member(
|
||||
result
|
||||
}
|
||||
|
||||
/// 将托管页面注册表投影成稳定标签 ID、标题与活动状态,不把托管引用交给原生侧栏。
|
||||
fn project_tabs(
|
||||
vm: &Vm,
|
||||
state: &mut crate::presentation::Presentation,
|
||||
@@ -749,6 +861,7 @@ fn project_tabs(
|
||||
impl UiBridge {
|
||||
/// Detach all occurrences without running layout/paint. All windows are
|
||||
/// already closed before source unmount hooks run, so they cannot remount.
|
||||
/// 按窗口和展示生命周期执行有界退出清理,允许调用方根据故障状态决定是否执行回调。
|
||||
pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> {
|
||||
let tasks = self.tasks.quiesce(vm)?;
|
||||
let mut drivers = Vec::new();
|
||||
@@ -818,16 +931,20 @@ impl UiBridge {
|
||||
result
|
||||
}
|
||||
|
||||
/// 查询任务系统是否仍有可推进的协作工作,供 worker 选择等待策略。
|
||||
pub fn has_cooperative_tasks(&self) -> bool {
|
||||
self.tasks.has_cooperative_work()
|
||||
}
|
||||
|
||||
/// 把当前 VM 交给任务调度器推进,限制一轮交付数量。
|
||||
pub fn poll_tasks(&self, vm: &mut Vm, limit: usize) -> Result<usize> {
|
||||
self.tasks.poll(vm, limit)
|
||||
}
|
||||
/// 取消当前域中的任务,撤销关闭/失败后不应再发生的交付。
|
||||
pub fn cancel_tasks(&self, vm: &mut Vm) -> Result<usize> {
|
||||
self.tasks.cancel_all(vm)
|
||||
}
|
||||
/// 返回当前仍有效的展示窗口身份,用于稳定遍历一次全窗口刷新。
|
||||
pub fn window_ids(&self) -> Result<Vec<u64>> {
|
||||
let state = self
|
||||
.session
|
||||
@@ -850,6 +967,7 @@ impl UiBridge {
|
||||
});
|
||||
Ok(ids)
|
||||
}
|
||||
/// 判断窗口是否仍登记且没有进入关闭状态。
|
||||
pub fn accepts_window(&self, id: u64) -> bool {
|
||||
self.session.lock().is_ok_and(|state| {
|
||||
state
|
||||
@@ -859,6 +977,7 @@ impl UiBridge {
|
||||
})
|
||||
}
|
||||
|
||||
/// 切换当前 Presentation 上下文;不会创建新窗口或复制 VM。
|
||||
pub fn select_window(&self, id: u64) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -870,6 +989,7 @@ impl UiBridge {
|
||||
state.current = id;
|
||||
Ok(())
|
||||
}
|
||||
/// 从托管展示域撤销窗口,退役 Mount 并清理该窗口的私有对象关系。
|
||||
pub fn retire_window(&self, vm: &mut Vm, id: u64) -> Result<()> {
|
||||
if id == crate::window::MAIN_WINDOW {
|
||||
bail!("the primary shell is closed by the OS window control");
|
||||
@@ -897,6 +1017,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 收尾已经关闭的展示资源,避免它们参与下一次窗口遍历。
|
||||
pub fn finish_closed(&self, vm: &mut Vm) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -921,6 +1042,7 @@ impl UiBridge {
|
||||
state.current = crate::window::MAIN_WINDOW;
|
||||
Ok(())
|
||||
}
|
||||
/// 从各托管窗口收集待发布命令;原生执行时还需校验 session。
|
||||
pub fn take_window_commands(&self, vm: &Vm) -> Result<Vec<crate::window::Command>> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -935,6 +1057,7 @@ impl UiBridge {
|
||||
Ok(commands)
|
||||
}
|
||||
|
||||
/// 校验并更新所属窗口的逻辑尺寸输入;尺寸不变时不制造新布局,改变时标记需要刷新。
|
||||
pub fn set_viewport(&self, vm: &mut Vm, viewport: Option<crate::layout::Size>) -> Result<bool> {
|
||||
if let Some(size) = viewport
|
||||
&& (!size.width.is_finite()
|
||||
@@ -970,6 +1093,7 @@ impl UiBridge {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 将 ui 入口与兼容名称安装到本次程序的全局字典,保持所有调用处在同一个 VM 域。
|
||||
pub fn install_globals(&self, vm: &mut Vm, globals: ObjectRef) -> Result<()> {
|
||||
vm.dict_set(globals, Value::Text("ui".into()), Value::Object(self.ui))?;
|
||||
// Compatibility spelling used by the original module examples.
|
||||
@@ -984,6 +1108,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 检查当前会话是否已经安排或拥有可展示页面。
|
||||
pub fn has_page(&self) -> Result<bool> {
|
||||
Ok(self
|
||||
.session
|
||||
@@ -993,6 +1118,7 @@ impl UiBridge {
|
||||
.values()
|
||||
.any(|view| view.pending.flatten().is_some() || view.active.is_some()))
|
||||
}
|
||||
/// 验证标签拖放仍指向有效页面和窗口关系,避免旧拖动操作跨域生效。
|
||||
pub fn accepts_tab_drop(&self, vm: &Vm, input: &crate::events::Input) -> bool {
|
||||
let Some(target) = &input.tab_drop else {
|
||||
return true;
|
||||
@@ -1030,12 +1156,14 @@ impl UiBridge {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// 通过展示协议打开源值,必要时创建或选择承载它的页面。
|
||||
pub fn open_value(&self, vm: &mut Vm, value: &Value) -> Result<()> {
|
||||
let function = object(Some(&vm.get_attr(self.ui, "open")?))?;
|
||||
let page = vm.call(function, std::slice::from_ref(value))?;
|
||||
vm.release_value(page)?;
|
||||
Ok(())
|
||||
}
|
||||
/// 处理页面激活、关闭、移动和拆分等标签操作,保持窗口所有权与展示状态一致。
|
||||
fn dispatch_tab(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> {
|
||||
let page = {
|
||||
let state = self
|
||||
@@ -1147,6 +1275,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在所属窗口的展示上下文中处理输入,区分页面管理、滚动、焦点、相机与普通事件。
|
||||
pub fn dispatch(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> {
|
||||
input
|
||||
.validate()
|
||||
@@ -1189,13 +1318,25 @@ impl UiBridge {
|
||||
vm.release_value(page)?;
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(input.kind.as_str(), "page-pan" | "page-zoom") {
|
||||
return self.dispatch_page_view(vm, input);
|
||||
}
|
||||
if matches!(input.kind.as_str(), "viewport-pan" | "viewport-zoom") {
|
||||
return self.dispatch_viewport_view(vm, input);
|
||||
}
|
||||
if matches!(
|
||||
input.kind.as_str(),
|
||||
"scene-pan" | "scene-orbit" | "scene-zoom"
|
||||
) {
|
||||
return self.dispatch_scene_view(vm, input);
|
||||
}
|
||||
if matches!(
|
||||
input.kind.as_str(),
|
||||
"tab-activate" | "tab-close" | "tab-detach" | "tab-drop"
|
||||
) {
|
||||
return self.dispatch_tab(vm, input);
|
||||
}
|
||||
let (weak, desktop, candidates, viewport) = {
|
||||
let (weak, desktop, candidates, viewport, surface) = {
|
||||
let state = self
|
||||
.session
|
||||
.lock()
|
||||
@@ -1211,13 +1352,8 @@ impl UiBridge {
|
||||
.ok_or_else(|| anyhow::anyhow!("Mount is no longer active"))?,
|
||||
state.desktop,
|
||||
state.mounts.ordered(),
|
||||
state.layout.viewport.map(|size| crate::layout::Size {
|
||||
width: (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0),
|
||||
height: (size.height
|
||||
- crate::tabs::INSET
|
||||
- crate::tabs::height(&state.last_tabs))
|
||||
.max(0.0),
|
||||
}),
|
||||
state.layout.viewport,
|
||||
state.surface.clone(),
|
||||
)
|
||||
};
|
||||
if input.kind == "scroll" {
|
||||
@@ -1231,8 +1367,7 @@ impl UiBridge {
|
||||
.ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?;
|
||||
if !data.mounted
|
||||
|| data.version != input.layout_version
|
||||
|| viewport
|
||||
.is_some_and(|size| point[0] >= size.width || point[1] >= size.height)
|
||||
|| !surface.contains(point, viewport)
|
||||
|| !data
|
||||
.geometry
|
||||
.contains(crate::geometry::Point::new(point[0], point[1]))
|
||||
@@ -1242,6 +1377,9 @@ impl UiBridge {
|
||||
let delta = input
|
||||
.delta
|
||||
.ok_or_else(|| anyhow::anyhow!("scroll has no delta"))?;
|
||||
let Some(delta) = data.geometry.logical_delta(input.point, delta) else {
|
||||
return Ok(());
|
||||
};
|
||||
let method = vm.bind_attr(mount, "scrollBy")?;
|
||||
let result = match method {
|
||||
Value::Object(function) => vm.call_with_limits(
|
||||
@@ -1265,11 +1403,10 @@ impl UiBridge {
|
||||
return result;
|
||||
}
|
||||
let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) {
|
||||
if viewport.is_some_and(|size| {
|
||||
[press, release]
|
||||
.iter()
|
||||
.any(|point| point[0] >= size.width || point[1] >= size.height)
|
||||
}) {
|
||||
if [press, release]
|
||||
.iter()
|
||||
.any(|point| !surface.contains(*point, viewport))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)?
|
||||
@@ -1283,6 +1420,31 @@ impl UiBridge {
|
||||
};
|
||||
let result = (|| {
|
||||
let data = vm.payload::<crate::mount::Mount>(mount, "Mount")?.clone();
|
||||
if data.geometry.scene3d_viewport
|
||||
&& input.kind == "click"
|
||||
&& let Some(point) = input.point
|
||||
{
|
||||
let function = vm.allocate(ContextNativeFunction::new(
|
||||
"3D pointer click",
|
||||
crate::scene3d_model::PointerPick {
|
||||
mount,
|
||||
desktop,
|
||||
point: crate::geometry::Point::new(point[0], point[1]),
|
||||
},
|
||||
vec![mount, desktop],
|
||||
))?;
|
||||
let result = vm.call_with_limits(
|
||||
function,
|
||||
&[],
|
||||
endor_core::ExecutionLimits {
|
||||
max_frames: 128,
|
||||
max_instructions: 1_000_000,
|
||||
},
|
||||
);
|
||||
vm.release(function)?;
|
||||
vm.release_value(result?)?;
|
||||
return Ok(());
|
||||
}
|
||||
if !data.mounted {
|
||||
bail!("event targets an obsolete presentation");
|
||||
}
|
||||
@@ -1319,6 +1481,7 @@ impl UiBridge {
|
||||
vm.release(mount)?;
|
||||
result
|
||||
}
|
||||
/// 整理不再有效的弱索引与展示根,并配合 VM 回收,避免派生缓存延长业务对象生命周期。
|
||||
pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec<ObjectRef>) -> Result<()> {
|
||||
vm.collect_cycles();
|
||||
let mut state = self
|
||||
@@ -1338,6 +1501,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 丢弃未成功发布的展示请求,恢复后续输入可以使用的会话状态。
|
||||
pub fn discard_pending(&self, vm: &mut Vm) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -1388,6 +1552,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 重新评估已失效的属性绑定,并把结果计入会话统计。
|
||||
pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> {
|
||||
let (nodes, desktops) = {
|
||||
let state = self
|
||||
@@ -1404,6 +1569,7 @@ impl UiBridge {
|
||||
state.binding_evaluations = state.binding_evaluations.saturating_add(evaluated);
|
||||
Ok(())
|
||||
}
|
||||
/// 排空一次 VM 变化记录并通知各窗口,提取活动页面快照,判断本次是否需要布局与发布。
|
||||
pub fn prepare_frame(&self, vm: &mut Vm) -> Result<Option<Option<Arc<UiNode>>>> {
|
||||
let changes = vm.take_changes();
|
||||
let mut state = self
|
||||
@@ -1448,6 +1614,7 @@ impl UiBridge {
|
||||
}
|
||||
Ok(changed.then_some(root))
|
||||
}
|
||||
/// 提交已经完成的出现几何与快照,并处理展示生命周期;不把挂起或失败的布局当成已发布帧。
|
||||
pub fn commit_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1477,6 +1644,7 @@ impl UiBridge {
|
||||
state.force_frame = false;
|
||||
state.last_snapshot = snapshot;
|
||||
state.last_tabs = frame.tabs.clone();
|
||||
state.surface = frame.surface.clone();
|
||||
state.published_version = frame.layout_version;
|
||||
// Hooks may call Page/container APIs; never hold the session mutex
|
||||
// across user code. Publish only after the refresh loop settles.
|
||||
@@ -1502,6 +1670,7 @@ impl UiBridge {
|
||||
result
|
||||
}
|
||||
|
||||
/// 在已完成的几何上启动绘制协议,保留绘制任务所需的宿主上下文。
|
||||
pub fn begin_paint_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1532,6 +1701,7 @@ impl UiBridge {
|
||||
self.accept_paint_progress(progress)
|
||||
}
|
||||
|
||||
/// 恢复同一次源绘制调用,把完成的 Draft 交回发布流程。
|
||||
pub fn resume_paint_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1541,6 +1711,7 @@ impl UiBridge {
|
||||
self.accept_paint_progress(progress)
|
||||
}
|
||||
|
||||
/// 区分挂起与完成的绘制进度;完成时保存真实读取依赖并接回发布流程。
|
||||
fn accept_paint_progress(
|
||||
&self,
|
||||
progress: crate::source_paint::Progress,
|
||||
@@ -1565,6 +1736,7 @@ impl UiBridge {
|
||||
Ok(progress)
|
||||
}
|
||||
|
||||
/// 取消尚未完成的源绘制,释放其续体和未发布上下文。
|
||||
pub fn cancel_paint_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1583,6 +1755,7 @@ impl UiBridge {
|
||||
invalidated
|
||||
}
|
||||
|
||||
/// 为目标快照建立 Mount、检查点和布局上下文,再启动可挂起的只读布局求解。
|
||||
pub fn begin_layout_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1649,10 +1822,8 @@ impl UiBridge {
|
||||
project_tabs(vm, &mut state)?
|
||||
};
|
||||
let full_viewport = layout.viewport;
|
||||
if let Some(size) = &mut layout.viewport {
|
||||
size.width = (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0);
|
||||
size.height = (size.height - crate::tabs::height(&tabs) - crate::tabs::INSET).max(0.0);
|
||||
}
|
||||
let surface = root.surface.clone().unwrap_or_default();
|
||||
layout.page_mode = root.surface.as_ref().map(|surface| surface.mode);
|
||||
{
|
||||
let state = self
|
||||
.session
|
||||
@@ -1688,6 +1859,7 @@ impl UiBridge {
|
||||
(hooks, state.mounts.ordered(), checkpoint)
|
||||
};
|
||||
let context = LayoutContext {
|
||||
surface,
|
||||
root: root.clone(),
|
||||
tabs,
|
||||
full_viewport,
|
||||
@@ -1706,6 +1878,7 @@ impl UiBridge {
|
||||
self.accept_layout_progress(vm, context, progress)
|
||||
}
|
||||
|
||||
/// 记录本次刷新主动让出 worker 的次数,用于诊断分片执行。
|
||||
pub fn note_refresh_yield(&self) -> Result<()> {
|
||||
let mut state = self
|
||||
.session
|
||||
@@ -1715,6 +1888,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录视口目标替换导致的刷新重启,便于区分正常挂起与取消重试。
|
||||
pub fn note_refresh_restart(
|
||||
&self,
|
||||
cancelled_layout: bool,
|
||||
@@ -1734,8 +1908,10 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 撤销未完成布局所保留的窗口、挂载与生命周期上下文。
|
||||
fn discard_layout_context(&self, vm: &mut Vm, context: LayoutContext) -> Result<()> {
|
||||
let LayoutContext {
|
||||
surface: _,
|
||||
desktop,
|
||||
window,
|
||||
hooks,
|
||||
@@ -1765,6 +1941,7 @@ impl UiBridge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 取消源布局续体并清理对应上下文,保持已发布帧可用。
|
||||
pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> {
|
||||
let PendingLayout { work, context } = pending;
|
||||
let cancelled = work.cancel(vm);
|
||||
@@ -1773,10 +1950,12 @@ impl UiBridge {
|
||||
restored
|
||||
}
|
||||
|
||||
/// 关闭失败布局的挂起工作并恢复检查点;供错误或候选撤销路径使用。
|
||||
pub fn abort_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> {
|
||||
self.cancel_layout_frame(vm, pending)
|
||||
}
|
||||
|
||||
/// 继续同一次尚未发布的布局任务;完成时沿原上下文收尾,不能新建一轮来重放副作用。
|
||||
pub fn resume_layout_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1794,6 +1973,7 @@ impl UiBridge {
|
||||
self.accept_layout_progress(vm, context, progress)
|
||||
}
|
||||
|
||||
/// 接收布局的 Pending/Complete 结果;保留上下文或执行最终投影和提交。
|
||||
fn accept_layout_progress(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1817,6 +1997,7 @@ impl UiBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 依次投影滚动/视口、补页面元数据、保存读取版本、提交 Mount,并处理延后的生命周期调用。
|
||||
fn finish_layout_frame(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
@@ -1824,6 +2005,7 @@ impl UiBridge {
|
||||
draft: crate::source_layout::Draft,
|
||||
) -> Result<crate::document::Frame> {
|
||||
let LayoutContext {
|
||||
mut surface,
|
||||
root,
|
||||
tabs,
|
||||
full_viewport,
|
||||
@@ -1846,11 +2028,22 @@ impl UiBridge {
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
||||
crate::scroll::project(vm, &state.mounts, &root, &mut frame)?;
|
||||
crate::viewport::project(&root, &mut frame)?;
|
||||
}
|
||||
// Native layout may replace the structural Frame; attach chrome only
|
||||
// after either layout path has produced the final page geometry.
|
||||
frame.tabs = tabs;
|
||||
frame.window = window;
|
||||
if let Some(root) = frame.mounts.first() {
|
||||
surface.extent = crate::layout::Size {
|
||||
width: root.width,
|
||||
height: root.height,
|
||||
};
|
||||
}
|
||||
if let Some(viewport) = full_viewport {
|
||||
surface.clamp(viewport);
|
||||
}
|
||||
frame.surface = surface;
|
||||
// Capture versions before lifecycle hooks can change a dependency.
|
||||
let desktops = self
|
||||
.session
|
||||
@@ -1905,6 +2098,7 @@ impl UiBridge {
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// 读取当前源程序已经选择的展示内容,为编译入口生成最终文档提供根。
|
||||
pub fn rendered(&self, vm: &Vm) -> Result<Option<Arc<UiNode>>> {
|
||||
let state = self
|
||||
.session
|
||||
@@ -1919,6 +2113,7 @@ impl UiBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从会话的类型注册表取得指定类型,再给原生分配的对象附加托管类型身份。
|
||||
fn attach_named_type(
|
||||
cx: &mut NativeContext<'_>,
|
||||
object: ObjectRef,
|
||||
@@ -1932,6 +2127,7 @@ fn attach_named_type(
|
||||
attach_desktop_type(cx, object, name, desktop)
|
||||
}
|
||||
|
||||
/// 按给定 desktop 查找类型并附加,支持不同窗口使用自己的展示上下文。
|
||||
pub fn attach_desktop_type(
|
||||
cx: &mut NativeContext<'_>,
|
||||
instance: ObjectRef,
|
||||
@@ -1942,6 +2138,7 @@ pub fn attach_desktop_type(
|
||||
cx.attach_type(instance, definition)
|
||||
}
|
||||
|
||||
/// 读取 desktop 的托管类型注册表;缺失或类型错误时返回 VM 错误。
|
||||
pub(crate) fn desktop_type(
|
||||
store: &endor_core::ObjectStore,
|
||||
name: &str,
|
||||
@@ -1951,6 +2148,7 @@ pub(crate) fn desktop_type(
|
||||
object(store.dict_get(types, &Value::Text(name.into()))?.as_ref())
|
||||
}
|
||||
|
||||
/// 将当前托管或可变状态提取为后续阶段使用的派生表示。
|
||||
pub fn snapshot(vm: &Vm, value: &Value) -> Result<Arc<UiNode>> {
|
||||
let Value::Object(root) = value else {
|
||||
bail!("main must return a Block");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! 安装一次 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> {
|
||||
let desktop = vm.new_object()?;
|
||||
roots.push(desktop);
|
||||
@@ -43,6 +47,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),
|
||||
@@ -84,8 +92,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")),
|
||||
@@ -130,10 +157,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,
|
||||
@@ -172,6 +206,7 @@ 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()
|
||||
@@ -232,11 +267,20 @@ 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)?;
|
||||
@@ -335,6 +379,15 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
}
|
||||
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),
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
//! 把 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()
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(self.operation.arity())
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
use Operation::*;
|
||||
let arity = self.operation.arity();
|
||||
@@ -16,6 +22,42 @@ impl NativeCallable for Function {
|
||||
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)));
|
||||
}
|
||||
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));
|
||||
}
|
||||
if matches!(self.operation, Implement | Implements | Supports) {
|
||||
let capability = object(args.get(1))?;
|
||||
let value = match self.operation {
|
||||
@@ -346,9 +388,11 @@ 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
|
||||
},
|
||||
@@ -360,6 +404,62 @@ 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,
|
||||
);
|
||||
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)?;
|
||||
data.controller = Some(crate::scene3d_model::allocate(
|
||||
cx,
|
||||
desktop,
|
||||
definition,
|
||||
crate::scene3d_model::Constructor::Orbit,
|
||||
&[],
|
||||
)?);
|
||||
Some(data)
|
||||
}
|
||||
Button => {
|
||||
let label = new_block(
|
||||
cx,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
//! 把内置开发工具和起始页接入托管 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 {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
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"));
|
||||
@@ -63,6 +68,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",
|
||||
@@ -79,6 +85,7 @@ 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<()> {
|
||||
let source = frontend::EndorReader::from_text(
|
||||
"tessera:start-page.endor",
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! 把页面、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<()> {
|
||||
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 = (|| {
|
||||
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"))?;
|
||||
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"))?;
|
||||
let local_delta = if input.kind == "scene-zoom" {
|
||||
[0., 0.]
|
||||
} else {
|
||||
let Some(delta) = geometry.logical_delta(input.point, delta) else {
|
||||
return Ok(());
|
||||
};
|
||||
delta
|
||||
};
|
||||
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,
|
||||
));
|
||||
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[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))?;
|
||||
}
|
||||
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
|
||||
})();
|
||||
vm.release(mount)?;
|
||||
result
|
||||
}
|
||||
|
||||
/// 把具体 CanvasViewport 的位移或缩放锚点映射到其显式 Camera2D。
|
||||
pub(super) fn dispatch_viewport_view(
|
||||
&self,
|
||||
vm: &mut Vm,
|
||||
input: &crate::events::Input,
|
||||
) -> Result<()> {
|
||||
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);
|
||||
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 = (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(());
|
||||
};
|
||||
x += delta[0] / zoom;
|
||||
y += delta[1] / zoom;
|
||||
}
|
||||
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<()> {
|
||||
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"
|
||||
);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -11,6 +15,12 @@ pub(super) fn method_signature(
|
||||
use Operation::*;
|
||||
let nominal = |name| desktop_type(store, name, desktop).map(Nominal);
|
||||
let (arguments, returns, effect) = match operation {
|
||||
Translate3D | Rotate3D => (vec![Number, Number, Number], Unit, CallEffect::Mutating),
|
||||
CameraControl => (
|
||||
vec![nominal("Object")?, nominal("CameraGesture")?],
|
||||
Unit,
|
||||
CallEffect::Mutating,
|
||||
),
|
||||
Bind => (
|
||||
vec![TextType, Function],
|
||||
nominal("Subscription")?,
|
||||
@@ -80,6 +90,7 @@ pub(super) fn method_signature(
|
||||
Ok(signature)
|
||||
}
|
||||
|
||||
/// 将具体宿主 Operation 映射到参数/返回类型契约。
|
||||
pub(super) fn operation_signature(
|
||||
store: &endor_core::ObjectStore,
|
||||
desktop: ObjectRef,
|
||||
@@ -133,11 +144,13 @@ pub(super) fn operation_signature(
|
||||
nominal("ScrollBlock")?,
|
||||
CallEffect::ReadOnly,
|
||||
),
|
||||
Row | Column => (
|
||||
Row | Column | Overlay => (
|
||||
vec![Number, Number],
|
||||
Some(nominal("Block")?),
|
||||
nominal(if matches!(operation, Row) {
|
||||
"Row"
|
||||
} else if matches!(operation, Overlay) {
|
||||
"Overlay"
|
||||
} else {
|
||||
"Column"
|
||||
})?,
|
||||
@@ -150,6 +163,51 @@ 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,
|
||||
),
|
||||
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,
|
||||
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")),
|
||||
};
|
||||
Ok(CallSignature {
|
||||
|
||||
@@ -1,80 +1,129 @@
|
||||
// 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 {
|
||||
tint: String
|
||||
function measure(self: Note, mount: Mount, constraints: Constraints): MeasureResult {
|
||||
measured = Column.measure(self, mount, constraints)
|
||||
return MeasureResult(224, measured.height)
|
||||
}
|
||||
function paint(self: Note, mount: Mount, geometry: Bounds): DisplayList {
|
||||
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
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
class StartActions {
|
||||
// 保存起始页面身份,子页面中的返回按钮激活同一 Page,而不是复制一份页面。
|
||||
home: Page
|
||||
// 窗口页面是普通有限 Column;可用区域来自宿主发布的逻辑视口。
|
||||
function blank(self: StartActions, event: Event): Unit {
|
||||
page = Page()
|
||||
page.title = "未命名页面"
|
||||
back = Button("返回开始页")
|
||||
page.title = "窗口页面"
|
||||
back = Button("返回画布")
|
||||
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"))
|
||||
view = ScrollBlock(1024, 800, content)
|
||||
view.axis = "vertical"
|
||||
view.flex = 1
|
||||
page.crossAlign = "stretch"
|
||||
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)
|
||||
view.axis = "horizontal"
|
||||
view.flex = 1
|
||||
page.crossAlign = "stretch"
|
||||
page.render(view)
|
||||
}
|
||||
// 重新调用工厂得到独立页面与相机,已有画布继续保留自己的视角。
|
||||
function canvas(self: StartActions, event: Event): Unit {
|
||||
page = newTab()
|
||||
}
|
||||
// 两个文字绑定观察同一个状态对象,示范共享业务状态与多个展示位置的区别。
|
||||
function counter(self: StartActions, event: Event): Unit {
|
||||
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 = "自由画布"
|
||||
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("试试:一个对象,两个视图 ↗")
|
||||
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
|
||||
world = Scene2D(hero)
|
||||
left = Note(24, 14, TextBlock("随手记下", 2), RectBlock(176, 100, 10, "#D5E4DA"), TextBlock("想法不必排成一条直线。"), "#EDF3E9")
|
||||
left.x = 36
|
||||
left.y = 92
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
//! 定义从托管对象提取的不可变视觉树。共享业务对象可以有多次展示,树节点与 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,
|
||||
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>,
|
||||
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 +46,37 @@ impl UiNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否需要对子项执行弹性主轴分配。
|
||||
pub fn has_flex(&self) -> bool {
|
||||
self.flex > 0.0 || self.flex_policy != crate::layout::FlexPolicy::default()
|
||||
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 {
|
||||
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
|
||||
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
|
||||
@@ -48,6 +93,47 @@ impl UiNode {
|
||||
.all(|(a, b)| Arc::ptr_eq(a, b))
|
||||
};
|
||||
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,7 +209,34 @@ impl UiNode {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 视觉内容的原生分类。叶节点保存绘制数据,容器保存子树,场景视口另外保存相机与世界投影。
|
||||
pub enum UiKind {
|
||||
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>>,
|
||||
},
|
||||
CanvasViewport {
|
||||
width: f32,
|
||||
height: f32,
|
||||
camera: crate::viewport::CameraView,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
Overlay {
|
||||
padding: f32,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
Canvas {
|
||||
positions: Vec<[f32; 2]>,
|
||||
children: Vec<Arc<UiNode>>,
|
||||
},
|
||||
Button {
|
||||
padding: f32,
|
||||
gap: f32,
|
||||
|
||||
+13
-3
@@ -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,22 @@ mod runtime;
|
||||
mod shell;
|
||||
|
||||
pub(crate) use bridge::ui_bridge;
|
||||
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 +34,16 @@ 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()));
|
||||
let (activity, control) = api::start(namespace.clone(), event_loop.create_proxy())?;
|
||||
let mut app = TesseraApp::new(namespace, activity, event_loop.create_proxy(), control)?;
|
||||
// run_app 接管线程直至退出;退出后仍需检查清理过程中记录的错误。
|
||||
event_loop.run_app(&mut app)?;
|
||||
app.exit_result()
|
||||
}
|
||||
|
||||
@@ -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,33 @@ 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 {
|
||||
field: String,
|
||||
pub compute: Option<ObjectRef>,
|
||||
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 +50,7 @@ impl ObjectPayload for Binding {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查绑定目标与可调用计算,建立托管连接及其引用关系。
|
||||
pub fn connect(
|
||||
cx: &mut NativeContext<'_>,
|
||||
target: ObjectRef,
|
||||
@@ -114,6 +124,7 @@ pub fn connect(
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// 在只读调用中重算失效绑定,采集新的读取版本,再将成功结果应用到目标属性。
|
||||
pub fn refresh(
|
||||
vm: &mut Vm,
|
||||
nodes: &[WeakObjectRef],
|
||||
|
||||
+364
-1
@@ -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,14 @@ use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, Vm
|
||||
use crate::ui_bridge::UiNode;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 稳定组成 slot 与子 Block 的配对;slot 不依赖孩子当前的屏幕位置。
|
||||
pub struct Child {
|
||||
pub slot: u64,
|
||||
pub block: ObjectRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 原生组件分类,只描述业务组件种类;每种展示几何由布局和 Mount 另行生成。
|
||||
pub enum Kind {
|
||||
Text {
|
||||
value: String,
|
||||
@@ -32,6 +38,16 @@ pub enum Kind {
|
||||
},
|
||||
Row,
|
||||
Column,
|
||||
Overlay,
|
||||
Scene2D,
|
||||
Viewport3D {
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
CanvasViewport {
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
Button,
|
||||
Page,
|
||||
}
|
||||
@@ -39,6 +55,7 @@ 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 {
|
||||
pub state: ObjectRef,
|
||||
pub page: bool,
|
||||
@@ -46,9 +63,11 @@ pub struct Block {
|
||||
}
|
||||
|
||||
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"));
|
||||
@@ -66,6 +85,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"));
|
||||
@@ -78,21 +98,26 @@ impl ObjectPayload for Block {
|
||||
}
|
||||
}
|
||||
|
||||
/// 提供受检查的 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,
|
||||
@@ -112,6 +137,7 @@ impl ObjectPayload for Block {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 组件当前业务描述与组成。与一次求出的屏幕几何分开保存,替换或属性变化会触发派生快照失效。
|
||||
pub struct Data {
|
||||
pub kind: Kind,
|
||||
pub children: Vec<Child>,
|
||||
@@ -120,7 +146,20 @@ 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,
|
||||
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,
|
||||
pub next_slot: u64,
|
||||
pub history: Vec<ObjectRef>,
|
||||
pub history_index: usize,
|
||||
@@ -132,6 +171,7 @@ pub struct Data {
|
||||
}
|
||||
|
||||
impl Data {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(kind: Kind, children: Vec<ObjectRef>, padding: f32, gap: f32) -> Self {
|
||||
let next_slot = children.len() as u64;
|
||||
Self {
|
||||
@@ -149,7 +189,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 +213,46 @@ impl Data {
|
||||
properties: None,
|
||||
}
|
||||
}
|
||||
/// 检查原生组件种类是否承载子组成,供树修改和布局选择使用。
|
||||
pub fn is_container(&self) -> bool {
|
||||
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,6 +277,7 @@ 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);
|
||||
for child in &data.children {
|
||||
@@ -224,11 +299,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,
|
||||
@@ -259,11 +336,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.
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
pub fn validate(
|
||||
store: &ObjectStore,
|
||||
root: ObjectRef,
|
||||
forbidden_state: Option<ObjectRef>,
|
||||
) -> Result<(), VmError> {
|
||||
// 显式进入/退出栈模拟深度优先遍历;退出时移除祖先链标记,允许共享子树。
|
||||
enum Step {
|
||||
Enter(ObjectRef),
|
||||
Exit(ObjectRef),
|
||||
@@ -309,17 +388,22 @@ pub fn validate(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将当前托管或可变状态提取为后续阶段使用的派生表示。
|
||||
pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result<Arc<UiNode>, VmError> {
|
||||
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())
|
||||
}
|
||||
let mut fields = vec![
|
||||
("x", number(data.position[0])),
|
||||
("y", number(data.position[1])),
|
||||
("flex", number(data.flex)),
|
||||
(
|
||||
"flexBasis",
|
||||
@@ -330,8 +414,73 @@ 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,
|
||||
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())),
|
||||
@@ -361,11 +510,13 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
|
||||
("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> {
|
||||
let mut data = data.clone();
|
||||
let properties = data
|
||||
@@ -378,9 +529,53 @@ pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
|
||||
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",
|
||||
))?,
|
||||
)?;
|
||||
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",
|
||||
))?;
|
||||
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");
|
||||
let number = || -> Result<f32, VmError> {
|
||||
@@ -393,7 +588,151 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
|
||||
}
|
||||
Ok(n)
|
||||
};
|
||||
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) => {
|
||||
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" 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"
|
||||
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,
|
||||
};
|
||||
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());
|
||||
};
|
||||
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(());
|
||||
}
|
||||
"flexBasis" => {
|
||||
data.flex_policy.basis = if matches!(value, Value::Nil)
|
||||
|| matches!(value, Value::Text(text) if text == "auto")
|
||||
@@ -423,6 +762,21 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
|
||||
return Ok(());
|
||||
}
|
||||
let container = data.is_container();
|
||||
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,6 +785,10 @@ 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());
|
||||
@@ -472,3 +830,8 @@ pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Resu
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 统一返回非法页面缩放错误,确保属性写入与输入路径使用相同限制。
|
||||
fn invalid_zoom() -> VmError {
|
||||
VmError::InvalidArgument("Page.zoom expects a number from 0.1 to 4")
|
||||
}
|
||||
|
||||
+194
-10
@@ -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,6 +49,7 @@ impl Default for NamespaceState {
|
||||
}
|
||||
|
||||
impl NamespaceState {
|
||||
/// 仅接收当前 session 的纯数据窗口命令,并按种类合并同资源的重复请求。
|
||||
pub fn publish_window_commands(&mut self, session: u64, commands: Vec<crate::window::Command>) {
|
||||
if self.runtime_session != session {
|
||||
self.window_commands.clear();
|
||||
@@ -63,13 +73,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,6 +95,7 @@ impl NamespaceState {
|
||||
Ok(Some(revision))
|
||||
}
|
||||
|
||||
/// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。
|
||||
pub fn clear(&mut self) -> Result<u64> {
|
||||
let submission = self.next_submission()?;
|
||||
let revision = self
|
||||
@@ -98,6 +112,7 @@ 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>,
|
||||
@@ -112,7 +127,20 @@ pub struct RenderDocument {
|
||||
}
|
||||
|
||||
impl RenderDocument {
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
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");
|
||||
}
|
||||
@@ -144,6 +172,16 @@ 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()?;
|
||||
}
|
||||
@@ -163,6 +201,9 @@ impl RenderDocument {
|
||||
}
|
||||
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 +211,9 @@ 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)?;
|
||||
}
|
||||
if let Some(scroll) = mount.scroll
|
||||
&& (!scroll.iter().all(|n| {
|
||||
n.is_finite() && (0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(n)
|
||||
@@ -206,6 +250,7 @@ impl RenderDocument {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验文档后把逻辑绘制命令转换成给定设备比例的显示列表,应用变换与裁剪。
|
||||
pub fn display_list(&self, scale: f32) -> Result<DisplayList> {
|
||||
let frame = &self.frame;
|
||||
let mut list = DisplayList::default();
|
||||
@@ -217,7 +262,17 @@ impl RenderDocument {
|
||||
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
|
||||
.to_draw_command(scale * view.zoom)?
|
||||
.translated(Point::new(view.x * scale, view.y * scale)),
|
||||
);
|
||||
if clip.is_some() {
|
||||
list.push(DrawCommand::PopClip);
|
||||
}
|
||||
@@ -225,6 +280,7 @@ impl RenderDocument {
|
||||
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 +289,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(),
|
||||
@@ -274,12 +338,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,6 +359,7 @@ pub struct CodeNode {
|
||||
}
|
||||
|
||||
impl CodeNode {
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
fn validate<'a>(&'a self, depth: usize, ids: &mut HashSet<&'a str>) -> Result<()> {
|
||||
if depth > 64 {
|
||||
bail!("architecture tree exceeds 64 levels");
|
||||
@@ -312,6 +379,7 @@ impl CodeNode {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按请求的代码节点身份或路径查找对应的源码投影。
|
||||
fn find(&self, id: &str) -> Option<&CodeNode> {
|
||||
if self.id == id {
|
||||
return Some(self);
|
||||
@@ -321,7 +389,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,6 +405,10 @@ 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)]
|
||||
pub command_mounts: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub mounts: Vec<MountGeometry>,
|
||||
#[serde(default)]
|
||||
pub layout_version: u64,
|
||||
@@ -345,12 +423,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,7 +446,20 @@ 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")]
|
||||
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")]
|
||||
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")]
|
||||
@@ -381,8 +477,39 @@ 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> {
|
||||
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;
|
||||
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)?;
|
||||
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;
|
||||
};
|
||||
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 +517,56 @@ 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| {
|
||||
(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()
|
||||
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 +600,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,
|
||||
@@ -505,8 +673,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,
|
||||
@@ -558,14 +737,17 @@ 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");
|
||||
@@ -576,10 +758,12 @@ fn validate_rect(x: f32, y: f32, width: f32, height: f32, radius: f32) -> Result
|
||||
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('#')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 模型层模块入口。包含托管业务对象、每次展示上下文、依赖缓存,以及交给窗口线程的发布值。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
pub(crate) mod binding;
|
||||
pub(crate) mod block;
|
||||
@@ -6,5 +8,6 @@ pub(crate) mod mount;
|
||||
pub(crate) mod object_list;
|
||||
pub(crate) mod presentation;
|
||||
pub(crate) mod protocols;
|
||||
pub(crate) mod scene3d;
|
||||
pub(crate) mod snapshot;
|
||||
pub(crate) mod window;
|
||||
|
||||
+132
-4
@@ -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,20 @@ 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],
|
||||
pub version: u64,
|
||||
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 +36,23 @@ 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 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
visit(self.block);
|
||||
if let Some(tasks) = self.tasks {
|
||||
@@ -49,12 +62,17 @@ impl ObjectPayload for Mount {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
|
||||
enum Query {
|
||||
Bounds,
|
||||
ScrollTo,
|
||||
ScrollBy,
|
||||
ScrollPosition,
|
||||
ScrollIntoView,
|
||||
FitView,
|
||||
Pick3D,
|
||||
ToView,
|
||||
ToLocal,
|
||||
Parent,
|
||||
FirstChild,
|
||||
NextSibling,
|
||||
@@ -64,6 +82,7 @@ enum Query {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
|
||||
struct Method {
|
||||
signature: endor_core::CallSignature,
|
||||
owner: ObjectRef,
|
||||
@@ -72,14 +91,21 @@ struct Method {
|
||||
}
|
||||
|
||||
impl NativeCallable for Method {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
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,6 +114,7 @@ impl NativeCallable for Method {
|
||||
))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mount = cx.store().payload::<Mount>(self.owner, "Mount")?.clone();
|
||||
if !mount.mounted
|
||||
@@ -99,6 +126,10 @@ impl NativeCallable for Method {
|
||||
| Query::ScrollTo
|
||||
| Query::ScrollBy
|
||||
| Query::ScrollIntoView
|
||||
| Query::FitView
|
||||
| Query::Pick3D
|
||||
| Query::ToView
|
||||
| Query::ToLocal
|
||||
| Query::ScrollPosition
|
||||
)
|
||||
{
|
||||
@@ -106,7 +137,12 @@ impl NativeCallable for Method {
|
||||
}
|
||||
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 +152,50 @@ impl NativeCallable for Method {
|
||||
return Err(VmError::InvalidArgument("incorrect Mount method arity"));
|
||||
}
|
||||
let value = match self.query {
|
||||
Query::ToView | Query::ToLocal => {
|
||||
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) {
|
||||
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 {
|
||||
mount.geometry.logical_point(point).map(|point| {
|
||||
crate::geometry::Point::new(
|
||||
point.x - mount.geometry.x,
|
||||
point.y - mount.geometry.y,
|
||||
)
|
||||
})
|
||||
};
|
||||
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
|
||||
}
|
||||
}
|
||||
Query::ScrollTo | Query::ScrollBy => {
|
||||
crate::scroll::set(
|
||||
cx,
|
||||
@@ -133,6 +213,24 @@ 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])?,
|
||||
),
|
||||
)?
|
||||
}
|
||||
Query::ScrollPosition => {
|
||||
let p = mount
|
||||
.geometry
|
||||
@@ -244,6 +342,7 @@ impl NativeCallable for Method {
|
||||
}
|
||||
}
|
||||
|
||||
/// 声明 Mount 查询方法的源类型契约,区分几何、层次与坐标操作。
|
||||
fn mount_signature(
|
||||
store: &endor_core::ObjectStore,
|
||||
desktop: ObjectRef,
|
||||
@@ -258,18 +357,28 @@ 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(),
|
||||
};
|
||||
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 +389,14 @@ fn mount_signature(
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
/// 活动出现的弱索引。真实托管拥有关系在 desktop 的挂载字典中,索引不能充当隐藏 GC 根。
|
||||
pub struct MountTable {
|
||||
pub active: HashMap<u64, WeakObjectRef>,
|
||||
order: Vec<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 同步出现关系时的共享临时工作状态,保存本轮重用、建立和退役安排。
|
||||
struct Sync {
|
||||
desktop: ObjectRef,
|
||||
page: Option<ObjectRef>,
|
||||
@@ -298,6 +409,7 @@ struct Sync {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 将一个生命周期回调及参数接入同一 VM 调用序列。
|
||||
struct LifecycleCall {
|
||||
function: ObjectRef,
|
||||
block: ObjectRef,
|
||||
@@ -305,10 +417,12 @@ struct LifecycleCall {
|
||||
unmount: bool,
|
||||
}
|
||||
impl NativeCallable for LifecycleCall {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
if self.unmount {
|
||||
crate::tasks::cancel_owner(cx, self.mount)?;
|
||||
@@ -320,6 +434,7 @@ impl NativeCallable for LifecycleCall {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造生命周期调用所需的托管函数与参数,使回调遵循同一 VM 的引用和执行约束。
|
||||
fn lifecycle_call(
|
||||
cx: &mut NativeContext<'_>,
|
||||
mount: ObjectRef,
|
||||
@@ -347,10 +462,12 @@ fn lifecycle_call(
|
||||
}
|
||||
|
||||
impl NativeCallable for Sync {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mut blocks = HashMap::new();
|
||||
for weak in &self.nodes {
|
||||
@@ -405,6 +522,10 @@ impl NativeCallable for Sync {
|
||||
("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),
|
||||
@@ -536,6 +657,7 @@ 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>,
|
||||
@@ -544,6 +666,7 @@ pub struct Checkpoint {
|
||||
}
|
||||
|
||||
impl Checkpoint {
|
||||
/// 释放检查点为回滚临时保留的托管根,成功提交后不再需要这些引用。
|
||||
pub fn release(self, vm: &mut Vm) -> Result<(), VmError> {
|
||||
let mut result = Ok(());
|
||||
for root in self.roots.into_iter().rev() {
|
||||
@@ -554,6 +677,7 @@ impl Checkpoint {
|
||||
result
|
||||
}
|
||||
|
||||
/// 恢复检查点中尚未发布的出现关系与布局上下文,并释放临时保留。
|
||||
pub fn restore(
|
||||
self,
|
||||
vm: &mut Vm,
|
||||
@@ -613,6 +737,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),
|
||||
@@ -668,6 +793,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)? {
|
||||
@@ -694,6 +820,7 @@ impl MountTable {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按原始展示顺序返回弱 Mount 身份,供事件和协议驱动器保持稳定顺序。
|
||||
pub fn ordered(&self) -> Vec<WeakObjectRef> {
|
||||
self.order
|
||||
.iter()
|
||||
@@ -701,6 +828,7 @@ impl MountTable {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 根据新结构复用或建立每次出现的 Mount,连接父子关系并准备生命周期调用。
|
||||
pub fn synchronize(
|
||||
&mut self,
|
||||
vm: &mut Vm,
|
||||
|
||||
@@ -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,10 +46,12 @@ impl NativeCallable for At {
|
||||
endor_core::CallEffect::ReadOnly,
|
||||
))
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
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"));
|
||||
@@ -57,6 +70,7 @@ impl NativeCallable for At {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将已有对象引用包装为新的 ObjectList,并安装默认名义类型及下标查询方法。
|
||||
pub fn collection(
|
||||
cx: &mut NativeContext<'_>,
|
||||
mounts: Vec<ObjectRef>,
|
||||
@@ -65,6 +79,7 @@ pub fn collection(
|
||||
build(cx, mounts, desktop, None)
|
||||
}
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
fn build(
|
||||
cx: &mut NativeContext<'_>,
|
||||
mounts: Vec<ObjectRef>,
|
||||
@@ -96,23 +111,28 @@ fn build(
|
||||
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()
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::range(1, usize::MAX))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
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"));
|
||||
|
||||
@@ -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,6 +9,7 @@ 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,
|
||||
@@ -16,6 +20,7 @@ pub(crate) struct Presentation {
|
||||
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,
|
||||
@@ -33,6 +38,7 @@ pub(crate) struct Presentation {
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
/// 比较布局/绘制读取版本和名称依赖,标记需要刷新并通知快照缓存。
|
||||
pub fn observe_changes(&mut self, vm: &Vm, changes: &ChangeBatch) {
|
||||
if let Some((phase, object, version)) = self
|
||||
.layout_reads
|
||||
@@ -81,6 +87,7 @@ impl Presentation {
|
||||
});
|
||||
}
|
||||
|
||||
/// 按尺寸、内容或绘制失效原因清理对应缓存,避免把所有变化都当成相同重建。
|
||||
pub fn invalidate(
|
||||
&mut self,
|
||||
cx: &mut NativeContext<'_>,
|
||||
@@ -111,6 +118,7 @@ impl Presentation {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(desktop: ObjectRef, tabs: ObjectRef) -> Self {
|
||||
Self {
|
||||
desktop,
|
||||
@@ -122,6 +130,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 +149,7 @@ impl Presentation {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把读取记录拆成对象版本和精确名称依赖,便于按实际观察内容判断失效。
|
||||
pub(crate) fn split_reads(
|
||||
reads: Vec<endor_core::ReadDependency>,
|
||||
) -> (
|
||||
@@ -177,9 +187,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(),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! 安装 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>,
|
||||
|
||||
@@ -0,0 +1,927 @@
|
||||
//! 将 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)
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
pub fn arity(self) -> (usize, usize) {
|
||||
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> {
|
||||
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> {
|
||||
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 => {
|
||||
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];
|
||||
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![],
|
||||
};
|
||||
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])?);
|
||||
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))?;
|
||||
}
|
||||
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",
|
||||
));
|
||||
}
|
||||
}
|
||||
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])?)?;
|
||||
}
|
||||
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.)?;
|
||||
}
|
||||
}
|
||||
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> {
|
||||
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> {
|
||||
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,
|
||||
}
|
||||
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(),
|
||||
};
|
||||
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) {
|
||||
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.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();
|
||||
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.
|
||||
let key = key & i64::MAX as u64;
|
||||
if panels.iter().any(|panel: &PanelMount| panel.key == key) {
|
||||
return Err(invalid("colliding 3D UI occurrence identities"));
|
||||
}
|
||||
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")?)?)?;
|
||||
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,
|
||||
}),
|
||||
});
|
||||
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")),
|
||||
}
|
||||
}
|
||||
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"));
|
||||
}
|
||||
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);
|
||||
};
|
||||
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);
|
||||
};
|
||||
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()),
|
||||
)?;
|
||||
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.
|
||||
/// 为指定出现解码相机手势并调用控制器;嵌套面板输入先转换到所属视口坐标。
|
||||
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")?)?;
|
||||
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"))?;
|
||||
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;
|
||||
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.);
|
||||
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 = 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"));
|
||||
}
|
||||
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 节点的原生平移/旋转操作,供尚不支持普通数值算术表达式的源程序调用。
|
||||
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 {
|
||||
value.rem_euclid(360.)
|
||||
} else {
|
||||
value
|
||||
},
|
||||
));
|
||||
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 {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
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")?;
|
||||
cx.set_attr(event, "hit", hit)?;
|
||||
crate::event_dispatch::dispatch(cx, mount.block, event, Some(self.mount), self.desktop)
|
||||
}
|
||||
}
|
||||
+122
-2
@@ -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,13 +22,16 @@ 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>>,
|
||||
entries: HashMap<ObjectRef, Cached>,
|
||||
dependents: HashMap<Dependency, HashSet<ObjectRef>>,
|
||||
parents: HashMap<ObjectRef, HashSet<ObjectRef>>,
|
||||
@@ -34,6 +42,7 @@ pub struct RenderCache {
|
||||
}
|
||||
|
||||
impl RenderCache {
|
||||
/// 把 VM 变化映射到读取过这些属性/对象的视觉节点,再沿父链向上传播脏标记。
|
||||
pub fn invalidate(&mut self, batch: ChangeBatch) {
|
||||
let mut queue = VecDeque::new();
|
||||
if batch.rescan_required {
|
||||
@@ -57,6 +66,7 @@ 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() {
|
||||
@@ -68,6 +78,7 @@ impl RenderCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 撤销旧缓存的反向读取边和父子边,为重新登记该节点做准备。
|
||||
fn remove(&mut self, block: ObjectRef) -> Option<Cached> {
|
||||
let old = self.entries.remove(&block)?;
|
||||
for source in &old.dependencies {
|
||||
@@ -89,7 +100,10 @@ impl RenderCache {
|
||||
Some(old)
|
||||
}
|
||||
|
||||
/// 删除托管堆中已不存在的身份,缓存本身不负责保持业务对象存活。
|
||||
pub fn prune(&mut self, store: &ObjectStore) {
|
||||
self.scenes3d
|
||||
.retain(|object, _| store.object(*object).is_ok());
|
||||
let dead: Vec<_> = self
|
||||
.entries
|
||||
.keys()
|
||||
@@ -102,6 +116,7 @@ impl RenderCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按显式栈遍历托管组成;复用干净子树,重建脏节点,并在完成前检查循环和资源上限。
|
||||
pub fn snapshot(
|
||||
&mut self,
|
||||
store: &ObjectStore,
|
||||
@@ -113,6 +128,7 @@ impl RenderCache {
|
||||
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)
|
||||
@@ -128,7 +144,7 @@ impl RenderCache {
|
||||
}
|
||||
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 {
|
||||
@@ -154,6 +170,48 @@ 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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
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"));
|
||||
@@ -259,11 +317,60 @@ 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,
|
||||
@@ -276,13 +383,26 @@ impl RenderCache {
|
||||
},
|
||||
};
|
||||
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,
|
||||
|
||||
@@ -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,23 +23,27 @@ 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 {
|
||||
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,
|
||||
pub desktop: ObjectRef,
|
||||
@@ -48,18 +57,23 @@ pub struct ManagedWindow {
|
||||
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,9 +85,11 @@ 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"));
|
||||
@@ -84,6 +100,7 @@ fn screen_coordinate(value: &Value) -> Result<i32, VmError> {
|
||||
.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 +115,7 @@ fn scalar(value: &Value) -> Result<f64, VmError> {
|
||||
Ok(number)
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 当前宿主 API 的操作分类,分派代码负责参数校验与状态更新。
|
||||
enum Operation {
|
||||
Id,
|
||||
Viewport,
|
||||
@@ -108,15 +126,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())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(match self.operation {
|
||||
Operation::Resize | Operation::MoveTo => 3,
|
||||
@@ -125,6 +146,7 @@ impl NativeCallable for Method {
|
||||
}))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
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"));
|
||||
@@ -196,6 +218,7 @@ impl NativeCallable for Method {
|
||||
Ok(NativeResult::Return(result))
|
||||
}
|
||||
}
|
||||
/// 把合法窗口操作追加到所属窗口的待发布命令,不能直接在 VM 线程操作 OS。
|
||||
pub fn enqueue(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -225,6 +248,7 @@ pub fn enqueue(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将页面登记到其窗口,维护托管所有权与页面顺序。
|
||||
pub fn register_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
window: ObjectRef,
|
||||
@@ -253,6 +277,7 @@ pub fn register_page(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 从窗口的页面集合撤销指定页面,保持后续展示索引一致。
|
||||
pub fn unregister_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
window: ObjectRef,
|
||||
@@ -276,6 +301,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 +316,7 @@ pub fn unregister_page_host(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在窗口之间移动页面的所有权和登记顺序,保留同一业务页面身份。
|
||||
pub fn move_page(
|
||||
cx: &mut NativeContext<'_>,
|
||||
window: ObjectRef,
|
||||
@@ -323,9 +350,11 @@ 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
|
||||
@@ -336,6 +365,7 @@ pub fn take_commands(vm: &Vm, window: ObjectRef) -> Result<Vec<Command>, VmError
|
||||
commands.sort_by_key(|command| command.sequence);
|
||||
Ok(commands)
|
||||
}
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
@@ -413,6 +443,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,
|
||||
@@ -449,6 +480,7 @@ pub fn create(
|
||||
Ok((window, desktop, tabs))
|
||||
}
|
||||
|
||||
/// 发起窗口关闭和相应生命周期清理,拒绝重复或不再有效的操作。
|
||||
pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmError> {
|
||||
if cx.store().is_read_only() {
|
||||
return Err(invalid(
|
||||
@@ -477,6 +509,7 @@ pub fn close(cx: &mut NativeContext<'_>, window: ObjectRef) -> Result<(), VmErro
|
||||
}
|
||||
|
||||
/// 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();
|
||||
vm.dict_clear(data.tabs)?;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 有限布局盒子的尺寸策略。区分 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.
|
||||
/// 根据固定/auto/fill 与 min/max 规则细化父级提供的可用约束。
|
||||
pub fn available(self, mut constraints: [f32; 4]) -> [f32; 4] {
|
||||
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> {
|
||||
let value = match policy {
|
||||
Dimension::Auto => intrinsic,
|
||||
Dimension::Fixed(value) => value,
|
||||
Dimension::Fill => {
|
||||
if available >= MAX_DIMENSION as f32 {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"fill requires a finite parent allocation on this axis",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
available
|
||||
}
|
||||
};
|
||||
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],
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,7 +36,12 @@ impl Color {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// 原生画布最终顺序执行的绘制原语,已经使用对应设备比例。
|
||||
pub enum DrawCommand {
|
||||
Scene3D {
|
||||
rect: Rect,
|
||||
view: std::sync::Arc<crate::scene3d::View>,
|
||||
},
|
||||
Clear(Color),
|
||||
PushClip(Rect),
|
||||
PopClip,
|
||||
@@ -59,20 +70,86 @@ pub enum DrawCommand {
|
||||
},
|
||||
}
|
||||
|
||||
impl DrawCommand {
|
||||
/// 返回平移后的绘制命令,并同步移动命令中的相关几何。
|
||||
pub fn translated(mut self, delta: Point) -> 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);
|
||||
}
|
||||
|
||||
/// 平移当前显示列表中的命令,供整块页面或管理面板放入窗口坐标。
|
||||
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 +165,12 @@ impl DisplayList {
|
||||
}
|
||||
}
|
||||
|
||||
/// 借用按绘制顺序排列的原生原语,不复制命令集合。
|
||||
pub fn commands(&self) -> &[DrawCommand] {
|
||||
&self.commands
|
||||
}
|
||||
|
||||
/// 按原顺序合并另一组绘制命令,不在这里重排层次。
|
||||
pub fn extend(&mut self, other: DisplayList) {
|
||||
self.commands.extend(other.commands);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+570
-49
@@ -1,3 +1,7 @@
|
||||
//! 原生布局求解器:自底向上测量期望尺寸,再自顶向下分配位置和实际尺寸。
|
||||
//! Row/Column/Overlay、边距、边框、内边距、对齐和弹性空间都在有限约束内计算;过大的尺寸和坐标返回错误。
|
||||
//! 缓存按组件形状、子项尺寸和约束判断可复用性。frame 先在克隆状态中求解,成功才替换运行状态,防止失败污染已发布几何。
|
||||
//!
|
||||
//! Iterative measure/arrange/paint over derived visual data. Every occurrence
|
||||
//! has its own geometry; measurement caches never become semantic object state.
|
||||
use crate::{
|
||||
@@ -10,7 +14,82 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
const MAX_LAYOUT_VISITS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
/// 上右下左四边的空间占用,用于 margin、padding 和 border 计算。
|
||||
pub struct Insets {
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
}
|
||||
|
||||
impl Insets {
|
||||
/// 返回该边距集合在水平方向占据的总空间。
|
||||
pub fn horizontal(self) -> f32 {
|
||||
self.left + self.right
|
||||
}
|
||||
/// 返回该边距集合在垂直方向占据的总空间。
|
||||
pub fn vertical(self) -> f32 {
|
||||
self.top + self.bottom
|
||||
}
|
||||
/// 在内容尺寸外加上对应四边占用,得到外层测量尺寸。
|
||||
fn outer(self, size: Size) -> Size {
|
||||
Size {
|
||||
width: size.width + self.horizontal(),
|
||||
height: size.height + self.vertical(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alignment inside a finite cross-axis content box. Overflow remains start
|
||||
/// aligned so that content cannot become inaccessible beyond the leading edge.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
/// 交叉轴对齐规则;Stretch 需要结合子项是否自动尺寸决定实际拉伸。
|
||||
pub enum CrossAlign {
|
||||
#[default]
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
Stretch,
|
||||
}
|
||||
|
||||
impl CrossAlign {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "start",
|
||||
Self::Center => "center",
|
||||
Self::End => "end",
|
||||
Self::Stretch => "stretch",
|
||||
}
|
||||
}
|
||||
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
|
||||
match value {
|
||||
"start" => Ok(Self::Start),
|
||||
"center" => Ok(Self::Center),
|
||||
"end" => Ok(Self::End),
|
||||
"stretch" => Ok(Self::Stretch),
|
||||
_ => Err(endor_core::VmError::InvalidArgument(
|
||||
"crossAlign/alignSelf expects start, center, end or stretch (alignSelf also accepts auto)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据交叉轴对齐方式计算有限剩余空间中的偏移。
|
||||
fn offset(self, available: f32, occupied: f32) -> f32 {
|
||||
let spare = (available - occupied).max(0.0);
|
||||
match self {
|
||||
Self::Start | Self::Stretch => 0.0,
|
||||
Self::Center => spare / 2.0,
|
||||
Self::End => spare,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
/// 容器主轴的内容对齐或剩余间隔分配方式。
|
||||
pub enum MainAlign {
|
||||
#[default]
|
||||
Start,
|
||||
@@ -19,6 +98,7 @@ pub enum MainAlign {
|
||||
SpaceBetween,
|
||||
}
|
||||
impl MainAlign {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Start => "start",
|
||||
@@ -27,6 +107,7 @@ impl MainAlign {
|
||||
Self::SpaceBetween => "space-between",
|
||||
}
|
||||
}
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
pub fn parse(value: &str) -> Result<Self, endor_core::VmError> {
|
||||
match value {
|
||||
"start" => Ok(Self::Start),
|
||||
@@ -42,6 +123,7 @@ impl MainAlign {
|
||||
|
||||
/// Main-axis constraints interpreted independently by each parent occurrence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
/// 主轴伸缩策略与限值,不能用一个宽度常量替代完整分配规则。
|
||||
pub struct FlexPolicy {
|
||||
pub basis: Option<f32>,
|
||||
pub min: f32,
|
||||
@@ -49,6 +131,7 @@ pub struct FlexPolicy {
|
||||
pub shrink: f32,
|
||||
}
|
||||
impl Default for FlexPolicy {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
basis: None,
|
||||
@@ -59,20 +142,25 @@ impl Default for FlexPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
/// 逻辑宽高值,不隐含窗口像素比例或相机缩放。
|
||||
pub struct Size {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 影响原生测量的缓存形状,避免把颜色等纯绘制变化视为尺寸变化。
|
||||
enum Shape {
|
||||
Sized(Box<Shape>, crate::box_model::Sizing, [f32; 4]),
|
||||
Text(String, u32, f32),
|
||||
Rect(f32, f32),
|
||||
Container(bool, f32, f32, Vec<Size>),
|
||||
Container(bool, Insets, f32, Vec<Size>),
|
||||
Padded(Box<Shape>, Insets),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
/// 结构出现身份键,区分同一业务对象在不同父位置的展示。
|
||||
struct Key {
|
||||
parent: Option<u64>,
|
||||
slot: u64,
|
||||
@@ -80,6 +168,7 @@ struct Key {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// 布局缓存、稳定出现身份与统计。构建在临时副本上完成,成功后才写回。
|
||||
pub struct LayoutRuntime {
|
||||
sizes: HashMap<String, (Shape, Size)>,
|
||||
ids: HashMap<Key, u64>,
|
||||
@@ -90,8 +179,10 @@ pub struct LayoutRuntime {
|
||||
pub settle_passes: usize,
|
||||
pub slices: usize,
|
||||
pub viewport: Option<Size>,
|
||||
pub page_mode: Option<crate::page::Mode>,
|
||||
}
|
||||
|
||||
/// 迭代遍历中的一个出现,保存父子索引、约束与稳定 ID。
|
||||
struct Item<'a> {
|
||||
node: &'a UiNode,
|
||||
parent: Option<usize>,
|
||||
@@ -100,6 +191,7 @@ struct Item<'a> {
|
||||
constraints: [f32; 4],
|
||||
}
|
||||
|
||||
/// 检查尺寸非负、有限且不超过布局上限。
|
||||
fn bounded(value: f32) -> Result<f32> {
|
||||
if !value.is_finite() || !(0.0..=MAX_DIMENSION as f32).contains(&value) {
|
||||
bail!("UI layout exceeds the 1000000 coordinate/size limit");
|
||||
@@ -107,6 +199,15 @@ fn bounded(value: f32) -> Result<f32> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 检查允许有正负方向的坐标仍在安全布局范围内。
|
||||
fn coordinate(value: f32) -> Result<f32> {
|
||||
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 {
|
||||
bail!("UI coordinate exceeds the 1000000 limit");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
|
||||
pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
|
||||
let size = viewport.unwrap_or(Size {
|
||||
width: MAX_DIMENSION as f32,
|
||||
@@ -114,7 +215,33 @@ pub fn root_constraints(viewport: Option<Size>) -> [f32; 4] {
|
||||
});
|
||||
[0.0, 0.0, size.width, size.height]
|
||||
}
|
||||
|
||||
/// 从父级上限扣除内边距;无界哨兵保持无界,不因减法被误认为有限。
|
||||
fn content_limit(available: f32, inset: f32) -> f32 {
|
||||
if available >= MAX_DIMENSION as f32 {
|
||||
available
|
||||
} else {
|
||||
(available - inset).max(0.0)
|
||||
}
|
||||
}
|
||||
/// 把父盒子的可用尺寸传递给子项,扣除容器内边距并考虑主轴是否可延伸。
|
||||
pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
|
||||
let parent = node.sizing.available(parent);
|
||||
if let UiKind::Overlay { .. } = node.kind {
|
||||
let padding = insets(node);
|
||||
return [
|
||||
0.0,
|
||||
0.0,
|
||||
content_limit(parent[2], padding.horizontal()),
|
||||
content_limit(parent[3], padding.vertical()),
|
||||
];
|
||||
}
|
||||
if matches!(
|
||||
node.kind,
|
||||
UiKind::Canvas { .. } | UiKind::Scene2D { .. } | UiKind::CanvasViewport { .. }
|
||||
) {
|
||||
return root_constraints(None);
|
||||
}
|
||||
if let UiKind::Scroll { width, height, .. } = node.kind {
|
||||
return arranged_child_constraints(
|
||||
node,
|
||||
@@ -127,39 +254,106 @@ pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] {
|
||||
0.0,
|
||||
0.0,
|
||||
MAX_DIMENSION as f32,
|
||||
(parent[3] - padding * 2.0).max(0.0),
|
||||
content_limit(parent[3], padding.vertical()),
|
||||
],
|
||||
Some((false, padding, _)) => [
|
||||
0.0,
|
||||
0.0,
|
||||
(parent[2] - padding * 2.0).max(0.0),
|
||||
content_limit(parent[2], padding.horizontal()),
|
||||
MAX_DIMENSION as f32,
|
||||
],
|
||||
None => parent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从子项可用范围扣除外边距,并使最小值不超过调整后的最大值。
|
||||
pub fn margin_constraints(child: &UiNode, mut constraints: [f32; 4]) -> [f32; 4] {
|
||||
for (axis, margin) in [child.margin.horizontal(), child.margin.vertical()]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if constraints[axis + 2] < MAX_DIMENSION as f32 {
|
||||
constraints[axis + 2] = (constraints[axis + 2] - margin).max(0.0);
|
||||
constraints[axis] = constraints[axis].min(constraints[axis + 2]);
|
||||
}
|
||||
}
|
||||
constraints
|
||||
}
|
||||
|
||||
/// Main-axis fill first measures against a finite offer, then participates in
|
||||
/// weighted allocation. The unlimited-axis sentinel is never made finite by padding.
|
||||
/// 根据具体子项尺寸策略和所在位置细化约束;世界 UI 根使用面板的有限像素尺寸。
|
||||
pub fn occurrence_constraints(
|
||||
parent: &UiNode,
|
||||
child: &UiNode,
|
||||
mut base: [f32; 4],
|
||||
offer: Size,
|
||||
child_index: usize,
|
||||
) -> [f32; 4] {
|
||||
if let UiKind::Viewport3D { panels, .. } = &parent.kind {
|
||||
let size = panels[child_index];
|
||||
return [size.width, size.height, size.width, size.height];
|
||||
}
|
||||
let inset = insets(parent);
|
||||
for (axis, dimension, available, padding) in [
|
||||
(0, child.sizing.width, offer.width, inset.horizontal()),
|
||||
(1, child.sizing.height, offer.height, inset.vertical()),
|
||||
] {
|
||||
if dimension == crate::box_model::Dimension::Fill
|
||||
&& base[axis + 2] >= MAX_DIMENSION as f32
|
||||
&& available < MAX_DIMENSION as f32
|
||||
{
|
||||
base[axis + 2] = (available - padding).max(0.0);
|
||||
base[axis] = base[axis].min(base[axis + 2]);
|
||||
}
|
||||
}
|
||||
margin_constraints(child, base)
|
||||
}
|
||||
|
||||
/// 生成求解开始时的子项约束,供后续测量和分配迭代使用。
|
||||
pub fn initial_child_constraints(
|
||||
parent: &UiNode,
|
||||
child: &UiNode,
|
||||
constraints: [f32; 4],
|
||||
child_index: usize,
|
||||
) -> [f32; 4] {
|
||||
let offer = parent.sizing.available(constraints);
|
||||
occurrence_constraints(
|
||||
parent,
|
||||
child,
|
||||
child_constraints(parent, constraints),
|
||||
Size {
|
||||
width: offer[2],
|
||||
height: offer[3],
|
||||
},
|
||||
child_index,
|
||||
)
|
||||
}
|
||||
/// Scroll cross-axis constraints come from this occurrence's actual viewport,
|
||||
/// including a source parent's allocation rather than only constructor size.
|
||||
/// 根据已分配父尺寸重新细化子项约束,使实际安排能够反向影响换行等测量。
|
||||
pub fn arranged_child_constraints(node: &UiNode, available: [f32; 4], bounds: Size) -> [f32; 4] {
|
||||
if let UiKind::Scroll { padding, axis, .. } = node.kind {
|
||||
if let UiKind::Scroll { axis, .. } = node.kind {
|
||||
let padding = insets(node);
|
||||
return [
|
||||
0.0,
|
||||
0.0,
|
||||
if axis.horizontal() {
|
||||
MAX_DIMENSION as f32
|
||||
} else {
|
||||
(bounds.width - padding * 2.0).max(0.0)
|
||||
(bounds.width - padding.horizontal()).max(0.0)
|
||||
},
|
||||
if axis.vertical() {
|
||||
MAX_DIMENSION as f32
|
||||
} else {
|
||||
(bounds.height - padding * 2.0).max(0.0)
|
||||
(bounds.height - padding.vertical()).max(0.0)
|
||||
},
|
||||
];
|
||||
}
|
||||
child_constraints(node, available)
|
||||
}
|
||||
|
||||
/// 将期望尺寸限制在调用方给出的最小/最大边界内。
|
||||
pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
|
||||
Size {
|
||||
width: size.width.clamp(constraints[0], constraints[2]),
|
||||
@@ -167,23 +361,49 @@ pub fn constrained(size: Size, constraints: [f32; 4]) -> Size {
|
||||
}
|
||||
}
|
||||
|
||||
fn container(node: &UiNode) -> Option<(bool, f32, f32)> {
|
||||
/// Effective content inset, including borders, in border-box coordinates.
|
||||
/// 计算布局使用的有效内边距和边框占用,兼容旧的统一 padding。
|
||||
pub fn insets(node: &UiNode) -> Insets {
|
||||
let fallback = match node.kind {
|
||||
UiKind::Row { padding, .. }
|
||||
| UiKind::Column { padding, .. }
|
||||
| UiKind::Button { padding, .. }
|
||||
| UiKind::Scroll { padding, .. }
|
||||
| UiKind::Overlay { padding, .. } => padding,
|
||||
_ => 0.0,
|
||||
};
|
||||
Insets {
|
||||
top: node.padding_edges[0].unwrap_or(fallback) + node.border.top,
|
||||
right: node.padding_edges[1].unwrap_or(fallback) + node.border.right,
|
||||
bottom: node.padding_edges[2].unwrap_or(fallback) + node.border.bottom,
|
||||
left: node.padding_edges[3].unwrap_or(fallback) + node.border.left,
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取容器方向与间距等原生布局配置,叶节点不参与容器规则。
|
||||
fn container(node: &UiNode) -> Option<(bool, Insets, f32)> {
|
||||
match &node.kind {
|
||||
UiKind::Row { padding, gap, .. } => Some((true, *padding, *gap)),
|
||||
UiKind::Scroll { padding, gap, .. } => Some((false, *padding, *gap)),
|
||||
UiKind::Column { padding, gap, .. } | UiKind::Button { padding, gap, .. } => {
|
||||
Some((false, *padding, *gap))
|
||||
UiKind::Row { gap, .. } => Some((true, insets(node), *gap)),
|
||||
UiKind::Scroll { gap, .. } => Some((false, insets(node), *gap)),
|
||||
UiKind::Column { gap, .. } | UiKind::Button { gap, .. } => {
|
||||
Some((false, insets(node), *gap))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据容器方向选择主轴,供 flex 和对齐共享同一判断。
|
||||
pub fn main_axis(node: &UiNode) -> Option<usize> {
|
||||
container(node).map(|(row, _, _)| usize::from(!row))
|
||||
}
|
||||
|
||||
/// 根据形状键计算期望尺寸,包括文字换行、容器子项和盒模型占用。
|
||||
fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
let size = match shape {
|
||||
Shape::Sized(shape, sizing, constraints) => {
|
||||
sizing.resolve(measure_shape(shape)?, *constraints)?
|
||||
}
|
||||
Shape::Padded(shape, padding) => padding.outer(measure_shape(shape)?),
|
||||
Shape::Text(value, size, width) => {
|
||||
let style = TextStyle::ui(*size);
|
||||
let intrinsic = text::measure(value, style);
|
||||
@@ -202,13 +422,21 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
.iter()
|
||||
.map(|s| if *row { s.width } else { s.height })
|
||||
.sum::<f32>()
|
||||
+ padding * 2.0
|
||||
+ if *row {
|
||||
padding.horizontal()
|
||||
} else {
|
||||
padding.vertical()
|
||||
}
|
||||
+ gap * children.len().saturating_sub(1) as f32;
|
||||
let cross = children
|
||||
.iter()
|
||||
.map(|s| if *row { s.height } else { s.width })
|
||||
.fold(0.0_f32, f32::max)
|
||||
+ padding * 2.0;
|
||||
+ if *row {
|
||||
padding.vertical()
|
||||
} else {
|
||||
padding.horizontal()
|
||||
};
|
||||
if *row {
|
||||
Size {
|
||||
width: along,
|
||||
@@ -227,19 +455,70 @@ fn measure_shape(shape: &Shape) -> Result<Size> {
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// 建立影响测量的形状键,将布局属性、子项测量结果和约束组合起来。
|
||||
fn shape(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Shape> {
|
||||
Ok(match &node.kind {
|
||||
UiKind::Text { value, size, .. } => Shape::Text(value.clone(), *size, constraints[2]),
|
||||
let available = node.sizing.available(constraints);
|
||||
let shape = match &node.kind {
|
||||
UiKind::Overlay { .. } => {
|
||||
let padding = insets(node);
|
||||
let size = children.into_iter().zip(node.children()).fold(
|
||||
Size::default(),
|
||||
|size, (child, node)| {
|
||||
let child = node.margin.outer(child);
|
||||
Size {
|
||||
width: size.width.max(child.width),
|
||||
height: size.height.max(child.height),
|
||||
}
|
||||
},
|
||||
);
|
||||
Shape::Rect(
|
||||
size.width + padding.horizontal(),
|
||||
size.height + padding.vertical(),
|
||||
)
|
||||
}
|
||||
UiKind::CanvasViewport { width, height, .. } | UiKind::Viewport3D { width, height, .. } => {
|
||||
Shape::Rect(*width, *height)
|
||||
}
|
||||
UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } => {
|
||||
let mut size = Size::default();
|
||||
for (position, child) in positions.iter().zip(children) {
|
||||
size.width = size.width.max(position[0] + child.width);
|
||||
size.height = size.height.max(position[1] + child.height);
|
||||
}
|
||||
Shape::Rect(size.width, size.height)
|
||||
}
|
||||
UiKind::Text { value, size, .. } => {
|
||||
let padding = insets(node);
|
||||
Shape::Padded(
|
||||
Box::new(Shape::Text(
|
||||
value.clone(),
|
||||
*size,
|
||||
(available[2] - padding.horizontal()).max(0.0),
|
||||
)),
|
||||
padding,
|
||||
)
|
||||
}
|
||||
UiKind::Rect { width, height, .. } => Shape::Rect(*width, *height),
|
||||
UiKind::Spacer(size) => Shape::Rect(*size, *size),
|
||||
UiKind::Scroll { width, height, .. } => Shape::Rect(*width, *height),
|
||||
_ => {
|
||||
let (row, padding, gap) = container(node).context("unknown native visual protocol")?;
|
||||
let children = children
|
||||
.into_iter()
|
||||
.zip(node.children())
|
||||
.map(|(size, child)| child.margin.outer(size))
|
||||
.collect();
|
||||
Shape::Container(row, padding, gap, children)
|
||||
}
|
||||
};
|
||||
Ok(if node.sizing == crate::box_model::Sizing::default() {
|
||||
shape
|
||||
} else {
|
||||
Shape::Sized(Box::new(shape), node.sizing, constraints)
|
||||
})
|
||||
}
|
||||
|
||||
/// 执行原生节点测量并应用布局约束,作为源协议的原生实现。
|
||||
pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4]) -> Result<Size> {
|
||||
measure_shape(&shape(node, children, constraints)?)
|
||||
}
|
||||
@@ -247,6 +526,7 @@ pub fn measure_native(node: &UiNode, children: Vec<Size>, constraints: [f32; 4])
|
||||
/// Private derived scratch space for one occurrence during layout. Contains no
|
||||
/// managed references; source code cannot mutate it through heap attributes.
|
||||
#[derive(Debug, Default)]
|
||||
/// 只读布局调用期间提供给 Mount 查询的派生上下文,不作为业务状态保留。
|
||||
pub struct Context {
|
||||
pub node: Option<std::sync::Arc<UiNode>>,
|
||||
pub children: Vec<Size>,
|
||||
@@ -254,16 +534,28 @@ pub struct Context {
|
||||
pub bounds: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// 为单个原生节点按已分配几何生成内容命令;容器背景/边框与整树遍历由外层组织。
|
||||
pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderCommand> {
|
||||
let size_for_text = size;
|
||||
let inset = insets(node);
|
||||
match &node.kind {
|
||||
UiKind::Viewport3D { view, .. } => Some(RenderCommand::Scene3D {
|
||||
x: x + inset.left,
|
||||
y: y + inset.top,
|
||||
width: (size.width - inset.horizontal()).max(0.),
|
||||
height: (size.height - inset.vertical()).max(0.),
|
||||
view: view.clone(),
|
||||
}),
|
||||
UiKind::Text { value, size, color } => Some(RenderCommand::Text {
|
||||
x,
|
||||
y,
|
||||
x: x + inset.left,
|
||||
y: y + inset.top,
|
||||
value: value.clone(),
|
||||
size: *size,
|
||||
color: color.clone(),
|
||||
wrap_bounds: Some([size_for_text.width, size_for_text.height]),
|
||||
wrap_bounds: Some([
|
||||
(size_for_text.width - inset.horizontal()).max(0.0),
|
||||
(size_for_text.height - inset.vertical()).max(0.0),
|
||||
]),
|
||||
}),
|
||||
UiKind::Rect { radius, color, .. } => Some(RenderCommand::RoundedRect {
|
||||
x,
|
||||
@@ -285,19 +577,130 @@ pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option<RenderC
|
||||
}
|
||||
}
|
||||
|
||||
/// 从一个节点及其分配几何生成原生绘制命令,包括背景、边框与内容。
|
||||
pub fn paint_commands(node: &UiNode, x: f32, y: f32, size: Size) -> Vec<RenderCommand> {
|
||||
let mut commands: Vec<_> = paint_native(node, x, y, size).into_iter().collect();
|
||||
let border = node.border;
|
||||
for [x, y, width, height] in [
|
||||
[x, y, size.width, border.top.min(size.height)],
|
||||
[
|
||||
x,
|
||||
y + (size.height - border.bottom).max(0.0),
|
||||
size.width,
|
||||
border.bottom.min(size.height),
|
||||
],
|
||||
[x, y, border.left.min(size.width), size.height],
|
||||
[
|
||||
x + (size.width - border.right).max(0.0),
|
||||
y,
|
||||
border.right.min(size.width),
|
||||
size.height,
|
||||
],
|
||||
] {
|
||||
if width > 0.0 && height > 0.0 {
|
||||
commands.push(RenderCommand::RoundedRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius: 0.0,
|
||||
color: node.border_color.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
commands
|
||||
}
|
||||
|
||||
/// Allocate main-axis space with bounded growth and basis-weighted shrinking.
|
||||
/// Preferred sizes come from this occurrence's first measurement in the solve;
|
||||
/// remeasurement must not turn a prior allocation into a new intrinsic basis.
|
||||
/// 判断子项主轴是否采用 fill,从而参与剩余空间分配。
|
||||
fn main_fill(child: &UiNode, row: bool) -> bool {
|
||||
(if row {
|
||||
child.sizing.width
|
||||
} else {
|
||||
child.sizing.height
|
||||
}) == crate::box_model::Dimension::Fill
|
||||
}
|
||||
|
||||
/// 取出主轴 flex/尺寸策略共同约束下的最小和最大分配范围。
|
||||
fn main_limits(child: &UiNode, row: bool) -> (f32, f32) {
|
||||
let sizing = child.sizing;
|
||||
let (min, max) = if row {
|
||||
(sizing.min_width, sizing.max_width)
|
||||
} else {
|
||||
(sizing.min_height, sizing.max_height)
|
||||
};
|
||||
(
|
||||
child.flex_policy.min.clamp(min, max),
|
||||
child.flex_policy.max.clamp(min, max),
|
||||
)
|
||||
}
|
||||
|
||||
/// 按权重分配主轴剩余空间,尊重每项 min/max;达到上限的项退出后重新分配剩余量。
|
||||
pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec<Size> {
|
||||
if matches!(node.kind, UiKind::Overlay { .. }) {
|
||||
let inset = insets(node);
|
||||
return children
|
||||
.iter()
|
||||
.zip(node.children())
|
||||
.map(|(size, child)| {
|
||||
let mut size = *size;
|
||||
if child.sizing.width == crate::box_model::Dimension::Fill {
|
||||
size.width = (bounds.width - inset.horizontal() - child.margin.horizontal())
|
||||
.max(0.0)
|
||||
.clamp(child.sizing.min_width, child.sizing.max_width);
|
||||
}
|
||||
if child.sizing.height == crate::box_model::Dimension::Fill
|
||||
|| (child.sizing.height == crate::box_model::Dimension::Auto
|
||||
&& child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch)
|
||||
{
|
||||
size.height = (bounds.height - inset.vertical() - child.margin.vertical())
|
||||
.max(0.0)
|
||||
.clamp(child.sizing.min_height, child.sizing.max_height);
|
||||
}
|
||||
size
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let Some((row, padding, gap)) = container(node) else {
|
||||
return children.to_vec();
|
||||
};
|
||||
let nodes = node.children();
|
||||
let mut output = children.to_vec();
|
||||
for (child, size) in nodes.iter().zip(&mut output) {
|
||||
if child.align_self.unwrap_or(node.cross_align) == CrossAlign::Stretch {
|
||||
let sizing = child.sizing;
|
||||
if row && sizing.height == crate::box_model::Dimension::Auto {
|
||||
size.height = (bounds.height - padding.vertical() - child.margin.vertical())
|
||||
.max(0.0)
|
||||
.clamp(sizing.min_height, sizing.max_height);
|
||||
} else if !row && sizing.width == crate::box_model::Dimension::Auto {
|
||||
size.width = (bounds.width - padding.horizontal() - child.margin.horizontal())
|
||||
.max(0.0)
|
||||
.clamp(sizing.min_width, sizing.max_width);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !nodes.iter().any(|child| child.has_flex()) {
|
||||
return children.to_vec();
|
||||
return output;
|
||||
}
|
||||
let space = ((if row { bounds.width } else { bounds.height }) as f64
|
||||
- padding as f64 * 2.0
|
||||
- if row {
|
||||
padding.horizontal()
|
||||
} else {
|
||||
padding.vertical()
|
||||
} as f64
|
||||
- nodes
|
||||
.iter()
|
||||
.map(|child| {
|
||||
if row {
|
||||
child.margin.horizontal()
|
||||
} else {
|
||||
child.margin.vertical()
|
||||
}
|
||||
})
|
||||
.sum::<f32>() as f64
|
||||
- gap as f64 * children.len().saturating_sub(1) as f64)
|
||||
.max(0.0);
|
||||
let bases: Vec<f64> = nodes
|
||||
@@ -305,7 +708,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
.zip(preferred)
|
||||
.map(|(child, size)| {
|
||||
child.flex_policy.basis.unwrap_or_else(|| {
|
||||
if child.flex > 0.0 {
|
||||
if child.flex > 0.0 || main_fill(child, row) {
|
||||
0.0
|
||||
} else if row {
|
||||
size.width
|
||||
@@ -319,7 +722,8 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
.iter()
|
||||
.zip(&bases)
|
||||
.map(|(child, basis)| {
|
||||
basis.clamp(child.flex_policy.min as f64, child.flex_policy.max as f64)
|
||||
let (min, max) = main_limits(child, row);
|
||||
basis.clamp(min as f64, max as f64)
|
||||
})
|
||||
.collect();
|
||||
let free = space - sizes.iter().sum::<f64>();
|
||||
@@ -329,7 +733,11 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
.zip(&bases)
|
||||
.map(|(child, basis)| {
|
||||
if growing {
|
||||
child.flex as f64
|
||||
if main_fill(child, row) && child.flex == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
child.flex as f64
|
||||
}
|
||||
} else {
|
||||
child.flex_policy.shrink as f64 * basis
|
||||
}
|
||||
@@ -338,11 +746,8 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
let limits: Vec<f64> = nodes
|
||||
.iter()
|
||||
.map(|child| {
|
||||
if growing {
|
||||
child.flex_policy.max as f64
|
||||
} else {
|
||||
child.flex_policy.min as f64
|
||||
}
|
||||
let (min, max) = main_limits(child, row);
|
||||
if growing { max as f64 } else { min as f64 }
|
||||
})
|
||||
.collect();
|
||||
let mut active: Vec<usize> = (0..nodes.len())
|
||||
@@ -381,7 +786,6 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut output = children.to_vec();
|
||||
for (size, along) in output.iter_mut().zip(sizes) {
|
||||
if row {
|
||||
size.width = along as f32;
|
||||
@@ -392,6 +796,7 @@ pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[S
|
||||
output
|
||||
}
|
||||
|
||||
/// 根据最终分配尺寸计算子项原点,处理容器方向、对齐、间距与外边距。
|
||||
pub fn arrange_native(
|
||||
node: &UiNode,
|
||||
x: f32,
|
||||
@@ -399,16 +804,78 @@ pub fn arrange_native(
|
||||
bounds: Size,
|
||||
children: &[Size],
|
||||
) -> Result<Vec<(f32, f32)>> {
|
||||
if matches!(node.kind, UiKind::Viewport3D { .. }) {
|
||||
return Ok(node.children().iter().map(|_| (0., 0.)).collect());
|
||||
}
|
||||
if let UiKind::Overlay { .. } = node.kind {
|
||||
let padding = insets(node);
|
||||
let horizontal = match node.main_align {
|
||||
MainAlign::Center => CrossAlign::Center,
|
||||
MainAlign::End => CrossAlign::End,
|
||||
_ => CrossAlign::Start,
|
||||
};
|
||||
return children
|
||||
.iter()
|
||||
.zip(node.children())
|
||||
.map(|(size, child)| {
|
||||
let outer = child.margin.outer(*size);
|
||||
let point = (
|
||||
x + padding.left
|
||||
+ child.margin.left
|
||||
+ horizontal.offset(bounds.width - padding.horizontal(), outer.width),
|
||||
y + padding.top
|
||||
+ child.margin.top
|
||||
+ child
|
||||
.align_self
|
||||
.unwrap_or(node.cross_align)
|
||||
.offset(bounds.height - padding.vertical(), outer.height),
|
||||
);
|
||||
coordinate(point.0)?;
|
||||
coordinate(point.1)?;
|
||||
coordinate(point.0 + size.width)?;
|
||||
coordinate(point.1 + size.height)?;
|
||||
Ok(point)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if matches!(node.kind, UiKind::CanvasViewport { .. }) {
|
||||
let inset = insets(node);
|
||||
return Ok(node
|
||||
.children()
|
||||
.iter()
|
||||
.map(|_| (x + inset.left, y + inset.top))
|
||||
.collect());
|
||||
}
|
||||
if let UiKind::Canvas { positions, .. } | UiKind::Scene2D { positions, .. } = &node.kind {
|
||||
return Ok(positions
|
||||
.iter()
|
||||
.map(|point| (x + point[0], y + point[1]))
|
||||
.collect());
|
||||
}
|
||||
let Some((row, padding, gap)) = container(node) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let occupied = children
|
||||
.iter()
|
||||
.map(|child| if row { child.width } else { child.height })
|
||||
.zip(node.children())
|
||||
.map(|(child, node)| {
|
||||
if row {
|
||||
child.width + node.margin.horizontal()
|
||||
} else {
|
||||
child.height + node.margin.vertical()
|
||||
}
|
||||
})
|
||||
.sum::<f32>()
|
||||
+ gap * children.len().saturating_sub(1) as f32;
|
||||
let available = if row { bounds.width } else { bounds.height };
|
||||
let spare = (available - padding * 2.0 - occupied).max(0.0);
|
||||
let spare = (available
|
||||
- if row {
|
||||
padding.horizontal()
|
||||
} else {
|
||||
padding.vertical()
|
||||
}
|
||||
- occupied)
|
||||
.max(0.0);
|
||||
let offset = match node.main_align {
|
||||
MainAlign::Center => spare / 2.0,
|
||||
MainAlign::End => spare,
|
||||
@@ -419,39 +886,77 @@ pub fn arrange_native(
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut cursor = padding + offset;
|
||||
let mut cursor = if row { padding.left } else { padding.top } + offset;
|
||||
let mut points = Vec::with_capacity(children.len());
|
||||
for child in children {
|
||||
for (index, child) in children.iter().enumerate() {
|
||||
let child_node = &node.children()[index];
|
||||
let margin = child_node.margin;
|
||||
let align = child_node.align_self.unwrap_or(node.cross_align);
|
||||
let cross = align.offset(
|
||||
if row {
|
||||
bounds.height - padding.vertical()
|
||||
} else {
|
||||
bounds.width - padding.horizontal()
|
||||
},
|
||||
if row {
|
||||
child.height + margin.vertical()
|
||||
} else {
|
||||
child.width + margin.horizontal()
|
||||
},
|
||||
);
|
||||
let point = if row {
|
||||
(x + cursor, y + padding)
|
||||
(
|
||||
x + cursor + margin.left,
|
||||
y + padding.top + cross + margin.top,
|
||||
)
|
||||
} else {
|
||||
(x + padding, y + cursor)
|
||||
(
|
||||
x + padding.left + cross + margin.left,
|
||||
y + cursor + margin.top,
|
||||
)
|
||||
};
|
||||
bounded(point.0)?;
|
||||
bounded(point.1)?;
|
||||
bounded(point.0 + child.width)?;
|
||||
bounded(point.1 + child.height)?;
|
||||
coordinate(point.0)?;
|
||||
coordinate(point.1)?;
|
||||
coordinate(point.0 + child.width)?;
|
||||
coordinate(point.1 + child.height)?;
|
||||
points.push(point);
|
||||
cursor += if row { child.width } else { child.height };
|
||||
cursor += if row {
|
||||
margin.horizontal()
|
||||
} else {
|
||||
margin.vertical()
|
||||
};
|
||||
cursor += gap + distributed_gap;
|
||||
}
|
||||
Ok(points)
|
||||
}
|
||||
|
||||
impl LayoutRuntime {
|
||||
/// 从页面模式和当前视口生成根节点约束;无视口时使用明确的无界轴哨兵。
|
||||
pub fn root_constraints(&self) -> [f32; 4] {
|
||||
self.page_mode.map_or_else(
|
||||
|| root_constraints(self.viewport),
|
||||
|mode| mode.constraints(self.viewport),
|
||||
)
|
||||
}
|
||||
/// 撤销指定业务组件的测量缓存,下一次遇到该节点重新求值。
|
||||
pub fn invalidate(&mut self, block: &str) {
|
||||
self.sizes.remove(block);
|
||||
}
|
||||
/// 提高新 Mount ID 的分配下限,避免取消/重试后复用已经暴露过的出现身份。
|
||||
pub fn reserve_identity_floor(&mut self, floor: u64) {
|
||||
self.next_id = self.next_id.max(floor);
|
||||
}
|
||||
/// 返回出现 ID 的当前分配高水位,供桥接层在取消/重试后继续保持唯一性。
|
||||
pub fn identity_high_watermark(&self) -> u64 {
|
||||
self.next_id
|
||||
}
|
||||
|
||||
/// 清空结构出现身份表;业务对象和源状态不因此被复制。
|
||||
pub fn clear_occurrences(&mut self) {
|
||||
self.ids.clear();
|
||||
}
|
||||
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
|
||||
pub fn frame(&mut self, root: &UiNode) -> Result<Frame> {
|
||||
// Stage all cache/identity changes; a rejected frame leaves the last
|
||||
// valid occurrence registry intact and never publishes partial geometry.
|
||||
@@ -462,6 +967,7 @@ impl LayoutRuntime {
|
||||
}
|
||||
|
||||
/// Allocate stable occurrence identities without measuring or publishing geometry.
|
||||
/// 先建立稳定的出现身份和父子关系,但不把此阶段当成最终测量结果。
|
||||
pub fn structure(&mut self, root: &UiNode) -> Result<Frame> {
|
||||
let mut next = self.clone();
|
||||
let frame = next.build(root, true)?;
|
||||
@@ -469,6 +975,7 @@ impl LayoutRuntime {
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
/// 以形状、子项尺寸和约束查缓存;未命中才执行测量并更新本轮统计。
|
||||
pub fn measure_node(
|
||||
&mut self,
|
||||
node: &UiNode,
|
||||
@@ -489,13 +996,14 @@ impl LayoutRuntime {
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// 迭代展开出现树、计算约束和几何、产生绘制命令;结构准备与完整布局共用此驱动。
|
||||
fn build(&mut self, root: &UiNode, structural: bool) -> Result<Frame> {
|
||||
self.measured = 0;
|
||||
self.arranged = 0;
|
||||
self.settle_passes = usize::from(!structural);
|
||||
self.slices = usize::from(!structural);
|
||||
let mut items = Vec::<Item<'_>>::new();
|
||||
let mut work = vec![(root, None::<usize>, 0, root_constraints(self.viewport))];
|
||||
let mut work = vec![(root, None::<usize>, 0, self.root_constraints())];
|
||||
let mut used = HashSet::new();
|
||||
while let Some((node, parent, slot, constraints)) = work.pop() {
|
||||
if items.len() >= MAX_LAYOUT_VISITS {
|
||||
@@ -533,12 +1041,14 @@ impl LayoutRuntime {
|
||||
id,
|
||||
constraints,
|
||||
});
|
||||
for (child, slot) in node.children().iter().zip(&node.slots).rev() {
|
||||
for (child_index, (child, slot)) in
|
||||
node.children().iter().zip(&node.slots).enumerate().rev()
|
||||
{
|
||||
work.push((
|
||||
child.as_ref(),
|
||||
Some(index),
|
||||
*slot,
|
||||
child_constraints(node, constraints),
|
||||
initial_child_constraints(node, child, constraints, child_index),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -568,11 +1078,20 @@ impl LayoutRuntime {
|
||||
self.arranged += usize::from(!structural);
|
||||
let (x, y) = points[index];
|
||||
let size = sizes[index];
|
||||
bounded(x)?;
|
||||
bounded(y)?;
|
||||
bounded(x + size.width)?;
|
||||
bounded(y + size.height)?;
|
||||
coordinate(x)?;
|
||||
coordinate(y)?;
|
||||
coordinate(x + size.width)?;
|
||||
coordinate(y + size.height)?;
|
||||
mounts.push(MountGeometry {
|
||||
panel: None,
|
||||
canvas_viewport: matches!(item.node.kind, UiKind::CanvasViewport { .. }),
|
||||
scene3d_viewport: matches!(item.node.kind, UiKind::Viewport3D { .. }),
|
||||
camera_controls: matches!(
|
||||
item.node.kind,
|
||||
UiKind::CanvasViewport { .. } | UiKind::Viewport3D { controls: true, .. }
|
||||
),
|
||||
viewport_clip: None,
|
||||
view: None,
|
||||
id: item.id,
|
||||
clip: None,
|
||||
scroll: None,
|
||||
@@ -586,8 +1105,8 @@ impl LayoutRuntime {
|
||||
height: size.height,
|
||||
interactive: item.node.interactive,
|
||||
});
|
||||
if !structural && let Some(command) = paint_native(item.node, x, y, size) {
|
||||
commands.push(command);
|
||||
if !structural {
|
||||
commands.extend(paint_commands(item.node, x, y, size));
|
||||
}
|
||||
if structural {
|
||||
continue;
|
||||
@@ -626,10 +1145,12 @@ impl LayoutRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在临时 LayoutRuntime 中生成完整几何,成功后才替换现有缓存和身份状态。
|
||||
pub fn frame(root: &UiNode) -> Result<Frame> {
|
||||
LayoutRuntime::default().frame(root)
|
||||
}
|
||||
|
||||
/// 为指定逻辑视口建立临时布局运行时并求完整帧。
|
||||
pub fn frame_with_viewport(root: &UiNode, viewport: Size) -> Result<Frame> {
|
||||
LayoutRuntime {
|
||||
viewport: Some(viewport),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
//! 渲染模块入口。排版、源视觉协议、相机投影和最终光栅化各自独立,通过不可变帧衔接。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
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;
|
||||
pub(crate) mod scene3d;
|
||||
pub(crate) mod scroll;
|
||||
pub(crate) mod software_renderer;
|
||||
pub(crate) mod source_hit;
|
||||
pub(crate) mod source_layout;
|
||||
pub(crate) mod source_paint;
|
||||
pub(crate) mod text;
|
||||
pub(crate) mod viewport;
|
||||
|
||||
@@ -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,
|
||||
@@ -40,9 +46,11 @@ pub(crate) fn number(
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
/// 将原生布局计算结果封装为协议需要的托管 Value。
|
||||
pub(crate) fn value(value: f32) -> Value {
|
||||
Value::Number((value as f64).into())
|
||||
}
|
||||
/// 新建指定名义类型的托管记录,并逐项设置字段,作为视觉协议的结果返回。
|
||||
pub(crate) fn record(
|
||||
cx: &mut NativeContext<'_>,
|
||||
desktop: ObjectRef,
|
||||
@@ -56,6 +64,7 @@ pub(crate) fn record(
|
||||
}
|
||||
Ok(object)
|
||||
}
|
||||
/// 在约束提供的视口内执行原生布局,从根出现提取宽高,再按协议约束限制结果。
|
||||
fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size, VmError> {
|
||||
let frame = layout::frame_with_viewport(
|
||||
node,
|
||||
@@ -75,6 +84,7 @@ fn size(node: &crate::ui_bridge::UiNode, constraints: [f32; 4]) -> Result<Size,
|
||||
})
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
|
||||
enum Query {
|
||||
Measure,
|
||||
Arrange,
|
||||
@@ -82,12 +92,14 @@ 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,
|
||||
@@ -122,13 +134,16 @@ pub(crate) fn is_intrinsic(
|
||||
}
|
||||
|
||||
impl NativeCallable for Method {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(3))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [owner, context, input] = args else {
|
||||
return Err(invalid("visual method expects a Mount and an input value"));
|
||||
@@ -202,8 +217,8 @@ impl NativeCallable for Method {
|
||||
)?)
|
||||
}
|
||||
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 {
|
||||
@@ -214,13 +229,20 @@ impl NativeCallable for Method {
|
||||
} 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,
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -268,8 +290,25 @@ 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 }) {
|
||||
for command in layout::paint_commands(&node, x, y, Size { width, height }) {
|
||||
let fields = match command {
|
||||
RenderCommand::Scene3D {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
view,
|
||||
} => {
|
||||
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,
|
||||
@@ -328,15 +367,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())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(if self.circle {
|
||||
4
|
||||
@@ -345,6 +387,7 @@ impl NativeCallable for PointMethod {
|
||||
}))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
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"));
|
||||
@@ -395,6 +438,7 @@ impl NativeCallable for PointMethod {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 旧 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]
|
||||
Window,
|
||||
Vertical,
|
||||
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,
|
||||
pub pan: Point,
|
||||
#[serde(default = "unit")]
|
||||
pub zoom: f32,
|
||||
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 {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(mode: Mode) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
zoom: 1.0,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 把页面视图坐标反算为旧 Page 相机下的世界坐标。
|
||||
pub fn to_world(&self, point: Point) -> Point {
|
||||
Point::new(
|
||||
point.x / self.zoom + self.pan.x,
|
||||
point.y / self.zoom + self.pan.y,
|
||||
)
|
||||
}
|
||||
|
||||
/// 把旧页面世界坐标变换到可见视图坐标。
|
||||
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) {
|
||||
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]
|
||||
fn camera_inverse_and_cursor_anchored_zoom_preserve_world_position() {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+44
-52
@@ -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,7 +23,7 @@ pub fn build_scene(
|
||||
document: &RenderDocument,
|
||||
developer_tools_state: &mut DeveloperToolsState,
|
||||
activity: &Activity,
|
||||
tab_chrome: crate::tabs::Chrome,
|
||||
sidebar: &mut crate::sidebar::State,
|
||||
) -> DisplayScene {
|
||||
let tool = document
|
||||
.frame
|
||||
@@ -27,69 +33,54 @@ 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;
|
||||
let content = Rect::new(
|
||||
left * scale,
|
||||
0.0,
|
||||
(width as f32 - left * scale).max(0.0),
|
||||
height as f32,
|
||||
);
|
||||
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());
|
||||
page.translate(Point::new(
|
||||
(left - surface.pan.x * zoom) * scale,
|
||||
-surface.pan.y * zoom * scale,
|
||||
));
|
||||
let mut display_list = DisplayList::default();
|
||||
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 +89,7 @@ pub fn build_scene(
|
||||
}
|
||||
}
|
||||
|
||||
/// 整窗合成结果:显示列表与同帧管理交互几何一起返回。
|
||||
pub struct DisplayScene {
|
||||
pub display_list: DisplayList,
|
||||
pub layout: SceneLayout,
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
//! 有界 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 {
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
pub fn valid(&self) -> bool {
|
||||
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 {
|
||||
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)]
|
||||
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 {
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
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 {
|
||||
/// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。
|
||||
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;
|
||||
}
|
||||
let _guard = Guard;
|
||||
let color_valid = |color: [f32; 3]| {
|
||||
color
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && (0.0..=1.0).contains(value))
|
||||
};
|
||||
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())
|
||||
&& 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> {
|
||||
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;
|
||||
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) {
|
||||
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;
|
||||
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> {
|
||||
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")?;
|
||||
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] {
|
||||
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();
|
||||
let mut previous = *polygon.last().unwrap();
|
||||
let mut before = distance(previous);
|
||||
for current in polygon {
|
||||
let after = distance(current);
|
||||
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 { .. });
|
||||
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];
|
||||
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;
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
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: 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)
|
||||
};
|
||||
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;
|
||||
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]
|
||||
}
|
||||
};
|
||||
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]
|
||||
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);
|
||||
}
|
||||
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]
|
||||
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. };
|
||||
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]
|
||||
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]
|
||||
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();
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! 3D 数学基础:向量、仿射矩阵、摄像机基向量、投影射线和三角形相交。
|
||||
//! 矩阵组合顺序决定局部变换如何进入父坐标系;接近退化的逆矩阵、法线和射线使用 Option 拒绝无效计算。
|
||||
//!
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::{Add, Mul, Sub};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
/// 三个有限浮点分量的数学向量,不是托管 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> {
|
||||
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> {
|
||||
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]);
|
||||
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.,
|
||||
]);
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
Perspective { fov: f32 },
|
||||
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> {
|
||||
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 })
|
||||
}
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
pub fn valid(self) -> bool {
|
||||
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 {
|
||||
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()?;
|
||||
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()?,
|
||||
})
|
||||
}
|
||||
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])> {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
let distance = ac.dot(q) * inverse;
|
||||
(distance >= 0.).then_some((distance, [1. - u - v, u, v]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 连接 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 {
|
||||
pub owner: u64,
|
||||
pub key: u64,
|
||||
pub view: Arc<View>,
|
||||
pub rect: [f32; 4],
|
||||
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;
|
||||
}
|
||||
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);
|
||||
let denominator = normal.dot(ray.direction);
|
||||
if denominator.abs() < 1e-7 {
|
||||
return None;
|
||||
}
|
||||
let distance = (center - ray.origin).dot(normal) / denominator;
|
||||
if !distance.is_finite() || distance <= 0. {
|
||||
return None;
|
||||
}
|
||||
let local = mesh
|
||||
.world
|
||||
.inverse()?
|
||||
.point(ray.origin + ray.direction * distance);
|
||||
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;
|
||||
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);
|
||||
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();
|
||||
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())
|
||||
{
|
||||
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(),
|
||||
};
|
||||
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();
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 不可变世界空间 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));
|
||||
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> {
|
||||
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),
|
||||
] {
|
||||
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 {
|
||||
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 {
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub fn build(meshes: &[Mesh]) -> Self {
|
||||
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();
|
||||
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
|
||||
}
|
||||
/// 检查索引覆盖的网格数量是否与场景相符;缺失缓存时允许重建。
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
output.sort_unstable();
|
||||
output
|
||||
}
|
||||
}
|
||||
+61
-12
@@ -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,14 +42,17 @@ 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,6 +63,7 @@ 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<()> {
|
||||
let mut nodes = HashMap::new();
|
||||
let mut work = vec![root];
|
||||
@@ -76,17 +86,25 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut 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();
|
||||
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 {
|
||||
@@ -99,27 +117,27 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
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,
|
||||
};
|
||||
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
|
||||
},
|
||||
@@ -128,6 +146,19 @@ pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame
|
||||
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
|
||||
@@ -149,13 +180,18 @@ 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
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 +202,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,6 +211,7 @@ 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(
|
||||
@@ -189,6 +227,7 @@ pub fn coordinate(value: &Value) -> Result<f32, VmError> {
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
/// 更新指定出现的滚动偏移,并钳制到本轴实际可滚动的内容范围。
|
||||
pub fn set(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -222,6 +261,7 @@ pub fn set(
|
||||
cx.replace_payload(owner, mount)?;
|
||||
cx.set_attr(desktop, "scrollRequested", Value::Bool(true))
|
||||
}
|
||||
/// 沿祖先滚动容器调整偏移,让目标出现进入可见范围。
|
||||
pub fn into_view(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -246,6 +286,14 @@ 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 {
|
||||
@@ -267,6 +315,7 @@ 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
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
//! 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。
|
||||
//! RenderScope 约束递归深度;累计像素预算限制嵌套 3D 和世界 UI 的中间表面分配。错误向上传递,不把半幅结果当成成功帧呈现。
|
||||
//!
|
||||
use crate::{
|
||||
display_list::{Color, DisplayList, DrawCommand},
|
||||
geometry::{Point, Rect},
|
||||
text::{self, TextStyle},
|
||||
};
|
||||
|
||||
/// 显示列表的 CPU 执行入口,错误在原生 present 前向上传递。
|
||||
pub struct SoftwareRenderer;
|
||||
|
||||
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();
|
||||
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 {
|
||||
/// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。
|
||||
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> {
|
||||
let _scope = RenderScope::enter()?;
|
||||
let mut canvas = Canvas {
|
||||
pixels: target,
|
||||
width,
|
||||
@@ -18,6 +69,39 @@ impl SoftwareRenderer {
|
||||
|
||||
for command in list.commands() {
|
||||
match command {
|
||||
DrawCommand::Scene3D { rect, view } => {
|
||||
let left = rect.x.max(canvas.clip.x).max(0.).floor() as u32;
|
||||
let top = rect.y.max(canvas.clip.y).max(0.).floor() as u32;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
canvas.pixels[y as usize * width as usize + x as usize] =
|
||||
surface[v as usize * w as usize + u as usize];
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawCommand::PushClip(rect) => {
|
||||
clips.push(canvas.clip);
|
||||
let x = canvas.clip.x.max(rect.x);
|
||||
@@ -66,9 +150,11 @@ impl SoftwareRenderer {
|
||||
} => canvas.draw_text(*origin, value, *style, *color),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 借用目标像素切片并维护当前裁剪,所有像素写入都受该上下文限制。
|
||||
struct Canvas<'a> {
|
||||
pixels: &'a mut [u32],
|
||||
width: u32,
|
||||
@@ -77,10 +163,12 @@ struct Canvas<'a> {
|
||||
}
|
||||
|
||||
impl Canvas<'_> {
|
||||
/// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。
|
||||
fn clear(&mut self, color: Color) {
|
||||
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);
|
||||
@@ -93,6 +181,7 @@ impl Canvas<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 用外轮廓与内轮廓的差绘制边框,避免把边框当成整个矩形填充。
|
||||
fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, width: f32, color: Color) {
|
||||
let bounds = self.clamped_bounds(rect);
|
||||
let width = width.max(1.0).min(rect.width / 2.0).min(rect.height / 2.0);
|
||||
@@ -118,14 +207,22 @@ impl Canvas<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把文字原点转换为局部裁剪区域,交给有界字形绘制,再混合到目标缓冲。
|
||||
fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) {
|
||||
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;
|
||||
@@ -141,12 +238,24 @@ 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) {
|
||||
(
|
||||
rect.x.max(self.clip.x).max(0.0).floor() as i32,
|
||||
@@ -162,6 +271,7 @@ impl Canvas<'_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// 在画布与当前裁剪范围内进行透明度混合,防止文字/图形写出目标缓冲。
|
||||
fn blend_pixel(&mut self, x: i32, y: i32, source: Color) {
|
||||
if x < 0
|
||||
|| y < 0
|
||||
@@ -195,6 +305,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;
|
||||
@@ -213,6 +324,7 @@ fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
|
||||
dx * dx + dy * dy <= radius * radius
|
||||
}
|
||||
|
||||
/// 将颜色通道打包成目标缓冲的整数像素表示。
|
||||
fn pack(color: Color) -> u32 {
|
||||
(color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32
|
||||
}
|
||||
@@ -245,7 +357,7 @@ 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 {
|
||||
@@ -270,7 +382,7 @@ 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);
|
||||
}
|
||||
@@ -286,7 +398,7 @@ 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));
|
||||
assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128));
|
||||
assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255))));
|
||||
@@ -299,7 +411,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 通过真正的源/原生 hitTest 协议确定指针目标。
|
||||
//! 按逆绘制顺序筛选候选,并在同一只读 VM 调用与预算中比较按下、释放目标;透明或未命中的子项可以继续回退。
|
||||
//!
|
||||
//! Resolve pointer targets in paint order through source/native hitTest methods.
|
||||
//! Both press and release selectors share one read-only VM call and budget.
|
||||
use crate::{mount::Mount, ui_bridge::attach_desktop_type};
|
||||
@@ -7,12 +10,15 @@ use endor_core::{
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 验证 hitTest 返回的是 Bool,避免把任意真值隐式当成命中。
|
||||
struct Boolean;
|
||||
impl NativeCallable for Boolean {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Bool(value)] = args else {
|
||||
return Err(VmError::InvalidArgument("hitTest must return Bool"));
|
||||
@@ -21,12 +27,15 @@ impl NativeCallable for Boolean {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 比较按下与释放的选择结果,只有同一目标才能继续点击分发。
|
||||
struct SameTarget;
|
||||
impl NativeCallable for SameTarget {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(2))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let value = match args {
|
||||
[Value::Object(first), Value::Object(second)] if first == second => {
|
||||
@@ -37,6 +46,7 @@ impl NativeCallable for SameTarget {
|
||||
Ok(NativeResult::Return(value))
|
||||
}
|
||||
}
|
||||
/// 构造只读命中选择驱动所需的托管代码与函数对象。
|
||||
fn code(
|
||||
cx: &mut NativeContext<'_>,
|
||||
instructions: Vec<Instruction>,
|
||||
@@ -52,6 +62,7 @@ fn code(
|
||||
let globals = cx.new_dict()?;
|
||||
cx.new_function("pointer hitTest", code, globals)
|
||||
}
|
||||
/// 按逆绘制顺序构造候选 hitTest 调用,先进行几何与局部坐标筛选。
|
||||
fn selector(
|
||||
cx: &mut NativeContext<'_>,
|
||||
candidates: &[WeakObjectRef],
|
||||
@@ -79,8 +90,12 @@ fn selector(
|
||||
continue;
|
||||
}
|
||||
let geometry = &target.geometry;
|
||||
let x = point[0] - geometry.x;
|
||||
let y = point[1] - geometry.y;
|
||||
let point = crate::geometry::Point::new(point[0], point[1]);
|
||||
let Some(point) = geometry.logical_point(point) else {
|
||||
continue;
|
||||
};
|
||||
let x = point.x - geometry.x;
|
||||
let y = point.y - geometry.y;
|
||||
if x < 0.0 || y < 0.0 || x > geometry.width || y > geometry.height {
|
||||
continue;
|
||||
}
|
||||
@@ -133,6 +148,7 @@ fn selector(
|
||||
code(cx, instructions, constants, CodeLayout::default())
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 在一个只读调用中准备两次指针选择所需的候选与局部坐标。
|
||||
struct Prepare {
|
||||
candidates: Vec<WeakObjectRef>,
|
||||
press: [f32; 2],
|
||||
@@ -140,10 +156,12 @@ struct Prepare {
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Prepare {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let down = selector(cx, &self.candidates, self.press, self.desktop)?;
|
||||
let up = selector(cx, &self.candidates, self.release, self.desktop)?;
|
||||
@@ -188,6 +206,7 @@ impl NativeCallable for Prepare {
|
||||
}
|
||||
}
|
||||
/// Returned Mount owns one root. No-hit and mismatched targets return None.
|
||||
/// 解析输入所指向的实际目标,避免把包装身份与底层数据混淆。
|
||||
pub fn resolve(
|
||||
vm: &mut Vm,
|
||||
desktop: ObjectRef,
|
||||
|
||||
+69
-14
@@ -1,3 +1,7 @@
|
||||
//! 源 measure/arrange 协议的分片布局驱动器。自底向上的测量与自顶向下的安排共享一个只读 VM 调用域。
|
||||
//! Plan 只保存派生几何和求解过程;Measurement 缓存记录真实读取依赖。已有业务对象在只读阶段不能被修改。
|
||||
//! 较大树可在节点/指令量子之间挂起。Work 拥有尚未发布的执行状态,必须 resume 或 cancel;完成后才把 Draft 交给桥接层提交。
|
||||
//!
|
||||
//! Bottom-up measurement and top-down arrangement share one read-only VM call.
|
||||
//! The host scratch plan contains derived data only, never a second business heap.
|
||||
use crate::{
|
||||
@@ -22,9 +26,11 @@ use std::{
|
||||
// same per-occurrence VM driver so even intrinsic work can yield between nodes.
|
||||
const NATIVE_FAST_PATH_MAX_NODES: usize = 64;
|
||||
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
/// 读取当前 desktop 的类型字典,为布局协议结果提供名义类型检查。
|
||||
fn desktop_type_registry(cx: &NativeContext<'_>, desktop: ObjectRef) -> Result<ObjectRef, VmError> {
|
||||
match cx.store().get_attr(desktop, "types")? {
|
||||
Value::Object(object) => Ok(object),
|
||||
@@ -32,6 +38,7 @@ fn desktop_type_registry(cx: &NativeContext<'_>, desktop: ObjectRef) -> Result<O
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
/// 一次源测量的缓存输入、输出和真实读取依赖;不同约束或依赖版本不能复用。
|
||||
struct Measurement {
|
||||
owner: ObjectRef,
|
||||
method: ObjectRef,
|
||||
@@ -42,12 +49,14 @@ struct Measurement {
|
||||
}
|
||||
/// Borrowed identities and versions only: this cache adds no managed roots.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
/// 按 Mount 弱身份保存源测量结果,不为业务对象增加托管根。
|
||||
pub struct Cache {
|
||||
entries: HashMap<WeakObjectRef, Measurement>,
|
||||
pub hits: usize,
|
||||
pub misses: usize,
|
||||
}
|
||||
impl Cache {
|
||||
/// 从测量缓存依赖中移除宿主私有 desktop 记账对象,防止内部同步造成自我失效。
|
||||
pub(crate) fn exclude_host_desktops(&mut self, desktops: &HashSet<ObjectRef>) {
|
||||
for entry in self.entries.values_mut() {
|
||||
entry
|
||||
@@ -55,10 +64,12 @@ impl Cache {
|
||||
.retain(|read| !desktops.contains(&read.object()));
|
||||
}
|
||||
}
|
||||
/// 移除受影响 owner 的源测量缓存;其他出现继续按其读取版本检查。
|
||||
pub fn invalidate(&mut self, owners: &HashSet<ObjectRef>) {
|
||||
self.entries
|
||||
.retain(|_, entry| !owners.contains(&entry.owner));
|
||||
}
|
||||
/// 清空本对象维护的当前内容或缓存,后续状态由调用方重新建立。
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.hits = 0;
|
||||
@@ -66,6 +77,7 @@ impl Cache {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 一个等待源测量返回的收集项,记录本次方法、约束和子项输入。
|
||||
struct Pending {
|
||||
token: usize,
|
||||
owner: ObjectRef,
|
||||
@@ -75,10 +87,13 @@ struct Pending {
|
||||
key: WeakObjectRef,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 未发布的布局求解工作区,包含出现树、期望尺寸、分配边界、收敛记录和缓存。
|
||||
struct Plan {
|
||||
// 使用出现索引保存树和几何,不为每个位置建立第二份业务对象。
|
||||
nodes: Vec<Arc<UiNode>>,
|
||||
children: Vec<Vec<usize>>,
|
||||
sizes: Vec<Size>,
|
||||
// preferred 是测量偏好,bounds 是父级实际分配;换行/flex 会使两者不同。
|
||||
preferred: Vec<Option<Size>>,
|
||||
bounds: Vec<Option<[f32; 4]>>,
|
||||
runtime: LayoutRuntime,
|
||||
@@ -92,16 +107,19 @@ struct Plan {
|
||||
native_arrange: Vec<bool>,
|
||||
custom_measure: Vec<bool>,
|
||||
measured_limits: Vec<[Option<f32>; 2]>,
|
||||
// 保存几何状态用于检测收敛和重复状态,防止相互依赖布局无限循环。
|
||||
previous: Option<Vec<u32>>,
|
||||
seen: BTreeSet<Vec<u32>>,
|
||||
}
|
||||
type Shared = Arc<Mutex<Plan>>;
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 源视觉调用当前处于测量还是安排阶段,决定调用方法与结果解码。
|
||||
enum Phase {
|
||||
Measure,
|
||||
Arrange,
|
||||
}
|
||||
impl Phase {
|
||||
/// 把当前布局阶段映射为 measure 或 arrange 方法名称。
|
||||
fn method(self) -> &'static str {
|
||||
match self {
|
||||
Self::Measure => "measure",
|
||||
@@ -110,6 +128,7 @@ impl Phase {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 为某次出现准备一个 measure/arrange 调用,选择原生快速结果或真正的源覆盖。
|
||||
struct Step {
|
||||
plan: Shared,
|
||||
index: usize,
|
||||
@@ -118,10 +137,12 @@ struct Step {
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Step {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mount = cx.store().payload::<Mount>(self.mount, "Mount")?.clone();
|
||||
let definition = cx
|
||||
@@ -165,7 +186,11 @@ impl NativeCallable for Step {
|
||||
Some(
|
||||
plan.runtime
|
||||
.measure_node(&node, children.clone(), constraints)
|
||||
.map_err(|_| invalid("native measurement exceeds layout limits"))?,
|
||||
.map_err(|error| {
|
||||
error.downcast_ref::<VmError>().cloned().unwrap_or_else(|| {
|
||||
invalid("native measurement exceeds layout limits")
|
||||
})
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
if !intrinsic {
|
||||
@@ -211,8 +236,7 @@ impl NativeCallable for Step {
|
||||
if matches!(self.phase, Phase::Arrange) {
|
||||
plan.runtime.arranged += 1;
|
||||
plan.native_arrange[self.index] = intrinsic;
|
||||
plan.custom_arrange[self.index] =
|
||||
!intrinsic || node.children().iter().any(|child| child.has_flex());
|
||||
plan.custom_arrange[self.index] = !intrinsic || node.allocates_children();
|
||||
}
|
||||
(node, children, preferred, bounds, native_size)
|
||||
};
|
||||
@@ -290,6 +314,7 @@ impl NativeCallable for Step {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 接收并检查源协议返回结果,将其合入 Plan,而不是直接写成发布帧。
|
||||
struct Collect {
|
||||
plan: Shared,
|
||||
index: usize,
|
||||
@@ -297,10 +322,12 @@ struct Collect {
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Collect {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let pending = if matches!(self.phase, Phase::Measure) {
|
||||
self.plan
|
||||
@@ -458,8 +485,8 @@ impl NativeCallable for Collect {
|
||||
return Err(invalid("duplicate arrangement slot"));
|
||||
}
|
||||
let bounds = [
|
||||
number(cx, *item, "x", false)?,
|
||||
number(cx, *item, "y", false)?,
|
||||
number(cx, *item, "x", true)?,
|
||||
number(cx, *item, "y", true)?,
|
||||
number(cx, *item, "width", false)?,
|
||||
number(cx, *item, "height", false)?,
|
||||
];
|
||||
@@ -493,14 +520,17 @@ impl NativeCallable for Collect {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 检查本轮布局是否已收敛,必要时安排下一次测量/分配迭代。
|
||||
struct Settle {
|
||||
plan: Shared,
|
||||
mounts: Arc<[WeakObjectRef]>,
|
||||
}
|
||||
impl NativeCallable for Settle {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let repeat = {
|
||||
let mut plan = self
|
||||
@@ -531,8 +561,15 @@ impl NativeCallable for Settle {
|
||||
available,
|
||||
Size { width, height },
|
||||
);
|
||||
for child in children {
|
||||
let mut constraints = base;
|
||||
for (child_index, child) in children.into_iter().enumerate() {
|
||||
let natural = layout::occurrence_constraints(
|
||||
&plan.nodes[index],
|
||||
&plan.nodes[child],
|
||||
base,
|
||||
Size { width, height },
|
||||
child_index,
|
||||
);
|
||||
let mut constraints = natural;
|
||||
if let Some(width) = plan.allocations[child][0] {
|
||||
constraints[0] = width;
|
||||
constraints[2] = width;
|
||||
@@ -554,8 +591,8 @@ impl NativeCallable for Settle {
|
||||
// own replacement measurement. Source arrange
|
||||
// allocations remain authoritative.
|
||||
plan.allocations[child][main] = None;
|
||||
constraints[main] = base[main];
|
||||
constraints[main + 2] = base[main + 2];
|
||||
constraints[main] = natural[main];
|
||||
constraints[main + 2] = natural[main + 2];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -616,15 +653,18 @@ impl NativeCallable for Settle {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 准备当前求解步骤所需的约束和 Mount 临时上下文。
|
||||
struct Prepare {
|
||||
plan: Shared,
|
||||
mounts: Vec<WeakObjectRef>,
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Prepare {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let advance = cx.allocate(ContextNativeFunction::new(
|
||||
"next layout occurrence",
|
||||
@@ -645,6 +685,7 @@ impl NativeCallable for Prepare {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 在同一驱动帧中安排下一项布局工作,使大树能够分片让出。
|
||||
struct Advance {
|
||||
plan: Shared,
|
||||
mounts: Arc<[WeakObjectRef]>,
|
||||
@@ -652,9 +693,11 @@ struct Advance {
|
||||
next: Mutex<usize>,
|
||||
}
|
||||
impl NativeCallable for Advance {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let count = self.mounts.len();
|
||||
let position = {
|
||||
@@ -731,6 +774,7 @@ impl NativeCallable for Advance {
|
||||
|
||||
/// An unpublished layout frame and derived caches. These are merged only after
|
||||
/// the driver completes; they are not another copy of application state.
|
||||
/// 已经完成本阶段、但尚未由桥接层发布的派生结果,包含帧/缓存或真实读取依赖。
|
||||
pub struct Draft {
|
||||
pub frame: Frame,
|
||||
pub runtime: LayoutRuntime,
|
||||
@@ -739,6 +783,7 @@ pub struct Draft {
|
||||
}
|
||||
|
||||
#[must_use = "pending layout work must be resumed or cancelled with its owning VM"]
|
||||
/// 本次推进处于挂起还是完成;挂起工作必须继续恢复或显式取消。
|
||||
pub enum Progress {
|
||||
Pending(Work),
|
||||
Complete(Draft),
|
||||
@@ -746,6 +791,7 @@ pub enum Progress {
|
||||
|
||||
/// Host-owned continuation. No borrowed frame/cache references cross a pause.
|
||||
/// The prepare handle owns one root until completion or explicit cancellation.
|
||||
/// 拥有未完成源调用及临时根的工作句柄;丢弃 Rust 值不能替代 VM 内的取消。
|
||||
pub struct Work {
|
||||
state: Draft,
|
||||
plan: Shared,
|
||||
@@ -755,12 +801,14 @@ pub struct Work {
|
||||
}
|
||||
|
||||
impl Work {
|
||||
/// 恢复当前 Work 拥有的 VM 续体,继续同一轮求解而不是重放 measure/arrange。
|
||||
pub fn resume(mut self, vm: &mut Vm, slice: usize) -> Result<Progress> {
|
||||
self.slices += 1;
|
||||
let progress = vm.resume_read_only_call(slice);
|
||||
self.accept(vm, progress)
|
||||
}
|
||||
|
||||
/// 显式结束布局续体和只读域,释放本次尚未提交的临时根。
|
||||
pub fn cancel(self, vm: &mut Vm) -> Result<()> {
|
||||
let cancelled = vm.cancel_read_only_call();
|
||||
let released = vm.release(self.prepare);
|
||||
@@ -769,6 +817,7 @@ impl Work {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 接收一次续体进度,挂起时保留工作,完成时将派生结果交给收尾阶段。
|
||||
fn accept(
|
||||
self,
|
||||
vm: &mut Vm,
|
||||
@@ -795,6 +844,7 @@ impl Work {
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立只读布局 Plan 与托管驱动函数;小型原生树可走直接路径,大树保留分片推进能力。
|
||||
pub fn start(
|
||||
vm: &mut Vm,
|
||||
desktop: ObjectRef,
|
||||
@@ -844,7 +894,7 @@ pub fn start(
|
||||
let mut nodes = Vec::new();
|
||||
let mut work = vec![root.clone()];
|
||||
while let Some(node) = work.pop() {
|
||||
custom |= node.has_flex();
|
||||
custom |= node.has_flex() || node.allocates_children();
|
||||
work.extend(node.children().iter().rev().cloned());
|
||||
nodes.push(node);
|
||||
}
|
||||
@@ -876,11 +926,15 @@ pub fn start(
|
||||
children[indices[&parent]].push(index);
|
||||
}
|
||||
}
|
||||
let mut constraints = vec![layout::root_constraints(runtime.viewport); nodes.len()];
|
||||
let mut constraints = vec![runtime.root_constraints(); nodes.len()];
|
||||
for index in 0..nodes.len() {
|
||||
let next = layout::child_constraints(&nodes[index], constraints[index]);
|
||||
for child in &children[index] {
|
||||
constraints[*child] = next;
|
||||
for (child_index, child) in children[index].iter().enumerate() {
|
||||
constraints[*child] = layout::initial_child_constraints(
|
||||
&nodes[index],
|
||||
&nodes[*child],
|
||||
constraints[index],
|
||||
child_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
let count = nodes.len();
|
||||
@@ -931,6 +985,7 @@ pub fn start(
|
||||
work.accept(vm, progress)
|
||||
}
|
||||
|
||||
/// 验证求解已收敛,收集最终几何、读取依赖和缓存,返回尚待桥接层提交的 Draft。
|
||||
fn finish(
|
||||
vm: &mut Vm,
|
||||
mut work: Work,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 执行源 paint 覆盖并解码受检查的 PaintCommand。
|
||||
//! 所有绘制回调位于只读域;临时返回对象可分配,但已有模型不可写。命令数量、类型、坐标和颜色在合入帧前检查。
|
||||
//! 源命令与原生命令按 Mount 绘制顺序合并,随后装配世界 UI 面板。挂起绘制同样需要显式恢复或取消。
|
||||
//!
|
||||
//! Source paint dispatch produces checked immutable commands. One read-only VM
|
||||
//! resumable call sequences all overrides, sharing one instruction/frame budget.
|
||||
use crate::{
|
||||
@@ -17,9 +21,11 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
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)
|
||||
@@ -27,6 +33,7 @@ fn object(value: Value) -> Result<ObjectRef, VmError> {
|
||||
Err(invalid("paint must return managed objects"))
|
||||
}
|
||||
}
|
||||
/// 把当前源值或文本选择解码成此调用需要的文本表示。
|
||||
fn text(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<String, VmError> {
|
||||
if let Value::Text(value) = store.get_attr(object, name)? {
|
||||
Ok(value)
|
||||
@@ -34,6 +41,7 @@ fn text(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<String, Vm
|
||||
Err(invalid("paint field must be text"))
|
||||
}
|
||||
}
|
||||
/// 在源 Value 与所需数值表示之间转换,并沿用本调用的有限值/范围检查。
|
||||
fn number(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<f32, VmError> {
|
||||
let Value::Number(value) = store.get_attr(object, name)? else {
|
||||
return Err(invalid("paint field must be a Number"));
|
||||
@@ -50,6 +58,7 @@ fn number(store: &ObjectStore, object: ObjectRef, name: &str) -> Result<f32, VmE
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
/// 从当前 desktop 的类型注册表取得绘制结果必须符合的托管类型。
|
||||
fn named_type(store: &ObjectStore, desktop: ObjectRef, name: &str) -> Result<ObjectRef, VmError> {
|
||||
let registry = object(store.get_attr(desktop, "types")?)?;
|
||||
object(
|
||||
@@ -58,6 +67,7 @@ fn named_type(store: &ObjectStore, desktop: ObjectRef, name: &str) -> Result<Obj
|
||||
.ok_or(invalid("missing paint type"))?,
|
||||
)
|
||||
}
|
||||
/// 把源方法返回的托管绘制值解码成受限 Rust 命令,检查名义类型、字段和绘制预算。
|
||||
fn decode(
|
||||
store: &ObjectStore,
|
||||
command: ObjectRef,
|
||||
@@ -68,9 +78,26 @@ fn decode(
|
||||
}
|
||||
let x = number(store, command, "x")?;
|
||||
let y = number(store, command, "y")?;
|
||||
let color = text(store, command, "color")?;
|
||||
let kind = text(store, command, "kind")?;
|
||||
let color = if kind == "scene3d" {
|
||||
String::new()
|
||||
} else {
|
||||
text(store, command, "color")?
|
||||
};
|
||||
let result = match kind.as_str() {
|
||||
"scene3d" => {
|
||||
let snapshot = object(store.get_attr(command, "scene")?)?;
|
||||
RenderCommand::Scene3D {
|
||||
x,
|
||||
y,
|
||||
width: number(store, command, "width")?,
|
||||
height: number(store, command, "height")?,
|
||||
view: store
|
||||
.payload::<crate::scene3d::PaintView>(snapshot, "immutable 3D paint snapshot")?
|
||||
.0
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
"roundedRect" | "strokeRoundedRect" => {
|
||||
let width = number(store, command, "width")?;
|
||||
let height = number(store, command, "height")?;
|
||||
@@ -133,22 +160,26 @@ fn decode(
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// 按出现 ID 收集已检查的源绘制覆盖,随后与原生默认命令合并。
|
||||
struct PaintOutput {
|
||||
commands: BTreeMap<u64, Vec<RenderCommand>>,
|
||||
count: usize,
|
||||
}
|
||||
type Output = Arc<Mutex<PaintOutput>>;
|
||||
#[derive(Debug)]
|
||||
/// 检查源 paint 返回列表并将命令保存到当前出现的绘制结果。
|
||||
struct Collect {
|
||||
desktop: ObjectRef,
|
||||
mount: u64,
|
||||
output: Output,
|
||||
}
|
||||
impl NativeCallable for Collect {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(result)] = args else {
|
||||
return Err(invalid("paint must return a DisplayList"));
|
||||
@@ -178,16 +209,19 @@ impl NativeCallable for Collect {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 为当前出现准备 paint 方法、几何和类型检查上下文。
|
||||
struct Prepare {
|
||||
desktop: ObjectRef,
|
||||
mounts: Vec<(u64, WeakObjectRef)>,
|
||||
output: Output,
|
||||
}
|
||||
impl NativeCallable for Prepare {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let advance = cx.allocate(ContextNativeFunction::new(
|
||||
"next paint occurrence",
|
||||
@@ -208,6 +242,7 @@ impl NativeCallable for Prepare {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 安排下一个出现的绘制调用,保持原始绘制顺序和共享 VM 预算。
|
||||
struct Advance {
|
||||
desktop: ObjectRef,
|
||||
mounts: Arc<[(u64, WeakObjectRef)]>,
|
||||
@@ -215,9 +250,11 @@ struct Advance {
|
||||
output: Output,
|
||||
}
|
||||
impl NativeCallable for Advance {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let occurrence = {
|
||||
let mut next = self
|
||||
@@ -287,6 +324,7 @@ impl NativeCallable for Advance {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已经完成本阶段、但尚未由桥接层发布的派生结果,包含帧/缓存或真实读取依赖。
|
||||
pub struct Draft {
|
||||
pub frame: Frame,
|
||||
pub reads: Vec<endor_core::ReadDependency>,
|
||||
@@ -294,12 +332,14 @@ pub struct Draft {
|
||||
}
|
||||
|
||||
#[must_use = "pending paint must be resumed or cancelled on its owning VM"]
|
||||
/// 本次推进处于挂起还是完成;挂起工作必须继续恢复或显式取消。
|
||||
pub enum Progress {
|
||||
Pending(Box<Work>),
|
||||
Complete(Box<Draft>),
|
||||
}
|
||||
|
||||
/// Only unpublished native commands and an owning prepare root cross a pause.
|
||||
/// 拥有未完成源调用及临时根的工作句柄;丢弃 Rust 值不能替代 VM 内的取消。
|
||||
pub struct Work {
|
||||
desktop: ObjectRef,
|
||||
prepare: ObjectRef,
|
||||
@@ -310,12 +350,14 @@ pub struct Work {
|
||||
}
|
||||
|
||||
impl Work {
|
||||
/// 继续当前绘制续体,保留其先前收集的命令和所属窗口上下文。
|
||||
pub fn resume(mut self, vm: &mut Vm, slice: usize) -> Result<Progress> {
|
||||
self.slices += 1;
|
||||
let progress = vm.resume_read_only_call(slice);
|
||||
self.accept(vm, progress)
|
||||
}
|
||||
|
||||
/// 关闭未完成绘制续体并释放其临时根,不发布半完成命令列表。
|
||||
pub fn cancel(self, vm: &mut Vm) -> Result<()> {
|
||||
let cancelled = vm.cancel_read_only_call();
|
||||
let released = vm.release(self.prepare);
|
||||
@@ -324,6 +366,7 @@ impl Work {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 区分源绘制续体是挂起还是完成,保留工作或结束只读域并生成 Draft。
|
||||
fn accept(
|
||||
self,
|
||||
vm: &mut Vm,
|
||||
@@ -351,6 +394,7 @@ impl Work {
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立本轮只读绘制驱动和结果收集器;源覆盖与原生默认绘制最终保持同一出现顺序。
|
||||
pub fn start(
|
||||
vm: &mut Vm,
|
||||
desktop: ObjectRef,
|
||||
@@ -394,12 +438,14 @@ pub fn start(
|
||||
work.accept(vm, result)
|
||||
}
|
||||
|
||||
/// 按 Mount 合并源覆盖和原生命令,补齐投影/裁剪表,再递归装配世界 UI 面板。
|
||||
fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -> Result<Progress> {
|
||||
let output = work
|
||||
.output
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("paint output poisoned"))?;
|
||||
if output.commands.is_empty() {
|
||||
let native_only = output.commands.is_empty();
|
||||
if work.root.is_none() {
|
||||
return Ok(Progress::Complete(Box::new(Draft {
|
||||
frame: work.frame,
|
||||
reads: Vec::new(),
|
||||
@@ -417,6 +463,8 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
let mut clips = Vec::new();
|
||||
let mut views = Vec::new();
|
||||
let mut owners = Vec::new();
|
||||
for mount in &frame.mounts {
|
||||
let before = commands.len();
|
||||
if let Some(custom) = output.commands.get(&mount.id) {
|
||||
@@ -425,7 +473,7 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
|
||||
let node = nodes
|
||||
.get(mount.block_id.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("paint projection has no node"))?;
|
||||
if let Some(command) = layout::paint_native(
|
||||
commands.extend(layout::paint_commands(
|
||||
node,
|
||||
mount.x,
|
||||
mount.y,
|
||||
@@ -433,23 +481,27 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
|
||||
width: mount.width,
|
||||
height: mount.height,
|
||||
},
|
||||
) {
|
||||
commands.push(command);
|
||||
}
|
||||
));
|
||||
}
|
||||
clips.extend(std::iter::repeat_n(mount.clip, commands.len() - before));
|
||||
views.extend(std::iter::repeat_n(mount.view, commands.len() - before));
|
||||
owners.extend(std::iter::repeat_n(mount.id, commands.len() - before));
|
||||
if commands.len() > 10_000 {
|
||||
bail!("paint command budget exceeded");
|
||||
}
|
||||
}
|
||||
frame.commands = commands;
|
||||
frame.command_clips = clips;
|
||||
frame.command_views = views;
|
||||
frame.command_mounts = owners;
|
||||
crate::scene3d::panel::compose(frame)?;
|
||||
let registry = object(vm.get_attr(desktop, "types")?)?;
|
||||
let reads = reads
|
||||
.into_iter()
|
||||
.filter(|dependency| {
|
||||
let object = dependency.object();
|
||||
object != desktop
|
||||
!native_only
|
||||
&& object != desktop
|
||||
&& object != registry
|
||||
&& vm
|
||||
.object(object)
|
||||
|
||||
+129
-49
@@ -1,3 +1,7 @@
|
||||
//! 统一字体发现、文字整形、测量与抗锯齿字形绘制。
|
||||
//! 测量与绘制使用同一字体系统和样式,避免布局宽度与屏幕文字不一致;中文与其他字符通过字体回退处理。
|
||||
//! 整形和字形缓存有数量/字节预算;极端放大采用有界字形 LOD,最终只遍历裁剪区域内的像素。
|
||||
//!
|
||||
//! Shared font discovery, shaping, measurement and antialiased rasterization.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
@@ -9,12 +13,14 @@ use cosmic_text::{Align, Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Sw
|
||||
use crate::display_list::Color;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||
/// 界面比例字体与源码等宽字体的选择,不直接指定某个必然安装的系统字体。
|
||||
pub enum TextFamily {
|
||||
Interface,
|
||||
Code,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
/// 字号、行高和字体类别,测量与绘制必须使用兼容样式。
|
||||
pub struct TextStyle {
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
@@ -22,6 +28,7 @@ pub struct TextStyle {
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// 生成界面文字的基准字号与行高,size 是协议使用的字号倍率。
|
||||
pub fn ui(size: u32) -> Self {
|
||||
Self {
|
||||
font_size: 14.0 * size as f32,
|
||||
@@ -30,6 +37,7 @@ impl TextStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成源码/Console 使用的等宽风格基础尺寸。
|
||||
pub fn code() -> Self {
|
||||
Self {
|
||||
font_size: 13.0,
|
||||
@@ -38,6 +46,7 @@ impl TextStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// 同比缩放字号与行高,保持设备比例变化后的排版比例一致。
|
||||
pub fn scaled(self, scale: f32) -> Self {
|
||||
Self {
|
||||
font_size: self.font_size * scale,
|
||||
@@ -48,12 +57,14 @@ impl TextStyle {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
/// 整形后实际占用的逻辑文字尺寸。
|
||||
pub struct TextSize {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Hash, PartialEq, Eq)]
|
||||
/// 文字整形缓存键,包含文本、样式和可选换行宽度。
|
||||
struct TextKey {
|
||||
value: String,
|
||||
font_size: u32,
|
||||
@@ -62,11 +73,13 @@ struct TextKey {
|
||||
wrap_width: Option<u32>,
|
||||
}
|
||||
|
||||
/// 已整形 Buffer 与测量结果,后续测量/绘制复用同一布局。
|
||||
struct PreparedText {
|
||||
buffer: Buffer,
|
||||
size: TextSize,
|
||||
}
|
||||
|
||||
/// 共享字体系统、字形缓存和整形缓存;调用通过全局锁串行访问。
|
||||
struct TextEngine {
|
||||
fonts: FontSystem,
|
||||
code_family: Option<String>,
|
||||
@@ -76,6 +89,7 @@ struct TextEngine {
|
||||
}
|
||||
|
||||
impl TextEngine {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
fn new() -> Self {
|
||||
let mut fonts = FontSystem::new();
|
||||
// Select installed, readable families rather than assuming an OS font
|
||||
@@ -114,10 +128,12 @@ impl TextEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 对不指定换行宽度的文本复用统一整形准备过程。
|
||||
fn prepare(&mut self, value: &str, style: TextStyle) -> TextKey {
|
||||
self.prepare_width(value, style, None)
|
||||
}
|
||||
|
||||
/// 为文本、字体样式和可选换行宽度建立整形缓存,超过缓存预算先清理旧结果。
|
||||
fn prepare_width(&mut self, value: &str, style: TextStyle, wrap_width: Option<f32>) -> TextKey {
|
||||
let key = TextKey {
|
||||
value: value.to_owned(),
|
||||
@@ -175,17 +191,20 @@ impl TextEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 取得全局受锁保护的字体/整形引擎;共享缓存不等于可以并行修改字体状态。
|
||||
fn engine() -> &'static Mutex<TextEngine> {
|
||||
static ENGINE: OnceLock<Mutex<TextEngine>> = OnceLock::new();
|
||||
ENGINE.get_or_init(|| Mutex::new(TextEngine::new()))
|
||||
}
|
||||
|
||||
/// 返回不受换行宽度约束时的字体整形尺寸。
|
||||
pub fn measure(value: &str, style: TextStyle) -> TextSize {
|
||||
let mut engine = engine().lock().expect("text engine lock");
|
||||
let key = engine.prepare(value, style);
|
||||
engine.buffers[&key].size
|
||||
}
|
||||
|
||||
/// 按可用宽度整形并测量多行高度,供布局与实际段落绘制共用。
|
||||
pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
|
||||
if width <= 0.0 {
|
||||
return TextSize {
|
||||
@@ -198,75 +217,110 @@ pub fn measure_wrapped(value: &str, style: TextStyle, width: f32) -> TextSize {
|
||||
engine.buffers[&key].size
|
||||
}
|
||||
|
||||
/// Draw at the actual device font size; no scaling of an already rasterized bitmap.
|
||||
pub fn draw(value: &str, style: TextStyle, color: Color, pixel: impl FnMut(i32, i32, Color)) {
|
||||
draw_width(value, style, color, None, pixel)
|
||||
}
|
||||
|
||||
pub fn draw_wrapped(
|
||||
value: &str,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
width: f32,
|
||||
pixel: impl FnMut(i32, i32, Color),
|
||||
) {
|
||||
if width > 0.0 {
|
||||
draw_width(value, style, color, Some(width), pixel)
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_width(
|
||||
/// Rasterize only visible glyph pixels. Large camera zooms use a bounded glyph
|
||||
/// bitmap and sample it at the requested size instead of allocating an enormous
|
||||
/// font image. Layout and wrapping still use proportionally identical metrics.
|
||||
/// 限制字形分辨率并只采样可见像素;保留文字布局比例,同时避免极端相机缩放产生巨型位图。
|
||||
pub fn draw_clipped(
|
||||
value: &str,
|
||||
style: TextStyle,
|
||||
color: Color,
|
||||
width: Option<f32>,
|
||||
clip: crate::geometry::Rect,
|
||||
mut pixel: impl FnMut(i32, i32, Color),
|
||||
) {
|
||||
if clip.width <= 0.
|
||||
|| clip.height <= 0.
|
||||
|| !style.font_size.is_finite()
|
||||
|| style.font_size <= 0.
|
||||
{
|
||||
return;
|
||||
}
|
||||
let lod = (512. / style.font_size).min(1.);
|
||||
let mut engine = engine().lock().expect("text engine lock");
|
||||
let key = engine.prepare_width(value, style, width);
|
||||
let key = engine.prepare_width(value, style.scaled(lod), width.map(|width| width * lod));
|
||||
let TextEngine {
|
||||
fonts,
|
||||
swash,
|
||||
buffers,
|
||||
..
|
||||
} = &mut *engine;
|
||||
let opacity = u16::from(color.alpha);
|
||||
buffers.get_mut(&key).expect("prepared text").buffer.draw(
|
||||
fonts,
|
||||
swash,
|
||||
cosmic_text::Color::rgb(color.red, color.green, color.blue),
|
||||
|x, y, width, height, color| {
|
||||
for dy in 0..height as i32 {
|
||||
for dx in 0..width as i32 {
|
||||
pixel(
|
||||
x + dx,
|
||||
y + dy,
|
||||
Color::rgba(
|
||||
color.r(),
|
||||
color.g(),
|
||||
color.b(),
|
||||
(u16::from(color.a()) * opacity / 255) as u8,
|
||||
let buffer = &buffers[&key].buffer;
|
||||
let mut cache_bytes: usize = swash
|
||||
.image_cache
|
||||
.values()
|
||||
.flatten()
|
||||
.map(|image| image.data.len())
|
||||
.sum();
|
||||
for run in buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
let physical = glyph.physical((0., run.line_y), 1.);
|
||||
// Conservative font-sized guard includes bearings/overhangs. The
|
||||
// exact bitmap bounds below determine the actual visible pixels.
|
||||
let guard = glyph.font_size * 4. / lod;
|
||||
let gx = physical.x as f32 / lod;
|
||||
let gy = physical.y as f32 / lod;
|
||||
if gx + guard < clip.x
|
||||
|| gx - guard > clip.x + clip.width
|
||||
|| gy + guard < clip.y
|
||||
|| gy - guard > clip.y + clip.height
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if swash.image_cache.len() >= 4096 || cache_bytes >= 33_554_432 {
|
||||
*swash = SwashCache::new();
|
||||
cache_bytes = 0;
|
||||
}
|
||||
let cached = swash.image_cache.contains_key(&physical.cache_key);
|
||||
let Some(image) = swash.get_image(fonts, physical.cache_key) else {
|
||||
continue;
|
||||
};
|
||||
if !cached {
|
||||
cache_bytes += image.data.len();
|
||||
}
|
||||
let left = (physical.x + image.placement.left) as f32 / lod;
|
||||
let top = (physical.y - image.placement.top) as f32 / lod;
|
||||
let right = left + image.placement.width as f32 / lod;
|
||||
let bottom = top + image.placement.height as f32 / lod;
|
||||
let base = glyph.color_opt.map_or(color, |value| {
|
||||
Color::rgba(value.r(), value.g(), value.b(), color.alpha)
|
||||
});
|
||||
for y in top.max(clip.y).ceil() as i32..bottom.min(clip.y + clip.height).ceil() as i32 {
|
||||
for x in
|
||||
left.max(clip.x).ceil() as i32..right.min(clip.x + clip.width).ceil() as i32
|
||||
{
|
||||
let u = (((x as f32 + 0.5 - left) * lod) as u32)
|
||||
.min(image.placement.width.saturating_sub(1));
|
||||
let v = (((y as f32 + 0.5 - top) * lod) as u32)
|
||||
.min(image.placement.height.saturating_sub(1));
|
||||
let index = (v * image.placement.width + u) as usize;
|
||||
let sample = match image.content {
|
||||
cosmic_text::SwashContent::Mask => Color::rgba(
|
||||
base.red,
|
||||
base.green,
|
||||
base.blue,
|
||||
(u16::from(image.data[index]) * u16::from(color.alpha) / 255) as u8,
|
||||
),
|
||||
);
|
||||
cosmic_text::SwashContent::Color => {
|
||||
let i = index * 4;
|
||||
Color::rgba(
|
||||
image.data[i],
|
||||
image.data[i + 1],
|
||||
image.data[i + 2],
|
||||
(u16::from(image.data[i + 3]) * u16::from(color.alpha) / 255) as u8,
|
||||
)
|
||||
}
|
||||
cosmic_text::SwashContent::SubpixelMask => continue,
|
||||
};
|
||||
pixel(x, y, sample);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
// The cache is an optimization, never an archive of all submitted glyphs.
|
||||
if swash.image_cache.len() > 4096
|
||||
|| swash
|
||||
.image_cache
|
||||
.values()
|
||||
.flatten()
|
||||
.map(|image| image.data.len())
|
||||
.sum::<usize>()
|
||||
> 33_554_432
|
||||
{
|
||||
*swash = SwashCache::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit a single displayed source line without slicing a UTF-8/grapheme cluster.
|
||||
/// 按真实字体测量截断到给定宽度,以字素簇边界拼接省略号,避免切开组合字符。
|
||||
pub fn fit(value: &str, width: f32, style: TextStyle) -> String {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
@@ -316,6 +370,32 @@ mod tests {
|
||||
use super::*;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[test]
|
||||
fn extreme_zoom_rasterizes_only_the_clip_with_bounded_glyph_sizes() {
|
||||
let mut count = 0;
|
||||
draw_clipped(
|
||||
"M",
|
||||
TextStyle::ui(1).scaled(10_000.),
|
||||
Color::rgba(1, 2, 3, 255),
|
||||
None,
|
||||
crate::geometry::Rect::new(50_000., 60_000., 32., 32.),
|
||||
|x, y, _| {
|
||||
assert!((50_000..50_032).contains(&x));
|
||||
assert!((60_000..60_032).contains(&y));
|
||||
count += 1;
|
||||
},
|
||||
);
|
||||
assert_eq!(count, 1024);
|
||||
let engine = engine().lock().unwrap();
|
||||
assert!(
|
||||
engine
|
||||
.swash
|
||||
.image_cache
|
||||
.keys()
|
||||
.all(|key| f32::from_bits(key.font_size_bits) <= 512.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_sensitive_shaping_wraps_words_and_clusters_without_changing_intrinsic_metrics() {
|
||||
let style = TextStyle::ui(1);
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
//! 独立 2D 相机与可逆投影。视口尺寸属于布局;平移、缩放属于视图,不改变世界对象的逻辑位置。
|
||||
//! project 为每次出现附加组合变换、裁剪和嵌套面板映射;绘制与命中共用这些结果。fit 根据有限内容边界调整指定相机。
|
||||
//!
|
||||
//! Reversible projections keep camera movement independent of layout.
|
||||
pub mod spatial;
|
||||
use crate::geometry::{Point, Rect};
|
||||
use endor_core::{ObjectPayload, ObjectRef, ObjectStore, VmError};
|
||||
|
||||
/// Marker payload gives cameras a native nominal identity while their fields
|
||||
/// remain ordinary traced, bindable VM attributes.
|
||||
#[derive(Debug)]
|
||||
/// 托管 2D 相机的名义标记,实际位置/缩放是被追踪的普通属性。
|
||||
pub struct Camera2D;
|
||||
impl ObjectPayload for Camera2D {
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"camera-2d"
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
/// 从 VM 提取的独立相机数值描述,渲染与布局投影不直接读取托管堆。
|
||||
pub struct CameraView {
|
||||
pub identity: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub zoom: f32,
|
||||
}
|
||||
|
||||
/// 读取并校验托管 Camera2D 的位置和缩放,生成原生投影描述。
|
||||
pub fn camera(store: &ObjectStore, object: ObjectRef) -> Result<CameraView, VmError> {
|
||||
store.payload::<Camera2D>(object, "Camera2D")?;
|
||||
let x = crate::scroll::coordinate(&store.get_attr(object, "x")?)?;
|
||||
let y = crate::scroll::coordinate(&store.get_attr(object, "y")?)?;
|
||||
let zoom = crate::scroll::coordinate(&store.get_attr(object, "zoom")?)?;
|
||||
if !(0.01..=100.0).contains(&zoom) {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"Camera2D.zoom expects 0.01 through 100",
|
||||
));
|
||||
}
|
||||
Ok(CameraView {
|
||||
identity: object.to_string(),
|
||||
x,
|
||||
y,
|
||||
zoom,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fit finite scene bounds using this occurrence's allocation. Sharing a camera
|
||||
/// is explicit, so fitting one view then intentionally affects all its users.
|
||||
/// 根据这个 CanvasViewport 出现的有限内容范围调整其相机;显式共享相机会同步影响其他视口。
|
||||
pub fn fit(
|
||||
cx: &mut endor_core::NativeContext<'_>,
|
||||
mount: &crate::mount::Mount,
|
||||
) -> Result<(), VmError> {
|
||||
if !mount.geometry.canvas_viewport || mount.layout.is_some() {
|
||||
return Err(VmError::InvalidArgument(
|
||||
"fitView requires a committed CanvasViewport Mount",
|
||||
));
|
||||
}
|
||||
let (_, data) = crate::block::resolve(cx.store(), mount.block)?;
|
||||
let data = crate::block::visual_data(cx.store(), data)?;
|
||||
let camera = data
|
||||
.camera
|
||||
.ok_or(VmError::InvalidArgument("missing viewport camera"))?;
|
||||
let root = mount
|
||||
.first_child
|
||||
.map(|weak| cx.upgrade(weak))
|
||||
.transpose()?
|
||||
.flatten()
|
||||
.ok_or(VmError::InvalidArgument("missing scene occurrence"))?;
|
||||
let scene = cx
|
||||
.store()
|
||||
.payload::<crate::mount::Mount>(root, "Mount")?
|
||||
.clone();
|
||||
let mut next = scene.first_child;
|
||||
let mut bounds: Option<[f32; 4]> = None;
|
||||
let mut count = 0;
|
||||
while let Some(weak) = next {
|
||||
count += 1;
|
||||
if count > 10_000 {
|
||||
return Err(VmError::ExecutionLimit("fitView scene bounds"));
|
||||
}
|
||||
let object = cx
|
||||
.upgrade(weak)?
|
||||
.ok_or(VmError::InvalidArgument("expired scene child"))?;
|
||||
let child = cx.store().payload::<crate::mount::Mount>(object, "Mount")?;
|
||||
let rect = [
|
||||
child.geometry.x - scene.geometry.x,
|
||||
child.geometry.y - scene.geometry.y,
|
||||
child.geometry.x - scene.geometry.x + child.geometry.width,
|
||||
child.geometry.y - scene.geometry.y + child.geometry.height,
|
||||
];
|
||||
bounds = Some(bounds.map_or(rect, |b| {
|
||||
[
|
||||
b[0].min(rect[0]),
|
||||
b[1].min(rect[1]),
|
||||
b[2].max(rect[2]),
|
||||
b[3].max(rect[3]),
|
||||
]
|
||||
}));
|
||||
next = child.next_sibling;
|
||||
}
|
||||
let node = crate::block::snapshot(cx.store(), mount.block)?;
|
||||
let inset = crate::layout::insets(&node);
|
||||
let width = (mount.geometry.width - inset.horizontal()).max(0.0);
|
||||
let height = (mount.geometry.height - inset.vertical()).max(0.0);
|
||||
let (x, y, zoom) = if let Some(bounds) = bounds {
|
||||
let zoom = (width / (bounds[2] - bounds[0] + 48.0))
|
||||
.min(height / (bounds[3] - bounds[1] + 48.0))
|
||||
.clamp(0.01, 100.0);
|
||||
(
|
||||
(bounds[0] + bounds[2]) / 2.0 - width / (2.0 * zoom),
|
||||
(bounds[1] + bounds[3]) / 2.0 - height / (2.0 * zoom),
|
||||
zoom,
|
||||
)
|
||||
} else {
|
||||
(0.0, 0.0, 1.0)
|
||||
};
|
||||
for (name, value) in [("x", x), ("y", y), ("zoom", zoom)] {
|
||||
cx.set_attr(
|
||||
camera,
|
||||
name,
|
||||
crate::native_visual::value(value.clamp(-1_000_000.0, 1_000_000.0)),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
/// 可组合的均匀缩放与平移;正向绘制和逆向命中必须使用同一结果。
|
||||
pub struct Transform {
|
||||
pub zoom: f32,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
impl Default for Transform {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
zoom: 1.0,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Transform {
|
||||
/// 将逻辑点按当前缩放和平移变换到外层视图。
|
||||
pub fn point(self, point: Point) -> Point {
|
||||
Point::new(point.x * self.zoom + self.x, point.y * self.zoom + self.y)
|
||||
}
|
||||
/// 将视图点反算回逻辑坐标,供命中和手势锚点计算使用。
|
||||
pub fn inverse(self, point: Point) -> Point {
|
||||
Point::new(
|
||||
(point.x - self.x) / self.zoom,
|
||||
(point.y - self.y) / self.zoom,
|
||||
)
|
||||
}
|
||||
/// 使用同一缩放和平移转换矩形的位置与尺寸。
|
||||
pub fn rect(self, rect: Rect) -> Rect {
|
||||
let point = self.point(Point::new(rect.x, rect.y));
|
||||
Rect::new(
|
||||
point.x,
|
||||
point.y,
|
||||
rect.width * self.zoom,
|
||||
rect.height * self.zoom,
|
||||
)
|
||||
}
|
||||
/// 将当前变换再放入外层变换,保持绘制与逆向命中的组合顺序一致。
|
||||
pub fn then(self, outer: Self) -> Self {
|
||||
Self {
|
||||
zoom: self.zoom * outer.zoom,
|
||||
x: self.x * outer.zoom + outer.x,
|
||||
y: self.y * outer.zoom + outer.y,
|
||||
}
|
||||
}
|
||||
/// 检查数值、几何和资源条件是否满足本类型约束,供上层拒绝非法输入。
|
||||
pub fn valid(self) -> bool {
|
||||
self.zoom.is_finite()
|
||||
&& self.zoom > 0.0
|
||||
&& self.zoom <= 1_000_000.0
|
||||
&& self.x.is_finite()
|
||||
&& self.y.is_finite()
|
||||
&& self.x.abs() <= 1_000_000_000_000.0
|
||||
&& self.y.abs() <= 1_000_000_000_000.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算视口与祖先裁剪的共同可见矩形。
|
||||
fn intersect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
||||
let x = a[0].max(b[0]);
|
||||
let y = a[1].max(b[1]);
|
||||
[
|
||||
x,
|
||||
y,
|
||||
((a[0] + a[2]).min(b[0] + b[2]) - x).max(0.0),
|
||||
((a[1] + a[3]).min(b[1] + b[3]) - y).max(0.0),
|
||||
]
|
||||
}
|
||||
|
||||
/// 遍历出现树,组合相机与父视图变换,建立裁剪和世界 UI 映射,最后重建屏幕命中索引。
|
||||
pub fn project(
|
||||
root: &crate::ui_bridge::UiNode,
|
||||
frame: &mut crate::document::Frame,
|
||||
) -> anyhow::Result<()> {
|
||||
use crate::ui_bridge::UiKind;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
let mut nodes = HashMap::new();
|
||||
let mut work = vec![root];
|
||||
while let Some(node) = work.pop() {
|
||||
nodes.insert(node.block_id.as_str(), node);
|
||||
work.extend(node.children().iter().map(|node| node.as_ref()));
|
||||
}
|
||||
type Scope = (
|
||||
Transform,
|
||||
Option<[f32; 4]>,
|
||||
Option<Arc<crate::scene3d::panel::Mapping>>,
|
||||
);
|
||||
let mut contexts = HashMap::<u64, Scope>::new();
|
||||
let next: HashMap<_, _> = frame
|
||||
.mounts
|
||||
.iter()
|
||||
.map(|mount| (mount.id, mount.next_sibling))
|
||||
.collect();
|
||||
let mut panels =
|
||||
HashMap::<u64, (Arc<crate::scene3d::panel::Mapping>, crate::layout::Size)>::new();
|
||||
for mount in &mut frame.mounts {
|
||||
let node = nodes[mount.block_id.as_str()];
|
||||
let (view, clip, panel) = if let Some((panel, size)) = panels.get(&mount.id) {
|
||||
(
|
||||
Transform::default(),
|
||||
Some([0., 0., size.width, size.height]),
|
||||
Some(panel.clone()),
|
||||
)
|
||||
} else {
|
||||
mount
|
||||
.parent
|
||||
.and_then(|parent| contexts.get(&parent).cloned())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
anyhow::ensure!(
|
||||
view.valid(),
|
||||
"nested viewport transform exceeds numeric budget"
|
||||
);
|
||||
mount.view = (view != Transform::default()).then_some(view);
|
||||
mount.panel = panel.clone();
|
||||
mount.clip = clip;
|
||||
mount.viewport_clip = None;
|
||||
let mut child_view = view;
|
||||
let mut child_clip = clip;
|
||||
let inset = crate::layout::insets(node);
|
||||
let viewport = if let UiKind::CanvasViewport { camera, .. } = &node.kind {
|
||||
let x = mount.x + inset.left;
|
||||
let y = mount.y + inset.top;
|
||||
child_view = Transform {
|
||||
zoom: camera.zoom,
|
||||
x: x * (1.0 - camera.zoom) - camera.x * camera.zoom,
|
||||
y: y * (1.0 - camera.zoom) - camera.y * camera.zoom,
|
||||
}
|
||||
.then(view);
|
||||
Some(Rect::new(
|
||||
x,
|
||||
y,
|
||||
(mount.width - inset.horizontal()).max(0.0),
|
||||
(mount.height - inset.vertical()).max(0.0),
|
||||
))
|
||||
} else if matches!(node.kind, UiKind::Viewport3D { .. }) {
|
||||
Some(Rect::new(
|
||||
mount.x + inset.left,
|
||||
mount.y + inset.top,
|
||||
(mount.width - inset.horizontal()).max(0.),
|
||||
(mount.height - inset.vertical()).max(0.),
|
||||
))
|
||||
} else if node.clip_children {
|
||||
let border = node.border;
|
||||
Some(Rect::new(
|
||||
mount.x + border.left.min(mount.width),
|
||||
mount.y + border.top.min(mount.height),
|
||||
(mount.width - border.horizontal()).max(0.0),
|
||||
(mount.height - border.vertical()).max(0.0),
|
||||
))
|
||||
} else if matches!(node.kind, UiKind::Scroll { .. }) {
|
||||
Some(Rect::new(mount.x, mount.y, mount.width, mount.height))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(rect) = viewport {
|
||||
let rect = view.rect(rect);
|
||||
let rect = [rect.x, rect.y, rect.width, rect.height];
|
||||
child_clip = Some(clip.map_or(rect, |clip| intersect(clip, rect)));
|
||||
if mount.canvas_viewport || mount.scene3d_viewport {
|
||||
mount.viewport_clip = child_clip;
|
||||
}
|
||||
}
|
||||
if let UiKind::Viewport3D {
|
||||
view: scene,
|
||||
panels: sizes,
|
||||
..
|
||||
} = &node.kind
|
||||
{
|
||||
let mut depth = 0;
|
||||
let mut ancestor = panel.clone();
|
||||
while let Some(mapping) = ancestor {
|
||||
depth += 1;
|
||||
ancestor = mapping.parent.clone();
|
||||
}
|
||||
anyhow::ensure!(depth < 8, "3D UI viewport nesting exceeds 8 surfaces");
|
||||
let mut child = mount.first_child;
|
||||
for (key, size) in node.slots.iter().zip(sizes) {
|
||||
let id = child.ok_or_else(|| anyhow::anyhow!("missing 3D UI root occurrence"))?;
|
||||
let mapping = Arc::new(crate::scene3d::panel::Mapping {
|
||||
owner: mount.id,
|
||||
key: *key,
|
||||
view: scene.clone(),
|
||||
rect: [
|
||||
mount.x + inset.left,
|
||||
mount.y + inset.top,
|
||||
(mount.width - inset.horizontal()).max(0.),
|
||||
(mount.height - inset.vertical()).max(0.),
|
||||
],
|
||||
outer: view,
|
||||
clip: mount.viewport_clip,
|
||||
parent: panel.clone(),
|
||||
});
|
||||
panels.insert(id, (mapping, *size));
|
||||
child = next[&id];
|
||||
}
|
||||
}
|
||||
contexts.insert(mount.id, (child_view, child_clip, panel));
|
||||
}
|
||||
let mounts: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect();
|
||||
frame.command_views.clear();
|
||||
frame.command_clips.clear();
|
||||
anyhow::ensure!(
|
||||
frame.command_mounts.len() == frame.commands.len(),
|
||||
"missing command occurrence table"
|
||||
);
|
||||
for owner in &frame.command_mounts {
|
||||
let mount = mounts
|
||||
.get(owner)
|
||||
.ok_or_else(|| anyhow::anyhow!("paint owner is unavailable"))?;
|
||||
frame.command_views.push(mount.view);
|
||||
frame.command_clips.push(mount.clip);
|
||||
}
|
||||
frame.hit_index = std::sync::Arc::new(spatial::Index::build(&frame.mounts));
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn nested_views_compose_and_invert_without_changing_layout_coordinates() {
|
||||
// Camera coordinates may be +/-1e6 at zoom 100. Their projected
|
||||
// translation must not inherit the smaller world-coordinate limit.
|
||||
assert!(
|
||||
Transform {
|
||||
zoom: 100.,
|
||||
x: -100_000_000.,
|
||||
y: 100_000_000.
|
||||
}
|
||||
.valid()
|
||||
);
|
||||
let inner = Transform {
|
||||
zoom: 2.0,
|
||||
x: 12.0,
|
||||
y: -40.0,
|
||||
};
|
||||
let outer = Transform {
|
||||
zoom: 0.5,
|
||||
x: 80.0,
|
||||
y: 20.0,
|
||||
};
|
||||
let point = Point::new(-5.0, 100.0);
|
||||
let composed = inner.then(outer);
|
||||
assert_eq!(composed.point(point), outer.point(inner.point(point)));
|
||||
assert_eq!(composed.inverse(composed.point(point)), point);
|
||||
assert_eq!(
|
||||
composed.rect(Rect::new(-5.0, 100.0, 20.0, 30.0)),
|
||||
Rect::new(81.0, 100.0, 20.0, 30.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 有界屏幕网格,用来减少普通二维命中和相机目标查询的候选。
|
||||
//! 巨大对象和 3D 面板进入后备集合,再由精确命中判断;索引预算不足不能导致遗漏目标。反序列化或索引长度不匹配时可退回完整候选。
|
||||
//!
|
||||
//! Bounded screen-space broad phase. Projective world UI uses the precise
|
||||
//! ray-based narrow phase rather than an unsafe screen rectangle approximation.
|
||||
use crate::{document::MountGeometry, geometry::Point};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// 有限屏幕格子和后备集合。候选筛选允许保守多取,但不能因索引预算漏掉有效命中。
|
||||
pub struct Index {
|
||||
count: usize,
|
||||
cells: HashMap<(i32, i32), Vec<usize>>,
|
||||
fallback: Vec<usize>,
|
||||
}
|
||||
impl Index {
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub fn build(mounts: &[MountGeometry]) -> Self {
|
||||
let mut result = Self {
|
||||
count: mounts.len(),
|
||||
..Self::default()
|
||||
};
|
||||
let mut entries = 0;
|
||||
for (index, mount) in mounts.iter().enumerate() {
|
||||
if !mount.interactive
|
||||
&& !mount.canvas_viewport
|
||||
&& !mount.scene3d_viewport
|
||||
&& mount.scroll.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if mount.panel.is_some() {
|
||||
result.fallback.push(index);
|
||||
continue;
|
||||
}
|
||||
let a = Point::new(mount.x, mount.y);
|
||||
let b = Point::new(mount.x + mount.width, mount.y + mount.height);
|
||||
let a = mount.view.map_or(a, |view| view.point(a));
|
||||
let b = mount.view.map_or(b, |view| view.point(b));
|
||||
let (mut x0, mut y0, mut x1, mut y1) =
|
||||
(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
|
||||
if let Some([x, y, w, h]) = mount.clip {
|
||||
x0 = x0.max(x);
|
||||
y0 = y0.max(y);
|
||||
x1 = x1.min(x + w);
|
||||
y1 = y1.min(y + h);
|
||||
}
|
||||
if x1 < x0 || y1 < y0 {
|
||||
continue;
|
||||
}
|
||||
if ![x0, y0, x1, y1]
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && value.abs() < 1e12)
|
||||
{
|
||||
result.fallback.push(index);
|
||||
continue;
|
||||
}
|
||||
let (left, top, right, bottom) = (cell(x0), cell(y0), cell(x1), cell(y1));
|
||||
let cells = (i64::from(right) - i64::from(left) + 1)
|
||||
.saturating_mul(i64::from(bottom) - i64::from(top) + 1);
|
||||
if cells > 256 || entries + cells > 65_536 {
|
||||
result.fallback.push(index);
|
||||
continue;
|
||||
}
|
||||
entries += cells;
|
||||
for x in left..=right {
|
||||
for y in top..=bottom {
|
||||
result.cells.entry((x, y)).or_default().push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
/// 返回当前网格格子与后备集合中的候选,去重并恢复逆绘制顺序。
|
||||
pub fn candidates(&self, point: Point, count: usize) -> Vec<usize> {
|
||||
if self.count != count {
|
||||
return (0..count).rev().collect();
|
||||
}
|
||||
let mut output = self.fallback.clone();
|
||||
if let Some(items) = self.cells.get(&(cell(point.x), cell(point.y))) {
|
||||
output.extend(items);
|
||||
}
|
||||
output.sort_unstable_by(|a, b| b.cmp(a));
|
||||
output.dedup();
|
||||
output
|
||||
}
|
||||
}
|
||||
/// 把屏幕坐标映射到有界整数网格坐标,处理负方向与极端数值。
|
||||
fn cell(value: f32) -> i32 {
|
||||
(value / 128.)
|
||||
.floor()
|
||||
.clamp(i32::MIN as f32, i32::MAX as f32) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn screen_grid_limits_candidates_and_keeps_huge_rectangles_hittable() {
|
||||
let (_, document) = crate::endor_host::ReplSession::load(
|
||||
"page=Page()\npage.render(Button(\"hit\"))",
|
||||
"app.main",
|
||||
1,
|
||||
930,
|
||||
)
|
||||
.unwrap();
|
||||
let template = document
|
||||
.frame
|
||||
.mounts
|
||||
.iter()
|
||||
.find(|mount| mount.interactive)
|
||||
.unwrap();
|
||||
let mut mounts = Vec::new();
|
||||
for index in 0..200 {
|
||||
let mut mount = template.clone();
|
||||
mount.x = index as f32 * 400.;
|
||||
mount.y = 0.;
|
||||
mount.width = 32.;
|
||||
mount.height = 32.;
|
||||
mount.view = None;
|
||||
mount.clip = None;
|
||||
mounts.push(mount);
|
||||
}
|
||||
let index = Index::build(&mounts);
|
||||
assert_eq!(index.candidates(Point::new(5., 5.), mounts.len()), vec![0]);
|
||||
mounts[199].x = -1_000_000.;
|
||||
mounts[199].width = 2_000_000.;
|
||||
let index = Index::build(&mounts);
|
||||
assert_eq!(
|
||||
index.candidates(Point::new(5., 5.), mounts.len()),
|
||||
vec![199, 0]
|
||||
);
|
||||
assert!(mounts[199].contains(Point::new(5., 5.)));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 本机 HTTP API 入口:路由请求、限制输入大小、观察活动,并将执行工作送入串行 worker。
|
||||
//! HTTP 线程不操作 VM;共享 NamespaceState 只保存最近完整发布文档和资源命令。提交序号用来拒绝已被较新 PUT/DELETE 取代的结果。
|
||||
//! 关闭流程先请求运行时有界清理,再通过完成通知让原生事件循环退出。
|
||||
//!
|
||||
mod guide;
|
||||
mod worker;
|
||||
|
||||
@@ -35,18 +39,21 @@ const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64;
|
||||
type Notify = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// 运行时清理的摘要与可选错误,供最终原生退出结果报告。
|
||||
pub struct ShutdownOutcome {
|
||||
pub summary: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
type ShutdownCompletion = Arc<(Mutex<Option<ShutdownOutcome>>, Condvar)>;
|
||||
#[derive(Clone)]
|
||||
/// 跨线程的退出控制句柄,使用有界通知和完成条件变量,不携带 VM。
|
||||
pub struct RuntimeControl {
|
||||
sender: SyncSender<()>,
|
||||
requested: Arc<AtomicBool>,
|
||||
completion: ShutdownCompletion,
|
||||
}
|
||||
impl RuntimeControl {
|
||||
/// 只发出一次退出请求,唤醒 worker 执行清理,并通过完成句柄通知原生循环。
|
||||
pub fn request_shutdown(&self) -> bool {
|
||||
if self.requested.swap(true, Ordering::AcqRel) {
|
||||
return true;
|
||||
@@ -58,9 +65,11 @@ impl RuntimeControl {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// 读取退出标志,使窗口事件和新输入尽早停止进入运行时。
|
||||
pub fn is_exiting(&self) -> bool {
|
||||
self.requested.load(Ordering::Acquire)
|
||||
}
|
||||
/// 在明确超时内等待 worker 的清理结果,不能无限阻塞退出。
|
||||
pub fn wait_shutdown(&self, timeout: std::time::Duration) -> Option<ShutdownOutcome> {
|
||||
let (lock, ready) = &*self.completion;
|
||||
let result = lock.lock().ok()?;
|
||||
@@ -70,6 +79,7 @@ impl RuntimeControl {
|
||||
result.clone()
|
||||
}
|
||||
}
|
||||
/// worker 持有的关闭接收端、控制状态和 GUI 完成通知代理。
|
||||
struct Shutdown {
|
||||
receiver: Receiver<()>,
|
||||
control: RuntimeControl,
|
||||
@@ -77,6 +87,7 @@ struct Shutdown {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// 送入执行队列的请求类别,区分完整源码、持久输入、事件与重置。
|
||||
enum RequestKind {
|
||||
Event,
|
||||
Code,
|
||||
@@ -85,6 +96,7 @@ enum RequestKind {
|
||||
ResetRepl,
|
||||
}
|
||||
|
||||
/// 保留原 HTTP 请求与提交身份的执行工作,完成或失败后由 worker 回应。
|
||||
struct BuildRequest {
|
||||
request: ObservedRequest,
|
||||
kind: RequestKind,
|
||||
@@ -93,6 +105,7 @@ struct BuildRequest {
|
||||
submission: u64,
|
||||
}
|
||||
|
||||
/// 建立本模块的执行/服务上下文,并把后续推进交给返回的句柄或驱动。
|
||||
pub fn start(
|
||||
namespace: Arc<RwLock<NamespaceState>>,
|
||||
proxy: EventLoopProxy<AppEvent>,
|
||||
@@ -127,6 +140,7 @@ pub fn start(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 为本地监听服务建立请求处理线程,并把执行请求转交有界队列。
|
||||
fn start_server(
|
||||
server: Arc<Server>,
|
||||
namespace: Arc<RwLock<NamespaceState>>,
|
||||
@@ -142,6 +156,7 @@ fn start_server(
|
||||
)
|
||||
}
|
||||
|
||||
/// 启动带活动观察的 HTTP 服务,将请求状态变化通知管理界面。
|
||||
fn start_server_observed(
|
||||
server: Arc<Server>,
|
||||
namespace: Arc<RwLock<NamespaceState>>,
|
||||
@@ -283,6 +298,7 @@ fn start_server_observed(
|
||||
}
|
||||
|
||||
// Capture only allowlisted headers: credentials and cookies never enter the inspector.
|
||||
/// HTTP 请求的观察包装,在读取正文和响应时维护 Network 工具记录。
|
||||
struct ObservedRequest {
|
||||
inner: Request,
|
||||
activity: Activity,
|
||||
@@ -290,6 +306,7 @@ struct ObservedRequest {
|
||||
notify: Notify,
|
||||
}
|
||||
impl ObservedRequest {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
fn new(inner: Request, activity: Activity, notify: Notify) -> Self {
|
||||
let headers = inner
|
||||
.headers()
|
||||
@@ -322,10 +339,12 @@ impl ObservedRequest {
|
||||
notify,
|
||||
}
|
||||
}
|
||||
/// 将受限请求正文记录到活动预览,不在观察器中执行源码。
|
||||
fn record_body(&self, body: &str) {
|
||||
self.activity.lock().unwrap().body(self.id, body);
|
||||
(self.notify)();
|
||||
}
|
||||
/// 发送最终 HTTP 响应,并同步结束该条活动记录。
|
||||
fn respond(self, response: Response<std::io::Cursor<Vec<u8>>>) -> std::io::Result<()> {
|
||||
let status = response.status_code();
|
||||
let headers = response.headers().to_vec();
|
||||
@@ -348,16 +367,19 @@ impl ObservedRequest {
|
||||
}
|
||||
impl Deref for ObservedRequest {
|
||||
type Target = Request;
|
||||
/// 将包装类型的只读访问转交给内部当前上下文。
|
||||
fn deref(&self) -> &Request {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
impl DerefMut for ObservedRequest {
|
||||
/// 将包装类型的可变访问转交给内部当前上下文。
|
||||
fn deref_mut(&mut self) -> &mut Request {
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 HTTP 方法和路径;只读路由读取发布值,执行路由交给 worker。
|
||||
fn handle(
|
||||
request: Request,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
@@ -410,6 +432,7 @@ fn handle(
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
/// 处理源码资源的 GET/DELETE 等请求,返回对应版本头或撤销运行时所有权。
|
||||
fn code_request(
|
||||
method: Method,
|
||||
addressed_namespace: &str,
|
||||
@@ -510,6 +533,7 @@ fn code_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取并检查请求体,分配提交身份,再把工作送进有界执行队列。
|
||||
fn submit(
|
||||
mut request: ObservedRequest,
|
||||
addressed: &str,
|
||||
@@ -576,6 +600,7 @@ fn submit(
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成较旧请求已被新提交取代的响应,拒绝晚到结果覆盖当前文档。
|
||||
fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
json(
|
||||
StatusCode(409),
|
||||
@@ -584,15 +609,18 @@ fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
)
|
||||
}
|
||||
|
||||
/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。
|
||||
fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> {
|
||||
path.strip_prefix("/v1/namespaces/")?
|
||||
.strip_suffix(suffix)
|
||||
.filter(|namespace| !namespace.is_empty() && !namespace.contains('/'))
|
||||
}
|
||||
/// 从固定路由格式提取命名空间,避免把不匹配的路径当作代码资源。
|
||||
fn namespace_from_path(path: &str) -> Option<&str> {
|
||||
namespace_from_route(path, "/code")
|
||||
}
|
||||
|
||||
/// 按源码字节上限读取 UTF-8 请求体,读取失败或超限时返回诊断。
|
||||
fn read_code(request: &mut Request) -> Result<String> {
|
||||
if request
|
||||
.body_length()
|
||||
@@ -612,6 +640,7 @@ fn read_code(request: &mut Request) -> Result<String> {
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// 将前端/编译/执行诊断编码为 API 错误响应,保留用户可理解的位置和消息。
|
||||
fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let mut diagnostic =
|
||||
serde_json::json!({ "severity": "error", "message": format!("{error:#}") });
|
||||
@@ -630,6 +659,7 @@ fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成统一 JSON 错误响应,使调用方能区分协议错误与成功结果。
|
||||
fn error_response(
|
||||
status: StatusCode,
|
||||
message: impl Into<String>,
|
||||
@@ -637,6 +667,7 @@ fn error_response(
|
||||
json(status, serde_json::json!({ "error": message.into() }))
|
||||
}
|
||||
|
||||
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
|
||||
fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
text(
|
||||
status,
|
||||
@@ -645,10 +676,12 @@ fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Curso
|
||||
)
|
||||
}
|
||||
|
||||
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
|
||||
fn markdown(status: StatusCode, value: String) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
text(status, &value, "text/markdown; charset=utf-8")
|
||||
}
|
||||
|
||||
/// 以明确的内容类型和状态码构造响应,并应用本机 API 的公共响应头。
|
||||
fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let mut response = Response::from_string(value).with_status_code(status);
|
||||
add_header(&mut response, "Content-Type", content_type);
|
||||
@@ -670,6 +703,7 @@ fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io
|
||||
response
|
||||
}
|
||||
|
||||
/// 向响应追加经过 HTTP 库检查的头字段,不直接拼接未验证的原始报文。
|
||||
fn add_header(response: &mut Response<std::io::Cursor<Vec<u8>>>, name: &str, value: &str) {
|
||||
if let Ok(header) = Header::from_bytes(name, value) {
|
||||
response.add_header(header);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! 从当前命名空间生成 /agent 使用指南。返回的示例、地址和版本描述来自运行时上下文;这里生成文档,不执行用户源码。
|
||||
//!
|
||||
//! Live agent-facing guide generated from the current namespace.
|
||||
use super::*;
|
||||
|
||||
/// 用当前命名空间、地址和版本填充在线指南,示例来自项目内置 Endor 源码。
|
||||
pub(super) fn agent_guide(namespace: &NamespaceState) -> String {
|
||||
format!(
|
||||
r##"# Tessera Agent API
|
||||
@@ -54,6 +57,38 @@ It does not publish a page or change revision. Console does not use this endpoin
|
||||
All execution endpoints share the eight-entry queue. These are in-process bounded
|
||||
host executions, not a complete untrusted-code sandbox or a process-isolation design.
|
||||
|
||||
## Layout, scenes and cameras
|
||||
|
||||
Use Page with nested Row / Column / Overlay for finite layout, ScrollView for
|
||||
scrolling, and CanvasViewport(width,height,Scene2D(...)) for a finite view into
|
||||
2D world coordinates. Each viewport owns an explicit Camera2D by default;
|
||||
assigning the same camera shares navigation. Viewport3D provides the analogous
|
||||
finite view into Scene3D using programmable perspective/orthographic cameras.
|
||||
See docs/scene-architecture.md and examples/world-ui.endor for the current model.
|
||||
|
||||
### Legacy Page compatibility
|
||||
|
||||
Page.mode is "window" (default), "vertical", "horizontal" or "canvas".
|
||||
Window fills the available page viewport. Vertical constrains width and grows
|
||||
height with children; horizontal constrains height and grows width. Scroll
|
||||
finite pages with the wheel; the offset clamps to their actual extent.
|
||||
Canvas arranges direct children at signed child.x / child.y world positions.
|
||||
page.render(root) replaces content; page.append(child) adds another child.
|
||||
Changing these attributes updates live occurrences, without copying objects.
|
||||
|
||||
Canvas uses Page.panX / panY as the world origin and Page.zoom (0.1..4).
|
||||
Wheel/trackpad pans, Space+left drag or middle drag pans, pinch or Cmd/Ctrl+wheel
|
||||
zooms around the pointer. Positions are finite and bounded to +/-1000000 by the
|
||||
existing renderer budget; canvas navigation is not clipped to content extent.
|
||||
The camera belongs to the Page and survives page switching.
|
||||
Nested ScrollBlock input is handled before page panning.
|
||||
|
||||
Native transport kinds "page-pan" and "page-zoom" use mount=0 and current
|
||||
window/session/layout_version. Pan delta is a two-component viewport-pixel
|
||||
displacement. Zoom uses delta=[positive_scale_factor,0] and point=[x,y] in
|
||||
viewport coordinates. Ordinary click/scroll coordinates remain world coordinates;
|
||||
the host inverts the camera and the worker rechecks visibility and frame identity.
|
||||
|
||||
## Managed visual types
|
||||
|
||||
`Object`, `Type`, `Function`, `Task`, `Block`, `ContainerBlock`, `TextBlock`, `RectBlock`,
|
||||
@@ -67,7 +102,7 @@ declarations, explicit first `self` parameters, positional field construction an
|
||||
member assignment. Inherited methods bind the actual receiver; overridden methods
|
||||
are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Explicit traits and source parameter/return annotation checks are enabled; declared-field construction/write checks are enabled alongside source function checks; complete initialization and static/effect typing remain pending. Native Block/Page methods use the same type method tables and binding path.
|
||||
|
||||
Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), moveTo(x,y), setTitle(text) and focus(). Requests project to their actual native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Secondary close after callback/rendering failure uses host-only teardown without invoking source callbacks or resuming execution. After reset/discard, closing an old picture never touches a fresh VM resource with the same number; viewport inputs are also generation-scoped. A teardown failure discards the affected VM and disables remaining pictures. Native initialization/surface errors are reported rather than panicking; failed secondary creation leaves a managed resource closable from Console. Native tab reordering, existing-window drops and release-point placement use the managed methods described below. Application-exit cleanup is described below.
|
||||
Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), moveTo(x,y), setTitle(text) and focus(). Each window has a collapsible left sidebar for pages and Console/Sources/Network/Settings. Cmd/Ctrl+B toggles it. Collapsed, only the sidebar toggle and native macOS window controls remain over page content. Viewport queries report the actual available page area, with no top strip or content inset. Page selection/closure calls actual managed Page methods; Ctrl+Tab cycles pages, Cmd/Ctrl+T creates one and Cmd/Ctrl+W closes the active page. Window() creates another native resource in the same VM, up to 16. Page.open(window), moveToWindow(window) and detachToWindow() preserve business identity and replace occurrence Mounts. Window.close() releases Page roots and retires its presentation; primary native close exits the application. Resource changes publish only after successful input/candidate completion. Frame/session/window generations reject stale events and commands. Sidebar pointer drag/reorder is not implemented.
|
||||
|
||||
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.
|
||||
|
||||
@@ -78,10 +113,10 @@ Source layout/paint now validate name-level read observations from Aster: each g
|
||||
|
||||
|
||||
|
||||
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.
|
||||
Page navigation is in the left sidebar; the former horizontal strip and its drag/overflow animation are removed. Managed moveTab/moveToWindow/detachToWindow and the version-checked tab-drop API remain available. Sidebar pointer drag/reorder and mixed-DPI cross-window gestures are not implemented in this revision.
|
||||
|
||||
|
||||
ContainerBlock.insert(position,block) accepts a zero-based position through the current count, returns a new stable slot and preserves existing slots/Mounts. Invalid indices and child cycles fail before changing the child list. Page.moveTab(position) uses a final zero-based index in its owning window. Native drops invoke the actual Page moveToWindow/moveTab/activate methods; a declined or redirected window move skips destination reordering. A drop allows 300000 instructions per method. Source overrides participate; earlier writes do not roll back after failure. Window.moveTo(x,y) queues the physical desktop outer origin, with signed integers within +/-1000000. Detached placement preserves the saved grab offset; the window manager may adjust it and unsupported global placement reports a diagnostic. Dragging uses a 12-logical-pixel threshold, visible insertion marker and Escape cancellation. Overflow auto-scroll, external-app occlusion and mixed-DPI verification remain pending. See examples/tab-placement.endor.
|
||||
ContainerBlock.insert(position,block) accepts a zero-based position through the current count, returns a new stable slot and preserves existing slots/Mounts. Invalid indices and child cycles fail before changing the child list. Page.moveTab(position) uses a final zero-based index in its owning window. Native drops invoke the actual Page moveToWindow/moveTab/activate methods; a declined or redirected window move skips destination reordering. A drop allows 300000 instructions per method. Source overrides participate; earlier writes do not roll back after failure. Window.moveTo(x,y) queues the physical desktop outer origin, with signed integers within +/-1000000. Detached placement preserves the saved grab offset; the window manager may adjust it and unsupported global placement reports a diagnostic. These movement methods remain callable from Endor and transport clients; the sidebar currently provides selection and closure rather than pointer dragging. See examples/tab-placement.endor.
|
||||
|
||||
Source measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. Native TextBlock wrapping now uses the occurrence width in measurement/cache/painting; source measurement/arrangement now repeats until constraints and geometry settle; additional allocation policies remain pending; ScrollBlock scrolling is described below.
|
||||
|
||||
@@ -119,6 +154,14 @@ ScrollBlock.axis is a managed visual attribute: "both" (default), "vertical" or
|
||||
|
||||
ContainerBlock.mainAlign accepts "start" (default), "center", "end" or "space-between". Native arrangement uses allocated Bounds after padding/minimum gap, distributes positive remaining main-axis space and keeps child sizes unchanged. Overflow keeps start placement. Explicit source arrange placements remain authoritative; this is alignment, not flex sizing.
|
||||
|
||||
Finite box layout: Overlay(padding, gap, children...) stacks children (gap is unused); mainAlign aligns horizontally and crossAlign vertically. Row/Column crossAlign accepts "start", "center", "end" or "stretch". Block.alignSelf defaults to "auto" and can override crossAlign per occurrence. marginTop/Right/Bottom/Left participate in measurement, alignment and flex allocation, without collapsing. paddingTop/Right/Bottom/Left override a side of the container padding; "auto" restores its fallback. borderTop/Right/Bottom/Left reserve inner border space; borderColor controls native border paint. Source paint overrides remain authoritative. boxWidth/boxHeight accept "auto", a nonnegative border-box dimension, or "fill" (finite parent required). Main-axis fill shares remaining space with weight 1 unless flex is set; minWidth/minHeight/maxWidth/maxHeight bound each dimension. Stretch changes only auto cross-axis dimensions. overflow is "visible" or "clip"; clipping affects descendant paint and hits. ScrollBlock handles scrolling independently of its content layout. See examples/box-layout.endor and docs/scene-architecture.md. Independent 2D scenes are described below; 3D support is described below. Page.mode remains a compatibility API.
|
||||
|
||||
Independent 2D scenes are now implemented: Scene2D(children...) stores world content at child x/y; CanvasViewport(width,height,scene) is a finite layout box with its own Camera2D(). Camera2D(x=0,y=0,zoom=1) fields are ordinary managed attributes; zoom is 0.01–100. Sharing a scene does not share its camera; assigning second.camera=first.camera does. Scene content may contain layout containers and nested finite viewports; Overlay siblings stay fixed. Mount.fitView() fits finite direct scene-child bounds using that occurrence's allocation. Native wheel/pinch/space or middle drag targets the innermost viewport; drag retains its initial target. viewport-pan/viewport-zoom carry the viewport Mount and current frame identities, with signed logical page coordinates. Pan delta is measured before the viewport projection; zoom delta is [factor,0] with an in-content pointer anchor. Worker validation rejects stale/invalid input before invoking the VM. Draw and hit transforms compose consistently while layout geometry and measurement caches remain in world coordinates. ScrollView is a nominal ScrollBlock subtype. The default page uses these APIs; old Page.mode/panX/panY/zoom remain for compatibility. 3D support is described below. See examples/scene-viewports.endor.
|
||||
|
||||
3D basics are now implemented: Scene3D/Group3D, Box3D, immutable Geometry3D made from Vec3/Triangle3D ObjectLists, Mesh3D, DirectionalLight, and finite Viewport3D. PerspectiveCamera and OrthographicCamera expose x/y/z, targetX/Y/Z, upX/Y/Z, near/far, fov or height; viewport aspect is derived separately. New viewports have independent cameras and OrbitController; explicit camera assignment shares a camera. A controller can be replaced with an Endor object defining update(camera,gesture), or disabled by assigning "none". Native scene-orbit/scene-pan/scene-zoom events carry viewport Mount/frame identities. Mesh/group translate and rotate use native arithmetic; camera.translate moves position and target. This avoids the fixed Aster version's unsupported numeric arithmetic expressions. Mount.pick3D(x,y) returns nullable RayHit3D with managed mesh and world intersection data; pointer click Event.hit carries the same result. Rendering uses frustum clipping and a depth buffer in a bounded cached surface. World-space UI composition is described below; visibility and resource work is continuing. See examples/scene3d.endor and docs/scene-architecture.md.
|
||||
|
||||
World UI is implemented with Panel3D(worldWidth,worldHeight,content). uiWidth/uiHeight provide an independent finite UI layout resolution (integer 1–2048); background is opaque. Ordinary Endor measure/arrange/paint/hitTest, shared objects, distinct Mounts and per-occurrence scroll state remain active. Panel transforms place a perspective-correct UI texture in the world. A visible ray hit maps through the plane to UI coordinates, then through any nested 2D camera; opaque geometry blocks UI input. Panel UI may contain another Viewport3D and further panels (maximum 8 world UI levels). Mount.toView(localX,localY) converts component-local coordinates to the page view; toLocal(viewX,viewY) performs the reverse when visible. Camera transport from a world UI viewport requires a pointer so pan/scroll increments can use the plane's projective mapping. Its scroll and camera gestures retain one target instead of driving both parent and child. Cyclic UI/scene composition and nested pixel allocations are bounded. See examples/world-ui.endor.
|
||||
|
||||
Block.flex is a nonnegative weight (default 0). Native containers reserve fixed measured children, padding and gaps, then share remaining allocated main-axis space between positive weights with zero basis. Actual child allocation feeds remeasurement. It does not request parent expansion. Further controls are described below.
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! API 集成回归。通过实际本地 HTTP 请求检查队列、候选替换、状态码、版本和完整文档发布。
|
||||
//! 测试服务使用独立监听地址和串行 worker;应结合断言理解成功输入、失败候选、过期事件和关闭的不同保留语义。
|
||||
//!
|
||||
use super::*;
|
||||
use std::{
|
||||
io::Write,
|
||||
@@ -479,3 +482,366 @@ page.render(TextBlock("input priority"))
|
||||
let value = runtime.execute("count.value", "app.main").unwrap().value;
|
||||
assert_eq!(value, "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_camera_crosses_real_http_route_and_preserves_negative_world_clicks() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
viewport: Some(crate::layout::Size {
|
||||
width: 600.0,
|
||||
height: 400.0,
|
||||
}),
|
||||
..NamespaceState::default()
|
||||
}));
|
||||
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
||||
let client = TestServer {
|
||||
address: server.server_addr().to_ip().unwrap(),
|
||||
server,
|
||||
api: Some(api),
|
||||
};
|
||||
let source = r#"
|
||||
count=ui.state(0)
|
||||
button=Button("world")
|
||||
button.onClick(count.increment)
|
||||
button.x=-160
|
||||
button.y=-80
|
||||
page=Page()
|
||||
page.mode="canvas"
|
||||
page.render(button)
|
||||
"#;
|
||||
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
||||
let frame = &namespace.read().unwrap().document.frame;
|
||||
serde_json::to_string(&crate::events::Input {
|
||||
window: 1,
|
||||
session: frame.session,
|
||||
layout_version: frame.layout_version,
|
||||
mount: 0,
|
||||
kind: kind.into(),
|
||||
delta: Some(delta),
|
||||
point,
|
||||
press_point: None,
|
||||
screen_position: None,
|
||||
tab_drop: None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let event_path = "/v1/namespaces/app.main/events";
|
||||
let pan = camera("page-pan", [-240.0, -160.0], None);
|
||||
let response = client.request("POST", event_path, &pan);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(
|
||||
namespace.read().unwrap().document.frame.surface.pan,
|
||||
crate::geometry::Point::new(-240.0, -160.0)
|
||||
);
|
||||
assert_eq!(client.request("POST", event_path, &pan).0, 409);
|
||||
let response = client.request(
|
||||
"POST",
|
||||
event_path,
|
||||
&camera("page-zoom", [2.0, 0.0], Some([120.0, 90.0])),
|
||||
);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
{
|
||||
let current = namespace.read().unwrap();
|
||||
assert_eq!(current.document.frame.surface.zoom, 2.0);
|
||||
assert_eq!(
|
||||
current.document.frame.surface.pan,
|
||||
crate::geometry::Point::new(-180.0, -115.0)
|
||||
);
|
||||
}
|
||||
let input = {
|
||||
let current = namespace.read().unwrap();
|
||||
let frame = ¤t.document.frame;
|
||||
let mount = frame.mounts.iter().find(|m| m.interactive).unwrap();
|
||||
assert!(mount.x < 0.0 && mount.y < 0.0);
|
||||
crate::events::Input {
|
||||
window: 1,
|
||||
session: frame.session,
|
||||
layout_version: frame.layout_version,
|
||||
mount: mount.id,
|
||||
kind: "click".into(),
|
||||
point: Some([-150.0, -70.0]),
|
||||
press_point: Some([-150.0, -70.0]),
|
||||
delta: None,
|
||||
screen_position: None,
|
||||
tab_drop: None,
|
||||
}
|
||||
};
|
||||
let response = client.request("POST", event_path, &serde_json::to_string(&input).unwrap());
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let response = client.request("POST", "/v1/namespaces/app.main/repl", "count.value");
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
|
||||
"1"
|
||||
);
|
||||
let response = client.request(
|
||||
"POST",
|
||||
event_path,
|
||||
&camera("page-zoom", [0.0, 0.0], Some([120.0, 90.0])),
|
||||
);
|
||||
assert_eq!(response.0, 400);
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_viewport_camera_crosses_http_and_rejects_bad_anchors_without_faulting() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
viewport: Some(crate::layout::Size {
|
||||
width: 600.0,
|
||||
height: 400.0,
|
||||
}),
|
||||
..NamespaceState::default()
|
||||
}));
|
||||
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
||||
let client = TestServer {
|
||||
address: server.server_addr().to_ip().unwrap(),
|
||||
server,
|
||||
api: Some(api),
|
||||
};
|
||||
let source = r#"
|
||||
content = Button("inside")
|
||||
content.x = 40
|
||||
content.y = 20
|
||||
scene = Scene2D(content)
|
||||
view = CanvasViewport(200, 100, scene)
|
||||
view.borderLeft = 10
|
||||
page = Page()
|
||||
page.render(Column(20, 0, view))
|
||||
"#;
|
||||
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let camera = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
||||
let current = namespace.read().unwrap();
|
||||
let frame = ¤t.document.frame;
|
||||
serde_json::to_string(&crate::events::Input {
|
||||
window: 1,
|
||||
session: frame.session,
|
||||
layout_version: frame.layout_version,
|
||||
mount: frame
|
||||
.mounts
|
||||
.iter()
|
||||
.find(|mount| mount.canvas_viewport)
|
||||
.unwrap()
|
||||
.id,
|
||||
kind: kind.into(),
|
||||
delta: Some(delta),
|
||||
point,
|
||||
press_point: None,
|
||||
screen_position: None,
|
||||
tab_drop: None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let path = "/v1/namespaces/app.main/events";
|
||||
let pan = camera("viewport-pan", [20.0, -10.0], None);
|
||||
let response = client.request("POST", path, &pan);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(client.request("POST", path, &pan).0, 409);
|
||||
let response = client.request(
|
||||
"POST",
|
||||
path,
|
||||
&camera("viewport-zoom", [2.0, 0.0], Some([80.0, 60.0])),
|
||||
);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
for (expression, expected) in [
|
||||
("view.camera.x", "45"),
|
||||
("view.camera.y", "10"),
|
||||
("view.camera.zoom", "2"),
|
||||
] {
|
||||
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"],
|
||||
expected
|
||||
);
|
||||
}
|
||||
for point in [[25.0, 30.0], [220.0, 30.0], [700.0, 700.0]] {
|
||||
let response = client.request(
|
||||
"POST",
|
||||
path,
|
||||
&camera("viewport-zoom", [2.0, 0.0], Some(point)),
|
||||
);
|
||||
assert_eq!(response.0, 400, "{}", response.2);
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene3d_controllers_cross_http_and_can_be_replaced_by_endor_code() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
viewport: Some(crate::layout::Size {
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
}),
|
||||
..NamespaceState::default()
|
||||
}));
|
||||
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
||||
let client = TestServer {
|
||||
address: server.server_addr().to_ip().unwrap(),
|
||||
server,
|
||||
api: Some(api),
|
||||
};
|
||||
let source = r##"
|
||||
class PanOnly {
|
||||
function update(self: PanOnly, camera: Object, gesture: CameraGesture): Unit { camera.translate(gesture.dx, gesture.dy, 0) }
|
||||
}
|
||||
view = Viewport3D(200,100,Scene3D(Box3D(2,2,2,"#CC4455")))
|
||||
page = Page()
|
||||
page.render(view)
|
||||
"##;
|
||||
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
||||
let current = namespace.read().unwrap();
|
||||
let frame = ¤t.document.frame;
|
||||
serde_json::to_string(&crate::events::Input {
|
||||
window: 1,
|
||||
session: frame.session,
|
||||
layout_version: frame.layout_version,
|
||||
mount: frame
|
||||
.mounts
|
||||
.iter()
|
||||
.find(|mount| mount.scene3d_viewport)
|
||||
.unwrap()
|
||||
.id,
|
||||
kind: kind.into(),
|
||||
delta: Some(delta),
|
||||
point,
|
||||
press_point: None,
|
||||
tab_drop: None,
|
||||
screen_position: None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let event_path = "/v1/namespaces/app.main/events";
|
||||
let zoom = gesture("scene-zoom", [2., 0.], Some([100., 50.]));
|
||||
let response = client.request("POST", event_path, &zoom);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(client.request("POST", event_path, &zoom).0, 409);
|
||||
let value = |expression: &str| {
|
||||
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
};
|
||||
assert_eq!(value("view.camera.z"), "2.5");
|
||||
let response = client.request("POST", event_path, &gesture("scene-orbit", [20., 0.], None));
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let x = value("view.camera.x").parse::<f32>().unwrap();
|
||||
assert!((x - 2.5 * 0.1f32.sin()).abs() < 0.0001);
|
||||
value("view.camera = OrthographicCamera()");
|
||||
let response = client.request(
|
||||
"POST",
|
||||
event_path,
|
||||
&gesture("scene-zoom", [2., 0.], Some([100., 50.])),
|
||||
);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(value("view.camera.height"), "2.5");
|
||||
value("view.controller = PanOnly()");
|
||||
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert_eq!(value("view.camera.x"), "7");
|
||||
let response = client.request(
|
||||
"POST",
|
||||
event_path,
|
||||
&gesture("scene-zoom", [2., 0.], Some([250., 50.])),
|
||||
);
|
||||
assert_eq!(response.0, 400);
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
value("view.controller = \"none\"");
|
||||
let response = client.request("POST", event_path, &gesture("scene-pan", [7., 0.], None));
|
||||
assert_eq!(response.0, 409);
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_in_world_ui_uses_ray_uv_coordinates_for_real_http_camera_events() {
|
||||
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
|
||||
let namespace = Arc::new(RwLock::new(NamespaceState {
|
||||
viewport: Some(crate::layout::Size {
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
}),
|
||||
..NamespaceState::default()
|
||||
}));
|
||||
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
|
||||
let client = TestServer {
|
||||
address: server.server_addr().to_ip().unwrap(),
|
||||
server,
|
||||
api: Some(api),
|
||||
};
|
||||
let source = r##"
|
||||
canvas=CanvasViewport(100,100,Scene2D(Button("UI in a world")))
|
||||
panel=Panel3D(2,2,canvas)
|
||||
panel.uiWidth=100
|
||||
panel.uiHeight=100
|
||||
panel.rotationY=25
|
||||
view=Viewport3D(200,200,Scene3D(panel))
|
||||
page=Page()
|
||||
page.render(view)
|
||||
"##;
|
||||
let response = client.request("PUT", "/v1/namespaces/app.main/code", source);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let gesture = |kind: &str, delta: [f32; 2], point: Option<[f32; 2]>| {
|
||||
let current = namespace.read().unwrap();
|
||||
let frame = ¤t.document.frame;
|
||||
serde_json::to_string(&crate::events::Input {
|
||||
window: 1,
|
||||
session: frame.session,
|
||||
layout_version: frame.layout_version,
|
||||
mount: frame
|
||||
.mounts
|
||||
.iter()
|
||||
.find(|mount| mount.canvas_viewport && mount.panel.is_some())
|
||||
.unwrap()
|
||||
.id,
|
||||
kind: kind.into(),
|
||||
point,
|
||||
press_point: None,
|
||||
delta: Some(delta),
|
||||
tab_drop: None,
|
||||
screen_position: None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let path = "/v1/namespaces/app.main/events";
|
||||
assert_eq!(
|
||||
client
|
||||
.request("POST", path, &gesture("viewport-pan", [10., 0.], None))
|
||||
.0,
|
||||
400
|
||||
);
|
||||
let response = client.request(
|
||||
"POST",
|
||||
path,
|
||||
&gesture("viewport-pan", [10., 0.], Some([100., 100.])),
|
||||
);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
let value = |expression: &str| {
|
||||
let response = client.request("POST", "/v1/namespaces/app.main/repl", expression);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
serde_json::from_str::<serde_json::Value>(&response.2).unwrap()["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<f32>()
|
||||
.unwrap()
|
||||
};
|
||||
let pan = value("canvas.camera.x");
|
||||
assert!(pan > 10.);
|
||||
assert_eq!(value("view.camera.x"), 0.);
|
||||
let response = client.request(
|
||||
"POST",
|
||||
path,
|
||||
&gesture("viewport-zoom", [2., 0.], Some([100., 100.])),
|
||||
);
|
||||
assert_eq!(response.0, 200, "{}", response.2);
|
||||
assert!((value("canvas.camera.x") - pan - 25.).abs() < 0.001);
|
||||
assert_eq!(value("canvas.camera.zoom"), 2.);
|
||||
assert!(namespace.read().unwrap().document.frame.fault.is_none());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 唯一串行执行 VM 的 API worker。保留活跃运行时,另建 PUT 候选,并在候选完整成功后切换发布。
|
||||
//! 输入、视口刷新与候选布局可以跨 worker 轮次挂起;中途只公布完整文档,不能让新输入进入未结束的只读调用。
|
||||
//! 事件身份同时校验窗口、session、layout_version 和 Mount 能力。失败、替换与关闭分别处理,避免旧回调或旧命令泄漏到新 VM。
|
||||
//!
|
||||
//! Serial VM ownership, incremental requests, publication and shutdown.
|
||||
use super::*;
|
||||
|
||||
@@ -13,6 +17,7 @@ type CandidateOutcome = std::thread::Result<
|
||||
)>,
|
||||
>;
|
||||
|
||||
/// 已执行业务部分但仍在等待展示刷新的输入元数据,避免恢复时重放回调。
|
||||
pub(super) struct PendingInput {
|
||||
epoch: u64,
|
||||
id: u64,
|
||||
@@ -20,17 +25,20 @@ pub(super) struct PendingInput {
|
||||
result: endor_host::ReplResult,
|
||||
}
|
||||
|
||||
/// 挂起 HTTP 请求的阶段:等旧刷新、继续输入或继续隔离候选。
|
||||
pub(super) enum PendingRequest {
|
||||
BeforeInput,
|
||||
Input(Box<PendingInput>),
|
||||
Candidate(Box<endor_host::ReplSession>),
|
||||
}
|
||||
|
||||
/// 一次 worker 推进的结果:继续保留请求或交付最终响应。
|
||||
pub(super) enum RequestProgress {
|
||||
Pending(PendingRequest),
|
||||
Complete(HttpResponse),
|
||||
}
|
||||
|
||||
/// 只提取仍属于当前 session 的窗口尺寸,避免旧原生窗口给新候选提供错误约束。
|
||||
fn current_viewports(
|
||||
current: &NamespaceState,
|
||||
) -> std::collections::BTreeMap<u64, crate::layout::Size> {
|
||||
@@ -55,11 +63,13 @@ fn current_viewports(
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
/// 唯一活跃 VM 的串行拥有者,另行管理候选和下一 session 身份。
|
||||
pub(super) struct Worker {
|
||||
pub(super) repl: Option<(u64, endor_host::ReplSession)>,
|
||||
pub(super) next_session: u64,
|
||||
}
|
||||
impl Worker {
|
||||
/// 撤销活跃运行时并有界清理窗口、Mount 和任务;故障域不再执行新的业务回调。
|
||||
pub(super) fn shutdown_runtime(&mut self) -> ShutdownOutcome {
|
||||
let Some((_, mut runtime)) = self.repl.take() else {
|
||||
return ShutdownOutcome {
|
||||
@@ -90,6 +100,7 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理当前无法继续正常交互的窗口关闭,撤销原生资源与相应运行时记录。
|
||||
pub(super) fn close_inactive_window(
|
||||
&mut self,
|
||||
input: &crate::events::Input,
|
||||
@@ -162,6 +173,7 @@ impl Worker {
|
||||
)
|
||||
}
|
||||
|
||||
/// 在没有前台输入的 worker 轮次推进尺寸和任务引起的展示刷新。
|
||||
pub(super) fn refresh_viewport(
|
||||
&mut self,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
@@ -170,12 +182,14 @@ impl Worker {
|
||||
self.refresh_viewport_mode(namespace, notify, false);
|
||||
}
|
||||
|
||||
/// 在输入之间推进视口/任务刷新;提交序号或 session 已变时拒绝旧结果发布。
|
||||
pub(super) fn refresh_viewport_mode(
|
||||
&mut self,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
notify: &Notify,
|
||||
before_input: bool,
|
||||
) {
|
||||
// 在读锁内取得一个一致的提交身份和尺寸快照;耗时 VM 工作不占用共享状态写锁。
|
||||
let (epoch, viewports) = match namespace.read() {
|
||||
Ok(current) => {
|
||||
let viewports = current_viewports(¤t);
|
||||
@@ -216,6 +230,7 @@ impl Worker {
|
||||
}
|
||||
Ok((completed.or(resized), runtime.take_window_commands()?))
|
||||
}));
|
||||
// 计算结束后重新核对归属:期间若发生 PUT/DELETE,不能把旧域草稿发布出去。
|
||||
let mut current = match namespace.write() {
|
||||
Ok(current) => current,
|
||||
Err(_) => return,
|
||||
@@ -267,6 +282,7 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查请求种类和归属,必要时先等已有刷新结束,再建立输入或候选任务。
|
||||
pub(super) fn begin_request(
|
||||
&mut self,
|
||||
job: &BuildRequest,
|
||||
@@ -336,6 +352,13 @@ impl Worker {
|
||||
"click"
|
||||
| "focus"
|
||||
| "scroll"
|
||||
| "page-pan"
|
||||
| "page-zoom"
|
||||
| "viewport-pan"
|
||||
| "viewport-zoom"
|
||||
| "scene-orbit"
|
||||
| "scene-pan"
|
||||
| "scene-zoom"
|
||||
| "tab-activate"
|
||||
| "tab-close"
|
||||
| "tab-detach"
|
||||
@@ -422,7 +445,10 @@ impl Worker {
|
||||
|| frame.session != input.session
|
||||
|| (input.kind != "window-close" && frame.layout_version != input.layout_version)
|
||||
|| frame.fault.is_some()
|
||||
|| if input.kind == "window-close" || input.kind.starts_with("developer-open:") {
|
||||
|| if input.kind == "window-close"
|
||||
|| input.kind.starts_with("developer-open:")
|
||||
|| matches!(input.kind.as_str(), "page-pan" | "page-zoom")
|
||||
{
|
||||
input.mount != 0
|
||||
} else if matches!(
|
||||
input.kind.as_str(),
|
||||
@@ -432,8 +458,15 @@ impl Worker {
|
||||
} else {
|
||||
!frame.mounts.iter().any(|mount| {
|
||||
mount.id == input.mount
|
||||
&& (mount.interactive
|
||||
|| input.kind == "scroll" && mount.scroll.is_some())
|
||||
&& if input.kind.starts_with("viewport-") {
|
||||
mount.canvas_viewport
|
||||
} else if input.kind.starts_with("scene-") {
|
||||
mount.scene3d_viewport && mount.camera_controls
|
||||
} else if input.kind == "scroll" {
|
||||
mount.scroll.is_some()
|
||||
} else {
|
||||
mount.interactive
|
||||
}
|
||||
})
|
||||
}
|
||||
|| self.repl.as_ref().is_none_or(|(context, runtime)| {
|
||||
@@ -447,6 +480,48 @@ impl Worker {
|
||||
"event targets an obsolete or inactive frame",
|
||||
));
|
||||
}
|
||||
if input.kind == "page-zoom" && frame.surface.mode != crate::page::Mode::Canvas
|
||||
|| input.kind == "page-pan" && frame.surface.mode == crate::page::Mode::Window
|
||||
{
|
||||
return RequestProgress::Complete(error_response(
|
||||
StatusCode(400),
|
||||
"camera input is unavailable in this Page mode",
|
||||
));
|
||||
}
|
||||
if matches!(input.kind.as_str(), "viewport-zoom" | "scene-zoom")
|
||||
&& !frame.mounts.iter().any(|mount| {
|
||||
mount.id == input.mount
|
||||
&& input.point.is_some_and(|point| {
|
||||
let point = crate::geometry::Point::new(point[0], point[1]);
|
||||
mount.contains(point)
|
||||
&& mount.viewport_clip.is_some_and(|clip| {
|
||||
mount.view_point(point, true).is_some_and(|point| {
|
||||
point.x >= clip[0]
|
||||
&& point.y >= clip[1]
|
||||
&& point.x < clip[0] + clip[2]
|
||||
&& point.y < clip[1] + clip[3]
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
{
|
||||
return RequestProgress::Complete(error_response(
|
||||
StatusCode(400),
|
||||
"zoom anchor is outside content viewport",
|
||||
));
|
||||
}
|
||||
if (input.kind.starts_with("viewport-") || input.kind.starts_with("scene-"))
|
||||
&& input.point.is_none()
|
||||
&& frame
|
||||
.mounts
|
||||
.iter()
|
||||
.any(|mount| mount.id == input.mount && mount.panel.is_some())
|
||||
{
|
||||
return RequestProgress::Complete(error_response(
|
||||
StatusCode(400),
|
||||
"world-space UI camera input requires a pointer",
|
||||
));
|
||||
}
|
||||
Some(input)
|
||||
} else {
|
||||
None
|
||||
@@ -532,6 +607,7 @@ impl Worker {
|
||||
))
|
||||
}
|
||||
|
||||
/// 恢复已挂起的请求,保持原 HTTP 响应和运行时身份直到最终完成。
|
||||
pub(super) fn resume_request(
|
||||
&mut self,
|
||||
job: &BuildRequest,
|
||||
@@ -548,6 +624,7 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 继续同一 Console/事件输入的展示刷新,不再次执行其业务回调。
|
||||
fn resume_input(
|
||||
&mut self,
|
||||
job: &BuildRequest,
|
||||
@@ -612,6 +689,7 @@ impl Worker {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// 校验输入仍属于当前域,再一次性发布结果、元数据和原生窗口命令。
|
||||
fn finish_input(
|
||||
&mut self,
|
||||
job: &BuildRequest,
|
||||
@@ -709,6 +787,7 @@ impl Worker {
|
||||
|
||||
/// An accepted PUT changes the submission, but does not revoke the old
|
||||
/// runtime. DELETE clears runtime_session and does revoke it.
|
||||
/// 接受较新提交不等于立即销毁旧 VM;候选失败时仍需保留当前可用运行时。
|
||||
pub(super) fn preserve_live_owner(&mut self, current: &NamespaceState) {
|
||||
if let Some((epoch, runtime)) = &mut self.repl {
|
||||
if current.runtime_session == runtime.id {
|
||||
@@ -719,6 +798,7 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 为完整 PUT 建立隔离候选,保留旧运行时直到新候选完整成功。
|
||||
fn begin_program_request(
|
||||
&mut self,
|
||||
job: &BuildRequest,
|
||||
@@ -771,6 +851,7 @@ impl Worker {
|
||||
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
|
||||
}
|
||||
|
||||
/// 推进候选的布局与视口收敛,检查其是否已被新提交撤销。
|
||||
fn resume_candidate(
|
||||
&mut self,
|
||||
job: &BuildRequest,
|
||||
@@ -818,6 +899,7 @@ impl Worker {
|
||||
RequestProgress::Complete(self.finish_candidate(job, namespace, notify, id, result))
|
||||
}
|
||||
|
||||
/// 候选成功才切换活跃 VM 和文档;失败保留先前已发布程序。
|
||||
fn finish_candidate(
|
||||
&mut self,
|
||||
job: &BuildRequest,
|
||||
@@ -866,6 +948,7 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理隔离表达式或一次性文档编译;编译前后核对 submission,只发布仍有效的完整快照。
|
||||
fn build(
|
||||
job: &BuildRequest,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 把 Aster 编译/执行与 Tessera 展示生命周期连接起来。Candidate 拥有 VM 与宿主根,ReplSession 保留实际运行中的全局环境。
|
||||
//! 完整源码 PUT 使用隔离候选;Console 输入则在已有 VM 上执行,先前发生的写入不等于可以回放或事务回滚。
|
||||
//! 刷新状态机遍历窗口并逐步推进布局/绘制。完成所有必要轮次才形成文档;取消会释放挂起调用并恢复未提交的 Mount。
|
||||
//!
|
||||
//! Compile complete source in a fresh VM or retain a worker-owned Console VM.
|
||||
//! Console retains mounted native references and publishes immutable frames after successful inputs.
|
||||
use anyhow::{Result, bail};
|
||||
@@ -22,6 +26,7 @@ const LIMITS: ExecutionLimits = ExecutionLimits {
|
||||
const MAX_AST_NODES: usize = 10_000;
|
||||
const MAX_AST_DEPTH: usize = 64;
|
||||
|
||||
/// 把源输入编译并执行为一次独立结果,最终将可展示对象转换为检查后的文档。
|
||||
pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderDocument> {
|
||||
if source.len() > MAX_SOURCE_BYTES {
|
||||
bail!("source exceeds the 1 MiB limit");
|
||||
@@ -106,6 +111,7 @@ pub fn compile(source: &str, namespace: &str, submission: u64) -> Result<RenderD
|
||||
}
|
||||
|
||||
/// Evaluate exactly one expression in a disposable VM; never touch the published page.
|
||||
/// 在隔离执行域求一个表达式结果;不会把结果作为持久 Console 环境发布。
|
||||
pub fn evaluate(source: &str) -> Result<String> {
|
||||
if source.len() > MAX_SOURCE_BYTES {
|
||||
bail!("expression exceeds the 1 MiB limit");
|
||||
@@ -143,6 +149,7 @@ pub fn evaluate(source: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 Script/Module 计划编译各根节点,为后续执行准备可加载模板。
|
||||
fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::CompiledCode>> {
|
||||
compile_plan_with_globals(
|
||||
source,
|
||||
@@ -155,6 +162,7 @@ fn compile_plan(source: &SourceUnit, plan: &SourcePlan) -> Result<Vec<compiler::
|
||||
)
|
||||
}
|
||||
|
||||
/// 结合已有全局信息编译本轮输入,保留持久 Console 的名称解析上下文。
|
||||
fn compile_plan_with_globals(
|
||||
source: &SourceUnit,
|
||||
plan: &SourcePlan,
|
||||
@@ -200,31 +208,39 @@ fn compile_plan_with_globals(
|
||||
|
||||
/// One real VM, owned only by the existing execution worker. No history replay.
|
||||
/// The UI bridge shares the same managed object heap and publishes atomic frames.
|
||||
/// 指定窗口尚未完成的绘制工作,文档与源续体必须保持配对。
|
||||
struct PendingPaint {
|
||||
window: u64,
|
||||
document: RenderDocument,
|
||||
work: Box<crate::source_paint::Work>,
|
||||
}
|
||||
|
||||
/// 跨 worker 轮次的全窗口刷新状态,保存已完成草稿和当前布局/绘制,直到稳定再发布。
|
||||
struct RefreshWork {
|
||||
// 已完成窗口的草稿先留在本轮;所有窗口收敛后才合成一次完整发布。
|
||||
documents: std::collections::BTreeMap<u64, RenderDocument>,
|
||||
windows: std::collections::VecDeque<u64>,
|
||||
current: Option<u64>,
|
||||
root: Option<std::sync::Arc<crate::ui_bridge::UiNode>>,
|
||||
// 续体必须保留其 owning VM;取消时显式关闭,不能靠丢弃 Rust 值。
|
||||
pending: Option<Box<crate::ui_bridge::PendingLayout>>,
|
||||
paint: Option<Box<PendingPaint>>,
|
||||
// pass_changed 要求再跑一轮确认稳定,changed 记录整次刷新是否有可发布变化。
|
||||
pass_start: bool,
|
||||
pass_changed: bool,
|
||||
changed: bool,
|
||||
work: usize,
|
||||
// 记录本轮尺寸目标,下一次 worker 轮次据此判断是否需要重定向。
|
||||
viewports: Option<std::collections::BTreeMap<u64, crate::layout::Size>>,
|
||||
}
|
||||
|
||||
/// 刷新仍在进行或已完成;完成也可能没有新文档需要发布。
|
||||
enum RefreshProgress {
|
||||
Pending,
|
||||
Complete(Option<Box<RenderDocument>>),
|
||||
}
|
||||
|
||||
/// 持久源运行时与其桥接会话,保存实际全局对象、代码投影、全窗口文档和挂起刷新。
|
||||
pub struct ReplSession {
|
||||
candidate: Candidate,
|
||||
bridge: ui_bridge::UiBridge,
|
||||
@@ -238,6 +254,7 @@ pub struct ReplSession {
|
||||
documents: std::collections::BTreeMap<u64, RenderDocument>,
|
||||
refresh: Option<RefreshWork>,
|
||||
}
|
||||
/// 一次 Console/事件操作的标量显示结果与可选完整文档,避免直接跨线程暴露托管返回值。
|
||||
pub struct ReplResult {
|
||||
pub value: String,
|
||||
pub document: Option<RenderDocument>,
|
||||
@@ -248,6 +265,7 @@ impl ReplSession {
|
||||
/// Compile once into an isolated candidate, then keep its actual heap and
|
||||
/// callback environments alive after the first successful publication.
|
||||
#[cfg(test)]
|
||||
/// 测试用同步装载入口:建立完整候选并排空初始刷新,返回真实运行时与完成文档。
|
||||
pub fn load(
|
||||
source: &str,
|
||||
namespace: &str,
|
||||
@@ -258,6 +276,7 @@ impl ReplSession {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 测试用同步装载入口:建立完整候选并排空初始刷新,返回真实运行时与完成文档。
|
||||
pub fn load_with_viewport(
|
||||
source: &str,
|
||||
namespace: &str,
|
||||
@@ -275,6 +294,7 @@ impl ReplSession {
|
||||
Ok((runtime, document))
|
||||
}
|
||||
|
||||
/// 建立可由 worker 分轮推进的完整源码候选,初始布局不必在一次调用里排空。
|
||||
pub fn load_incremental(
|
||||
source: &str,
|
||||
namespace: &str,
|
||||
@@ -297,6 +317,7 @@ impl ReplSession {
|
||||
Ok((runtime, document))
|
||||
}
|
||||
|
||||
/// 建立隔离候选、安装宿主根并执行完整源码;候选尚未完成布局时不替换活跃程序。
|
||||
fn prepare_program(
|
||||
source: &str,
|
||||
namespace: &str,
|
||||
@@ -368,6 +389,7 @@ impl ReplSession {
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// 建立空的持久 Console 运行时,并安装当前逻辑视口输入。
|
||||
pub fn new_with_viewport(id: u64, viewport: Option<crate::layout::Size>) -> Result<Self> {
|
||||
let mut runtime = Self::new(id)?;
|
||||
runtime
|
||||
@@ -376,6 +398,7 @@ impl ReplSession {
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(id: u64) -> Result<Self> {
|
||||
let mut candidate = Candidate::new()?;
|
||||
let globals = candidate.vm.new_dict()?;
|
||||
@@ -398,10 +421,12 @@ impl ReplSession {
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
/// 同步执行一个 Console 输入并排空刷新,返回标量结果及可选新文档。
|
||||
pub fn execute(&mut self, source: &str, namespace: &str) -> Result<ReplResult> {
|
||||
self.execute_mode(source, namespace, None)
|
||||
}
|
||||
|
||||
/// 执行一次输入的业务部分,再让展示刷新以可恢复工作跨 worker 轮次推进。
|
||||
pub fn execute_incremental(
|
||||
&mut self,
|
||||
source: &str,
|
||||
@@ -411,6 +436,7 @@ impl ReplSession {
|
||||
self.execute_mode(source, namespace, Some(viewports))
|
||||
}
|
||||
|
||||
/// 在当前真实 VM 中编译并执行新输入,再按同步或分片模式刷新展示;已发生的业务写入不会被源重放回滚。
|
||||
fn execute_mode(
|
||||
&mut self,
|
||||
source: &str,
|
||||
@@ -555,14 +581,17 @@ impl ReplSession {
|
||||
result
|
||||
}
|
||||
|
||||
/// 查询调度器是否仍有需要主动推进的源协程工作。
|
||||
pub fn has_cooperative_tasks(&self) -> bool {
|
||||
self.bridge.has_cooperative_tasks()
|
||||
}
|
||||
|
||||
/// 检查是否存在尚未结束的视觉刷新工作,决定能否接收新的业务变更。
|
||||
pub fn has_pending_refresh(&self) -> bool {
|
||||
self.refresh.is_some()
|
||||
}
|
||||
|
||||
/// 拒绝在未完成的只读刷新内部再次进行业务变更,防止同一 VM 重入。
|
||||
fn require_idle_refresh(&self) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
!self.has_pending_refresh(),
|
||||
@@ -571,6 +600,7 @@ impl ReplSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 在业务变化后开始或继续展示刷新,保证后续输入不会进入未结束的只读视觉调用。
|
||||
fn refresh_after_mutation(
|
||||
&mut self,
|
||||
viewports: Option<&std::collections::BTreeMap<u64, crate::layout::Size>>,
|
||||
@@ -588,6 +618,7 @@ impl ReplSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// 同步排空当前刷新状态机,直到全窗口收敛或出现错误。
|
||||
fn refresh_document(&mut self) -> Result<Option<RenderDocument>> {
|
||||
loop {
|
||||
match self.poll_refresh(false)? {
|
||||
@@ -599,6 +630,7 @@ impl ReplSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// 推进一次刷新状态机,保存挂起的 Work;只有全窗口收敛时才生成可发布文档。
|
||||
fn poll_refresh(&mut self, record_yield: bool) -> Result<RefreshProgress> {
|
||||
let mut work = self.refresh.take().unwrap_or_else(|| RefreshWork {
|
||||
documents: self.documents.clone(),
|
||||
@@ -654,7 +686,9 @@ impl ReplSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一个刷新步骤:刷新绑定、选择窗口、准备快照、推进布局或绘制,并决定是否需要下一轮。
|
||||
fn step_refresh(&mut self, work: &mut RefreshWork) -> Result<bool> {
|
||||
// 阶段一:新一轮先刷新绑定,再取得稳定窗口遍历顺序。
|
||||
if work.pass_start {
|
||||
self.bridge.refresh_bindings(&mut self.candidate.vm)?;
|
||||
work.windows = self.bridge.window_ids()?.into();
|
||||
@@ -663,6 +697,7 @@ impl ReplSession {
|
||||
work.pass_start = false;
|
||||
return Ok(false);
|
||||
}
|
||||
// 阶段二:逐个选择窗口;一轮发生变化时再扫描一轮,直到依赖不再引起新帧。
|
||||
let Some(id) = work.current else {
|
||||
if let Some(id) = work.windows.pop_front() {
|
||||
work.current = Some(id);
|
||||
@@ -675,6 +710,7 @@ impl ReplSession {
|
||||
return Ok(true);
|
||||
};
|
||||
self.bridge.select_window(id)?;
|
||||
// 已进入 paint 的窗口直接恢复原绘制续体,不重放业务回调或布局。
|
||||
if let Some(paint) = work.paint.take() {
|
||||
let PendingPaint {
|
||||
window,
|
||||
@@ -686,6 +722,7 @@ impl ReplSession {
|
||||
.resume_paint_frame(&mut self.candidate.vm, *paint_work)?;
|
||||
return Self::accept_window_paint(work, window, document, progress);
|
||||
}
|
||||
// 阶段三:恢复旧布局,或在快照/依赖确实变化时建立新布局。
|
||||
let progress = if let Some(pending) = work.pending.take() {
|
||||
self.bridge
|
||||
.resume_layout_frame(&mut self.candidate.vm, *pending)?
|
||||
@@ -704,6 +741,7 @@ impl ReplSession {
|
||||
}
|
||||
crate::ui_bridge::LayoutProgress::Complete(frame) => *frame,
|
||||
};
|
||||
// 阶段四:几何完成后建立候选文档,再运行 paint;这里尚未向 GUI 发布。
|
||||
let mut document = RenderDocument {
|
||||
windows: Default::default(),
|
||||
source: self.source.clone(),
|
||||
@@ -732,6 +770,7 @@ impl ReplSession {
|
||||
Self::accept_window_paint(work, id, document, progress)
|
||||
}
|
||||
|
||||
/// 接收指定窗口的绘制进度,将挂起 Work 保留或把完整文档存入本轮结果。
|
||||
fn accept_window_paint(
|
||||
work: &mut RefreshWork,
|
||||
id: u64,
|
||||
@@ -757,6 +796,7 @@ impl ReplSession {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// 收敛后组合所有窗口文档,保留本轮完整结果;没有变化时不额外制造发布。
|
||||
fn finish_refresh(&mut self, mut work: RefreshWork) -> Result<Option<RenderDocument>> {
|
||||
self.bridge.finish_closed(&mut self.candidate.vm)?;
|
||||
let ids = self.bridge.window_ids()?;
|
||||
@@ -796,6 +836,7 @@ impl ReplSession {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 同步更新多个窗口尺寸,复用同一运行时并完成必要刷新。
|
||||
pub fn resize_windows(
|
||||
&mut self,
|
||||
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
|
||||
@@ -803,6 +844,7 @@ impl ReplSession {
|
||||
self.resize_windows_incremental(viewports, true)
|
||||
}
|
||||
|
||||
/// 用最新视口替换旧目标,显式取消未完成布局,同时保留已完成窗口结果和已发生的业务效果。
|
||||
fn retarget_refresh(
|
||||
&mut self,
|
||||
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
|
||||
@@ -846,6 +888,7 @@ impl ReplSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把各窗口最新尺寸送入同一运行时;视口更新可跨轮次挂起,也可由调用方同步排空。
|
||||
pub fn resize_windows_incremental(
|
||||
&mut self,
|
||||
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
|
||||
@@ -900,6 +943,7 @@ impl ReplSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在空闲轮次推进一个刷新步骤,完成时执行统一结果收尾。
|
||||
fn advance_idle_refresh(&mut self) -> Result<Option<RenderDocument>> {
|
||||
let result = self.poll_refresh(true).map(|progress| match progress {
|
||||
RefreshProgress::Pending => None,
|
||||
@@ -908,6 +952,7 @@ impl ReplSession {
|
||||
self.finish_refresh_update(result)
|
||||
}
|
||||
|
||||
/// 统一处理刷新失败、未发布状态清理和运行时引用回收。
|
||||
fn finish_refresh_update(
|
||||
&mut self,
|
||||
result: Result<Option<RenderDocument>>,
|
||||
@@ -925,11 +970,13 @@ impl ReplSession {
|
||||
|
||||
/// Deliver ready work on the owning VM worker, never from the GUI or a
|
||||
/// provider thread. A failed callback stops the same shared fault domain.
|
||||
/// 同步推进可交付任务和必要刷新,供不使用分片 worker 的调用方使用。
|
||||
pub fn poll_tasks(&mut self) -> Result<Option<RenderDocument>> {
|
||||
self.poll_tasks_mode(None)
|
||||
}
|
||||
|
||||
/// Idle delivery starts at most one callback, then parks its layout work.
|
||||
/// 按有界任务交付量推进,再以可挂起刷新处理变化。
|
||||
pub fn poll_tasks_incremental(
|
||||
&mut self,
|
||||
viewports: &std::collections::BTreeMap<u64, crate::layout::Size>,
|
||||
@@ -937,6 +984,7 @@ impl ReplSession {
|
||||
self.poll_tasks_mode(Some(viewports))
|
||||
}
|
||||
|
||||
/// 按本轮预算交付已完成任务或推进协程,再对其业务变化执行相同的展示刷新流程。
|
||||
fn poll_tasks_mode(
|
||||
&mut self,
|
||||
viewports: Option<&std::collections::BTreeMap<u64, crate::layout::Size>>,
|
||||
@@ -973,12 +1021,14 @@ impl ReplSession {
|
||||
result
|
||||
}
|
||||
|
||||
/// 取得本轮已稳定运行时的窗口命令,挂起刷新时禁止提前取走。
|
||||
pub fn take_window_commands(&self) -> Result<Vec<crate::window::Command>> {
|
||||
self.require_idle_refresh()?;
|
||||
self.bridge.take_window_commands(&self.candidate.vm)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 测试用单窗口尺寸更新入口,遵循与原生尺寸发布相同的校验和刷新规则。
|
||||
pub fn resize(
|
||||
&mut self,
|
||||
viewport: Option<crate::layout::Size>,
|
||||
@@ -1000,6 +1050,7 @@ impl ReplSession {
|
||||
result
|
||||
}
|
||||
|
||||
/// 关闭尚未完成的刷新任务,释放续体并恢复未提交 Mount;不能只丢弃 Work 结构。
|
||||
fn cancel_pending_refresh(&mut self) -> Result<()> {
|
||||
if let Some(mut work) = self.refresh.take() {
|
||||
if let Some(paint) = work.paint.take() {
|
||||
@@ -1017,6 +1068,7 @@ impl ReplSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 有界关闭任务、挂载和窗口,释放运行时根,并报告清理结果。
|
||||
pub fn shutdown(&mut self) -> Result<(usize, usize, usize)> {
|
||||
self.cancel_pending_refresh()?;
|
||||
let callbacks = !self.faulted;
|
||||
@@ -1024,6 +1076,7 @@ impl ReplSession {
|
||||
self.bridge.shutdown(&mut self.candidate.vm, callbacks)
|
||||
}
|
||||
|
||||
/// 撤销指定窗口的运行时展示资源,防止后续刷新重新发布它。
|
||||
pub fn retire_window(&mut self, id: u64) -> Result<()> {
|
||||
self.require_idle_refresh()?;
|
||||
// This path is exclusively host cleanup after the domain has stopped.
|
||||
@@ -1036,23 +1089,28 @@ impl ReplSession {
|
||||
.reclaim(&mut self.candidate.vm, &mut self.candidate.roots)
|
||||
}
|
||||
|
||||
/// 查询当前运行时是否仍接受对应的窗口或拖放目标。
|
||||
pub fn accepts_tab_drop(&self, input: &crate::events::Input) -> bool {
|
||||
self.bridge.accepts_tab_drop(&self.candidate.vm, input)
|
||||
}
|
||||
|
||||
/// 查询当前运行时是否仍接受对应的窗口或拖放目标。
|
||||
pub fn accepts_window(&self, id: u64) -> bool {
|
||||
self.bridge.accepts_window(id)
|
||||
}
|
||||
|
||||
/// 报告该运行时是否已经停止正常业务执行,供 worker 选择故障清理路径。
|
||||
pub fn is_faulted(&self) -> bool {
|
||||
self.faulted
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// 同步分发一次已校验输入并完成展示刷新。
|
||||
pub fn dispatch(&mut self, input: &crate::events::Input) -> Result<ReplResult> {
|
||||
self.dispatch_mode(input, None)
|
||||
}
|
||||
|
||||
/// 分发一次输入回调,再把视觉刷新交给 worker 分片推进。
|
||||
pub fn dispatch_incremental(
|
||||
&mut self,
|
||||
input: &crate::events::Input,
|
||||
@@ -1061,6 +1119,7 @@ impl ReplSession {
|
||||
self.dispatch_mode(input, Some(viewports))
|
||||
}
|
||||
|
||||
/// 在正确窗口上下文执行输入回调,再刷新展示;故障时停止受影响域并清理未完成工作。
|
||||
fn dispatch_mode(
|
||||
&mut self,
|
||||
input: &crate::events::Input,
|
||||
@@ -1107,11 +1166,13 @@ impl ReplSession {
|
||||
}
|
||||
|
||||
impl Drop for ReplSession {
|
||||
/// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cancel_pending_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归修正代码投影的源行号,使拼接输入仍可定位到原始文本。
|
||||
fn shift_node_lines(node: &mut CodeNode, offset: usize) {
|
||||
node.start_line += offset;
|
||||
node.end_line += offset;
|
||||
@@ -1119,6 +1180,7 @@ fn shift_node_lines(node: &mut CodeNode, offset: usize) {
|
||||
shift_node_lines(child, offset);
|
||||
}
|
||||
}
|
||||
/// 把 VM 返回值转换为 Console 的可显示表示,不让借用的托管引用跨线程。
|
||||
fn value_repr(vm: &Vm, value: &Value) -> Result<String> {
|
||||
Ok(match value {
|
||||
Value::Nil => "nil".into(),
|
||||
@@ -1129,6 +1191,7 @@ fn value_repr(vm: &Vm, value: &Value) -> Result<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 拥有 VM、全局字典与宿主保留根;关闭时按顺序释放,失败也必须收尾。
|
||||
struct Candidate {
|
||||
vm: Vm,
|
||||
roots: Vec<ObjectRef>,
|
||||
@@ -1136,6 +1199,7 @@ struct Candidate {
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
/// 创建独立 VM,先不保留任何宿主根或全局字典;后续安装失败也由该候选负责清理。
|
||||
fn new() -> Result<Self> {
|
||||
Ok(Self {
|
||||
vm: Vm::new()?,
|
||||
@@ -1144,6 +1208,7 @@ impl Candidate {
|
||||
})
|
||||
}
|
||||
|
||||
/// 释放候选拥有的宿主根并清理 VM;通过显式关闭保证错误也能被报告。
|
||||
fn close(&mut self) -> Result<()> {
|
||||
if let Some(globals) = self.globals.take() {
|
||||
self.vm.dict_clear(globals)?;
|
||||
@@ -1160,6 +1225,7 @@ impl Candidate {
|
||||
}
|
||||
|
||||
impl Drop for Candidate {
|
||||
/// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。
|
||||
fn drop(&mut self) {
|
||||
// Explicit close reports accounting errors; dropping the fresh VM is
|
||||
// also the final cleanup boundary on every early return or unwind.
|
||||
@@ -1167,6 +1233,7 @@ impl Drop for Candidate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 VM 错误与当前源位置/调用栈组合成用户可读的诊断。
|
||||
pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error {
|
||||
let locations = vm
|
||||
.last_traceback()
|
||||
@@ -1187,6 +1254,7 @@ pub(crate) fn runtime_error(vm: &Vm, error: VmError) -> anyhow::Error {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
/// 编译前的语法节点数量/嵌套预算,限制输入结构复杂度。
|
||||
struct AstBudget {
|
||||
depth: usize,
|
||||
nodes: usize,
|
||||
@@ -1194,6 +1262,7 @@ struct AstBudget {
|
||||
|
||||
impl Visitor for AstBudget {
|
||||
type Error = anyhow::Error;
|
||||
/// 更新源结构遍历的深度/数量预算,拒绝超出允许范围的语法树。
|
||||
fn enter_node(&mut self, _span: Span) -> Result<()> {
|
||||
self.depth += 1;
|
||||
self.nodes += 1;
|
||||
@@ -1202,11 +1271,13 @@ impl Visitor for AstBudget {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// 离开当前语法节点时归还嵌套深度;累计节点数保留,不能通过退出子树绕过总量预算。
|
||||
fn leave_node(&mut self) {
|
||||
self.depth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 把输入语法结构转换为 Sources 工具使用的 CodeNode 层次,保存原始源码位置。
|
||||
fn architecture(
|
||||
source: &SourceUnit,
|
||||
plan: &SourcePlan,
|
||||
@@ -1246,6 +1317,7 @@ fn architecture(
|
||||
tree.node(namespace.to_owned(), kind, span, children)
|
||||
}
|
||||
|
||||
/// 建立源文本每行起点表,供字节 span 转换为行列位置。
|
||||
fn line_starts(source: &str) -> Vec<usize> {
|
||||
let bytes = source.as_bytes();
|
||||
let mut starts = vec![0];
|
||||
@@ -1257,6 +1329,7 @@ fn line_starts(source: &str) -> Vec<usize> {
|
||||
starts
|
||||
}
|
||||
|
||||
/// 构造 Sources 语法投影所需的源码与定位辅助状态。
|
||||
struct Architecture {
|
||||
line_starts: Vec<usize>,
|
||||
next_id: usize,
|
||||
@@ -1264,6 +1337,7 @@ struct Architecture {
|
||||
}
|
||||
|
||||
impl Architecture {
|
||||
/// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。
|
||||
fn node(&mut self, name: String, kind: &str, span: Span, children: Vec<CodeNode>) -> CodeNode {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
@@ -1278,6 +1352,7 @@ impl Architecture {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。
|
||||
fn declaration(&mut self, declaration: &Decl) -> CodeNode {
|
||||
match &declaration.kind {
|
||||
DeclKind::Function(function) => self.function(function),
|
||||
@@ -1293,6 +1368,7 @@ impl Architecture {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。
|
||||
fn function(&mut self, function: &frontend::ast::Function) -> CodeNode {
|
||||
let children = function
|
||||
.body
|
||||
@@ -1308,6 +1384,7 @@ impl Architecture {
|
||||
)
|
||||
}
|
||||
|
||||
/// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。
|
||||
fn statement(&mut self, statement: &Stmt) -> CodeNode {
|
||||
match &statement.kind {
|
||||
StmtKind::MemberAssignment(assignment) => {
|
||||
@@ -1351,6 +1428,7 @@ impl Architecture {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将相应语法结构投影为带源码范围的 CodeNode,供 Sources 展示和定位。
|
||||
fn expression(&mut self, expr: &Expr) -> CodeNode {
|
||||
let (name, kind, children) = match &expr.kind {
|
||||
ExprKind::Name(name) => (name.clone(), "name", vec![]),
|
||||
|
||||
+1667
-9
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
//! 区分完整 Module 与顶层 Script,并保留输入源码位置。
|
||||
//! 解析包装或片段拼接产生的 span 偏移要递归修正,保证诊断和 Sources 视图仍指向用户原始行列。
|
||||
//!
|
||||
//! Feed a source snapshot to Aster's existing module or interactive entry points.
|
||||
//! Statement syntax, tokens and ASTs all remain Aster-owned. The host only batches
|
||||
//! complete interactive inputs, just as a REPL feeds consecutive inputs to a VM.
|
||||
@@ -7,12 +10,14 @@ use frontend::{
|
||||
ast::{AstRoot, Decl, DeclKind, Expr, ExprKind, InteractiveInput, Stmt, StmtKind},
|
||||
};
|
||||
|
||||
/// 待编译输入是完整模块还是顶层脚本;两者保留不同根结构但使用统一后续接口。
|
||||
pub enum SourcePlan {
|
||||
Module(AstRoot),
|
||||
Script(Vec<AstRoot>),
|
||||
}
|
||||
|
||||
impl SourcePlan {
|
||||
/// 按输入计划返回需要编译的语法根,隐藏 Script 与 Module 组织差异。
|
||||
pub fn roots(&self) -> &[AstRoot] {
|
||||
match self {
|
||||
Self::Module(root) => std::slice::from_ref(root),
|
||||
@@ -21,6 +26,7 @@ impl SourcePlan {
|
||||
}
|
||||
}
|
||||
|
||||
/// 把外部源码或属性表示转换为内部枚举/结构,并反馈不支持的输入。
|
||||
pub fn parse(source: &SourceUnit) -> Result<SourcePlan> {
|
||||
if let Ok(module) = frontend::parse_source_ast(source, ParseMode::Module) {
|
||||
return Ok(SourcePlan::Module(module));
|
||||
@@ -78,11 +84,13 @@ pub fn parse(source: &SourceUnit) -> Result<SourcePlan> {
|
||||
Ok(SourcePlan::Script(roots))
|
||||
}
|
||||
|
||||
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
|
||||
fn shift_span(span: &mut Span, offset: usize) {
|
||||
span.start += offset;
|
||||
span.end += offset;
|
||||
}
|
||||
|
||||
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
|
||||
fn shift_input(input: &mut InteractiveInput, offset: usize) {
|
||||
shift_span(&mut input.span, offset);
|
||||
for statement in &mut input.statements {
|
||||
@@ -90,6 +98,7 @@ fn shift_input(input: &mut InteractiveInput, offset: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
|
||||
fn shift_statement(statement: &mut Stmt, offset: usize) {
|
||||
shift_span(&mut statement.span, offset);
|
||||
match &mut statement.kind {
|
||||
@@ -120,6 +129,7 @@ fn shift_statement(statement: &mut Stmt, offset: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
|
||||
fn shift_declaration(declaration: &mut Decl, offset: usize) {
|
||||
shift_span(&mut declaration.span, offset);
|
||||
match &mut declaration.kind {
|
||||
@@ -142,6 +152,7 @@ fn shift_declaration(declaration: &mut Decl, offset: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
|
||||
fn shift_function(function: &mut frontend::ast::Function, offset: usize) {
|
||||
shift_span(&mut function.span, offset);
|
||||
shift_span(&mut function.name.span, offset);
|
||||
@@ -157,6 +168,7 @@ fn shift_function(function: &mut frontend::ast::Function, offset: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归调整对应语法节点的源码范围,保持片段解析后的错误位置仍指向原输入。
|
||||
fn shift_expression(expression: &mut Expr, offset: usize) {
|
||||
shift_span(&mut expression.span, offset);
|
||||
match &mut expression.kind {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 托管事件与订阅的传播实现。Connection 持有回调引用,Event 保存目标、阶段与停止传播状态。
|
||||
//! 分发沿出现关系执行 capture → target → bubble,并处理默认行为;只有同一 VM 中的回调可以接触托管对象。
|
||||
//!
|
||||
//! Managed subscriptions and occurrence-specific propagation. A validated VM
|
||||
//! function sequences calls, so all handlers share one execution budget.
|
||||
use crate::{block, mount::Mount, ui_bridge::attach_desktop_type};
|
||||
@@ -7,10 +10,12 @@ use endor_core::{
|
||||
};
|
||||
|
||||
const MAX_DELIVERIES: usize = 10_000;
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
|
||||
/// 检查订阅回调满足事件处理器调用契约,避免等到实际点击才暴露签名错误。
|
||||
pub fn validate_handler(
|
||||
store: &endor_core::ObjectStore,
|
||||
function: ObjectRef,
|
||||
@@ -30,6 +35,7 @@ pub fn validate_handler(
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// 本次传播的托管事件状态,包含目标、当前阶段和阻止默认/停止传播控制。
|
||||
pub struct Event {
|
||||
dispatch: Option<WeakObjectRef>,
|
||||
group: usize,
|
||||
@@ -38,36 +44,45 @@ pub struct Event {
|
||||
prevented: bool,
|
||||
}
|
||||
impl ObjectPayload for Event {
|
||||
/// 声明此 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-event"
|
||||
}
|
||||
/// 声明此 payload 是否允许使用托管属性存储,具体字段仍由类型与调用检查约束。
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 实际拥有回调引用的托管连接,挂在目标的订阅关系中参与 GC。
|
||||
pub struct Connection {
|
||||
pub handler: Option<ObjectRef>,
|
||||
pub kind: String,
|
||||
pub capture: bool,
|
||||
}
|
||||
impl ObjectPayload for Connection {
|
||||
/// 声明此 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-event-connection"
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
if let Some(handler) = self.handler {
|
||||
visit(handler);
|
||||
@@ -77,22 +92,27 @@ impl ObjectPayload for Connection {
|
||||
|
||||
/// A token never retains its target or callback. The Block owns the connection.
|
||||
#[derive(Debug)]
|
||||
/// 源代码可保留的订阅能力包装,用于取消对应连接。
|
||||
pub struct Subscription {
|
||||
pub connection: WeakObjectRef,
|
||||
}
|
||||
impl ObjectPayload for Subscription {
|
||||
/// 声明此 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-subscription"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
|
||||
enum Method {
|
||||
Stop,
|
||||
StopImmediate,
|
||||
@@ -102,10 +122,12 @@ enum Method {
|
||||
Connected,
|
||||
}
|
||||
impl NativeCallable for Method {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(owner)] = args else {
|
||||
return Err(invalid("event method receiver"));
|
||||
@@ -179,17 +201,21 @@ impl NativeCallable for Method {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 安装到托管类型上的原生构造调用适配器。
|
||||
pub struct Constructor {
|
||||
pub signature: Option<endor_core::CallSignature>,
|
||||
}
|
||||
impl NativeCallable for Constructor {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
self.signature.clone()
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::range(2, usize::MAX))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(definition), Value::Text(kind), values @ ..] = args else {
|
||||
return Err(invalid("Event requires an event kind"));
|
||||
@@ -212,6 +238,7 @@ impl NativeCallable for Constructor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 建立本次分发拥有的托管 Event,保存目标、输入与传播控制状态。
|
||||
pub fn new_event(
|
||||
cx: &mut NativeContext<'_>,
|
||||
definition: ObjectRef,
|
||||
@@ -223,7 +250,7 @@ pub fn new_event(
|
||||
let event = cx.allocate(Event::default())?;
|
||||
cx.attach_type(event, definition)?;
|
||||
cx.set_attr(event, "kind", Value::Text(kind.into()))?;
|
||||
for name in ["target", "mount", "currentTarget", "currentMount"] {
|
||||
for name in ["target", "mount", "currentTarget", "currentMount", "hit"] {
|
||||
cx.set_attr(event, name, Value::Nil)?;
|
||||
}
|
||||
cx.set_attr(event, "phase", Value::Text("none".into()))?;
|
||||
@@ -231,19 +258,23 @@ pub fn new_event(
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 带事件类型签名和 owner 的原生方法包装。
|
||||
struct TypedMethod {
|
||||
method: Method,
|
||||
signature: endor_core::CallSignature,
|
||||
}
|
||||
impl NativeCallable for TypedMethod {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
self.method.invoke(cx, args)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
@@ -292,12 +323,14 @@ pub fn install(
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 本次递送选择的对象方法或显式订阅处理器。
|
||||
enum Handler {
|
||||
Token(ObjectRef),
|
||||
Legacy(ObjectRef),
|
||||
ObjectMethod,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 事件传播过程中将要调用的一项处理器及其阶段/目标上下文。
|
||||
struct Delivery {
|
||||
event: ObjectRef,
|
||||
owner: ObjectRef,
|
||||
@@ -307,10 +340,12 @@ struct Delivery {
|
||||
handler: Handler,
|
||||
}
|
||||
impl NativeCallable for Delivery {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mut event = cx.store().payload::<Event>(self.event, "Event")?.clone();
|
||||
if event.immediate || event.stopped_at.is_some_and(|group| group != self.group) {
|
||||
@@ -363,12 +398,15 @@ impl NativeCallable for Delivery {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 传播结束后的默认行为收尾步骤。
|
||||
struct Finish(ObjectRef);
|
||||
impl NativeCallable for Finish {
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mut event = cx.store().payload::<Event>(self.0, "Event")?.clone();
|
||||
event.dispatch = None;
|
||||
@@ -383,6 +421,7 @@ impl NativeCallable for Finish {
|
||||
/// Snapshot the selected occurrence's route and listener order. Disconnection
|
||||
/// before a listener's turn skips it; newly registered listeners wait for the
|
||||
/// next dispatch. A code event without a Mount visits only its target object.
|
||||
/// 按 Mount 祖先路径执行捕获、目标与冒泡阶段,再处理对应默认行为。
|
||||
pub fn dispatch(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
|
||||
+167
-6
@@ -1,9 +1,16 @@
|
||||
//! 原生窗口输入到执行 worker 的纯数据传输。Input 只含窗口/会话/版本/Mount 身份及有限坐标。
|
||||
//! 连续相机和滚动输入出队时可更新帧版本,但不改变捕获目标和所属 VM;离散点击仍保留严格的过期帧语义。
|
||||
//! 队列有界且当前逐项经本地 HTTP 发送;这不意味着输入已经按显示帧合并,也不是无损无限队列。
|
||||
//!
|
||||
//! Native input transport. Frame/mount identities and optional logical pointer positions cross to the VM worker.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpStream,
|
||||
sync::mpsc::{SyncSender, sync_channel},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
mpsc::{SyncSender, sync_channel},
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
@@ -11,6 +18,7 @@ use winit::event_loop::EventLoopProxy;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
/// 拖放目标窗口、目标布局版本和插入位置,源标签身份由外层 Input 保存。
|
||||
pub struct TabDrop {
|
||||
pub window: u64,
|
||||
pub layout_version: u64,
|
||||
@@ -19,6 +27,7 @@ pub struct TabDrop {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
/// 原生或 HTTP 输入的纯数据报文;身份与坐标必须同时通过 worker 和能力校验。
|
||||
pub struct Input {
|
||||
#[serde(default = "default_window")]
|
||||
pub window: u64,
|
||||
@@ -38,11 +47,13 @@ pub struct Input {
|
||||
pub screen_position: Option<[i32; 2]>,
|
||||
}
|
||||
|
||||
/// 返回默认主窗口身份,供省略窗口字段的兼容输入使用。
|
||||
fn default_window() -> u64 {
|
||||
crate::window::MAIN_WINDOW
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// 检查当前输入或发布值的不变量,调用方据校验结果决定是否继续。
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.window == 0 {
|
||||
return Err("window identity must be nonzero");
|
||||
@@ -73,12 +84,55 @@ impl Input {
|
||||
{
|
||||
return Err("screen_position is only accepted for tab-detach with bounded coordinates");
|
||||
}
|
||||
if matches!(
|
||||
self.kind.as_str(),
|
||||
"page-pan"
|
||||
| "page-zoom"
|
||||
| "viewport-pan"
|
||||
| "viewport-zoom"
|
||||
| "scene-orbit"
|
||||
| "scene-pan"
|
||||
| "scene-zoom"
|
||||
) {
|
||||
let viewport = self.kind.starts_with("viewport-") || self.kind.starts_with("scene-");
|
||||
let valid_delta = self.delta.is_some_and(|delta| {
|
||||
delta
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||||
});
|
||||
let valid_point = self.point.is_none_or(|point| {
|
||||
point
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0 && (viewport || *v >= 0.0))
|
||||
});
|
||||
if !valid_delta
|
||||
|| !valid_point
|
||||
|| (if viewport {
|
||||
self.mount == 0
|
||||
} else {
|
||||
self.mount != 0
|
||||
})
|
||||
|| self.press_point.is_some()
|
||||
|| self.screen_position.is_some()
|
||||
{
|
||||
return Err("invalid page camera input");
|
||||
}
|
||||
if self.kind.ends_with("-zoom")
|
||||
&& (self.point.is_none()
|
||||
|| self
|
||||
.delta
|
||||
.is_none_or(|delta| !(0.1..=10.0).contains(&delta[0]) || delta[1] != 0.0))
|
||||
{
|
||||
return Err("page-zoom requires an anchor and positive scale factor");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.kind == "scroll" {
|
||||
return match (self.point, self.press_point, self.delta) {
|
||||
(Some(point), None, Some(delta))
|
||||
if point
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && (0.0..=1_000_000.0).contains(v))
|
||||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||||
&& delta
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && v.abs() <= 1_000_000.0) =>
|
||||
@@ -98,7 +152,7 @@ impl Input {
|
||||
&& point
|
||||
.into_iter()
|
||||
.chain(press)
|
||||
.all(|value| value.is_finite() && (0.0..=1_000_000.0).contains(&value)) =>
|
||||
.all(|value| value.is_finite() && value.abs() <= 1_000_000.0) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
@@ -107,14 +161,27 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// 原生输入的有界发送端,后台线程串行发往本地 API;所有窗口共享权威发布状态。
|
||||
pub struct Client(SyncSender<Input>);
|
||||
impl Client {
|
||||
pub fn new(proxy: EventLoopProxy<crate::app::AppEvent>) -> anyhow::Result<Self> {
|
||||
let (sender, receiver) = sync_channel::<Input>(8);
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(
|
||||
proxy: EventLoopProxy<crate::app::AppEvent>,
|
||||
namespace: Arc<RwLock<crate::document::NamespaceState>>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let (sender, receiver) = sync_channel::<Input>(64);
|
||||
thread::Builder::new()
|
||||
.name("tessera-native-events".into())
|
||||
.spawn(move || {
|
||||
for input in receiver {
|
||||
for mut input in receiver {
|
||||
if let Ok(current) = namespace.read() {
|
||||
if !refresh_continuous_input(&mut input, ¤t) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = send(&input) {
|
||||
let _ = proxy.send_event(crate::app::AppEvent::RuntimeFailed {
|
||||
message: error.to_string(),
|
||||
@@ -124,11 +191,56 @@ impl Client {
|
||||
})?;
|
||||
Ok(Self(sender))
|
||||
}
|
||||
/// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。
|
||||
pub fn send(&self, input: Input) -> bool {
|
||||
self.0.try_send(input).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
// Native gestures retain the captured Mount across publications. Refresh only
|
||||
// the version at dequeue, never session, window or target. HTTP callers and
|
||||
// discrete actions still receive strict stale-frame rejection.
|
||||
/// 连续手势出队时只更新有效目标的帧版本;保留原 session、窗口和 Mount,离散事件不自动重试。
|
||||
fn refresh_continuous_input(input: &mut Input, current: &crate::document::NamespaceState) -> bool {
|
||||
if !matches!(
|
||||
input.kind.as_str(),
|
||||
"viewport-pan" | "viewport-zoom" | "scene-pan" | "scene-orbit" | "scene-zoom" | "scroll"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
let frame = if input.window == crate::window::MAIN_WINDOW {
|
||||
Some(¤t.document.frame)
|
||||
} else {
|
||||
current
|
||||
.document
|
||||
.windows
|
||||
.get(&input.window)
|
||||
.map(|document| &document.frame)
|
||||
};
|
||||
let Some(frame) = frame else {
|
||||
return false;
|
||||
};
|
||||
if current.runtime_session != input.session
|
||||
|| frame.session != input.session
|
||||
|| frame.fault.is_some()
|
||||
|| !frame.mounts.iter().any(|mount| {
|
||||
mount.id == input.mount
|
||||
&& if input.kind.starts_with("viewport-") {
|
||||
mount.canvas_viewport
|
||||
} else if input.kind.starts_with("scene-") {
|
||||
mount.scene3d_viewport && mount.camera_controls
|
||||
} else {
|
||||
mount.scroll.is_some()
|
||||
}
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
input.layout_version = frame.layout_version;
|
||||
true
|
||||
}
|
||||
|
||||
/// 通过有界原生输入客户端或本地 HTTP 发送纯数据事件;过期版本按既定协议处理。
|
||||
fn send(input: &Input) -> anyhow::Result<()> {
|
||||
let body = serde_json::to_string(input)?;
|
||||
let mut stream =
|
||||
@@ -165,6 +277,55 @@ fn send(input: &Input) -> anyhow::Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn queued_native_pan_keeps_each_delta_and_rejects_replaced_targets() {
|
||||
let (mut session, document) = crate::endor_host::ReplSession::load(
|
||||
"view=CanvasViewport(200,100,Scene2D(RectBlock(20,20,0)))\npage=Page()\npage.render(view)",
|
||||
"app.main", 1, 912,
|
||||
).unwrap();
|
||||
let mount = document
|
||||
.frame
|
||||
.mounts
|
||||
.iter()
|
||||
.find(|mount| mount.canvas_viewport)
|
||||
.unwrap()
|
||||
.id;
|
||||
let original = Input {
|
||||
window: 1,
|
||||
session: 912,
|
||||
layout_version: document.frame.layout_version,
|
||||
mount,
|
||||
kind: "viewport-pan".into(),
|
||||
point: Some([50., 50.]),
|
||||
delta: Some([3., 0.]),
|
||||
press_point: None,
|
||||
tab_drop: None,
|
||||
screen_position: None,
|
||||
};
|
||||
let mut current = crate::document::NamespaceState {
|
||||
runtime_session: 912,
|
||||
document,
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..12 {
|
||||
let mut queued = original.clone();
|
||||
assert!(refresh_continuous_input(&mut queued, ¤t));
|
||||
current.document = session.dispatch(&queued).unwrap().document.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
session.execute("view.camera.x", "app.main").unwrap().value,
|
||||
"36"
|
||||
);
|
||||
let mut click = original.clone();
|
||||
click.kind = "click".into();
|
||||
assert!(refresh_continuous_input(&mut click, ¤t));
|
||||
assert_eq!(click.layout_version, original.layout_version);
|
||||
current.runtime_session = 913;
|
||||
assert!(!refresh_continuous_input(&mut original.clone(), ¤t));
|
||||
current.runtime_session = 912;
|
||||
current.document.frame.mounts.clear();
|
||||
assert!(!refresh_continuous_input(&mut original.clone(), ¤t));
|
||||
}
|
||||
#[test]
|
||||
fn drop_metadata_is_scoped_to_its_event_kind_and_bounded() {
|
||||
let mut input:Input=serde_json::from_str(r#"{"window":1,"session":1,"layout_version":2,"mount":3,"kind":"tab-drop","tab_drop":{"window":2,"layout_version":4,"index":1}}"#).unwrap();
|
||||
assert!(input.validate().is_ok());
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! 后台文件/网络 provider。线程池只携带纯数据、取消控制与弱 Task 身份,不持有 VM。
|
||||
//! Lease 由托管任务拥有,释放会撤销交付资格并取消可取消的后台工作;已经完成的结果还要由 VM worker 检查归属后才能交付。
|
||||
//! 并发、队列、读取字节数、超时和 URL 协议均有边界,避免 provider 绕过运行时资源限制。
|
||||
//!
|
||||
//! Background providers carry plain data and weak task identities, never a VM.
|
||||
use endor_core::{VmError, WeakObjectRef};
|
||||
use std::{
|
||||
@@ -21,20 +25,24 @@ const QUEUE: usize = 64;
|
||||
type Completion = Result<String, String>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// provider 共享的就绪结果与并发计数;内容只有纯数据和弱任务身份。
|
||||
struct Shared {
|
||||
ready: Mutex<HashMap<WeakObjectRef, Completion>>,
|
||||
active: AtomicUsize,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
/// 某个运行时拥有的后台 provider 能力,负责提交和取走完成结果。
|
||||
pub struct Provider {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
/// 后台工作取消标记与可取消子进程,不拥有托管 VM。
|
||||
struct Control {
|
||||
cancelled: AtomicBool,
|
||||
process: Mutex<Option<Child>>,
|
||||
}
|
||||
impl Control {
|
||||
/// 撤销当前操作的后续交付资格,并执行对应取消清理。
|
||||
fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Release);
|
||||
if let Ok(mut process) = self.process.lock()
|
||||
@@ -46,19 +54,23 @@ impl Control {
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 后台工作种类及其纯文本参数,源调用的类型检查在提交前完成。
|
||||
pub enum Request {
|
||||
ReadText(String),
|
||||
FetchText(String),
|
||||
}
|
||||
/// Only Task payloads own this lease. Provider threads hold plain control data.
|
||||
#[derive(Debug)]
|
||||
/// 仅托管 Task 拥有的交付租约;释放它会取消后台资格并清理晚到结果。
|
||||
pub struct Lease {
|
||||
shared: Arc<Shared>,
|
||||
task: WeakObjectRef,
|
||||
control: Arc<Control>,
|
||||
}
|
||||
impl Drop for Lease {
|
||||
/// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。
|
||||
fn drop(&mut self) {
|
||||
// 租约释放立即取消交付资格;后台 OS 读取即使稍后结束,也不能再次唤醒旧 Task。
|
||||
self.control.cancel();
|
||||
if let Ok(mut ready) = self.shared.ready.lock() {
|
||||
ready.remove(&self.task);
|
||||
@@ -66,6 +78,7 @@ impl Drop for Lease {
|
||||
self.shared.active.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
/// 线程池队列中的纯数据工作,关联弱任务身份、控制和共享完成表。
|
||||
struct Job {
|
||||
request: Request,
|
||||
task: WeakObjectRef,
|
||||
@@ -73,6 +86,7 @@ struct Job {
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
impl Provider {
|
||||
/// 检查并发额度并取得任务 Lease,再把纯数据工作放入有界线程池队列。
|
||||
pub fn submit(
|
||||
&self,
|
||||
task: WeakObjectRef,
|
||||
@@ -101,6 +115,7 @@ impl Provider {
|
||||
.map_err(|_| VmError::ExecutionLimit("I/O provider queue full or unavailable"))?;
|
||||
Ok(lease)
|
||||
}
|
||||
/// 取走指定弱任务身份的 provider 完成结果;实际托管写入仍由 VM worker 执行。
|
||||
pub fn take_ready(&self) -> Result<Vec<(WeakObjectRef, Completion)>, VmError> {
|
||||
Ok(self
|
||||
.shared
|
||||
@@ -111,6 +126,7 @@ impl Provider {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
/// 懒初始化共享工作线程和有界任务队列;初始化失败反馈给提交方。
|
||||
fn pool() -> Result<&'static SyncSender<Job>, &'static str> {
|
||||
static POOL: OnceLock<Result<SyncSender<Job>, String>> = OnceLock::new();
|
||||
POOL.get_or_init(|| {
|
||||
@@ -150,6 +166,7 @@ fn pool() -> Result<&'static SyncSender<Job>, &'static str> {
|
||||
.as_ref()
|
||||
.map_err(|_| "I/O provider unavailable")
|
||||
}
|
||||
/// 读取受字节上限限制的 UTF-8 文件内容,错误作为任务结果交付。
|
||||
fn read_text(path: &str) -> Completion {
|
||||
let metadata = std::fs::metadata(path).map_err(|error| error.to_string())?;
|
||||
if !metadata.is_file() {
|
||||
@@ -178,6 +195,7 @@ fn read_text(path: &str) -> Completion {
|
||||
|
||||
/// No shell interpretation, curlrc, URL globbing, or non-HTTP redirects.
|
||||
/// Authentication/custom headers are deliberately outside this text GET API.
|
||||
/// 检查网络请求的 URL 协议、长度与凭据等约束,拒绝不受支持的输入。
|
||||
pub fn valid_url(url: &str) -> bool {
|
||||
if url.len() > 8192 || url.chars().any(char::is_control) {
|
||||
return false;
|
||||
@@ -193,8 +211,10 @@ pub fn valid_url(url: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Created only on an I/O worker. All early exits kill and reap the child.
|
||||
/// 子进程作用域收尾保护,避免取消/失败路径遗留未回收进程。
|
||||
struct Reaper<'a>(&'a Control);
|
||||
impl Drop for Reaper<'_> {
|
||||
/// 在 Rust 所有者离开作用域时执行本类型的配套清理;不能用它代替需要报告错误的显式关闭。
|
||||
fn drop(&mut self) {
|
||||
let child = self.0.process.lock().ok().and_then(|mut slot| slot.take());
|
||||
if let Some(mut child) = child {
|
||||
@@ -203,6 +223,7 @@ impl Drop for Reaper<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 在取消、超时、协议和大小限制下取得 HTTP 文本;不把网络执行放在 VM 线程。
|
||||
fn fetch_text(url: &str, control: &Control) -> Completion {
|
||||
if !valid_url(url) {
|
||||
return Err("invalid HTTP(S) URL".into());
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 运行时模块入口。VM 生命周期、API、源程序组织、事件与异步任务在这里组合。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
pub(crate) mod api;
|
||||
pub(crate) mod endor_host;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! owner/key 管理的异步任务与协作式源函数调度。弱索引用于查找,回调、等待对象和协程帧是托管图中的真实边。
|
||||
//! 任务可能处于等待 I/O、定时器、可执行协程或等待另一任务的状态;取消 owner/key 不能让晚到结果再次执行。
|
||||
//! 调度使用有界量子和公平推进;await 挂起后保留操作数与帧,不重放此前副作用。失败和依赖环有明确处理路径。
|
||||
//!
|
||||
//! Owner-managed asynchronous work. Only weak identities enter the scheduler;
|
||||
//! callbacks and scopes are ordinary traced edges in the Aster object graph.
|
||||
use std::{
|
||||
@@ -24,6 +28,7 @@ const MAX_READY: usize = 64;
|
||||
type Index = Arc<Mutex<Vec<WeakObjectRef>>>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 任务对源代码可见的生命周期状态,完成/取消/失败各有不同 await 语义。
|
||||
enum State {
|
||||
Pending,
|
||||
Running,
|
||||
@@ -32,6 +37,7 @@ enum State {
|
||||
Failed,
|
||||
}
|
||||
impl State {
|
||||
/// 返回内部枚举或对象对应的稳定源名称。
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
@@ -44,6 +50,7 @@ impl State {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// 托管异步对象,保存 owner/key、结果与真正拥有的回调/协程/等待引用。
|
||||
struct Task {
|
||||
owner: WeakObjectRef,
|
||||
key: String,
|
||||
@@ -60,15 +67,19 @@ struct Task {
|
||||
result: Option<Value>,
|
||||
}
|
||||
impl ObjectPayload for Task {
|
||||
/// 提供受检查的 Any 下转型入口,让宿主按具体 payload 类型读取数据。
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
/// 返回运行时诊断/类型检查使用的稳定 payload 名称。
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-task"
|
||||
}
|
||||
/// 声明此 payload 的赋值遵循引用身份语义,避免把内部宿主状态当作可复制值。
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// 把 Task 状态投影成 await 的就绪、等待、取消或失败结果,不在等待期间主动轮询执行。
|
||||
fn poll_await(&self) -> Result<endor_core::AwaitPoll, VmError> {
|
||||
match self.state {
|
||||
State::Pending | State::Running => Ok(endor_core::AwaitPoll::Pending),
|
||||
@@ -86,6 +97,7 @@ impl ObjectPayload for Task {
|
||||
)),
|
||||
}
|
||||
}
|
||||
/// 枚举本对象真正拥有的托管强引用,供 Aster 引用追踪和循环回收使用。
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
if let Some(callback) = self.callback {
|
||||
visit(callback);
|
||||
@@ -101,15 +113,18 @@ impl ObjectPayload for Task {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 统一构造本模块的参数错误,让调用方停止处理无效输入。
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
/// 从源值中提取并检查托管对象身份,错误类型沿 VM 错误返回。
|
||||
fn object(value: &Value) -> Result<ObjectRef, VmError> {
|
||||
match value {
|
||||
Value::Object(object) => Ok(*object),
|
||||
_ => Err(invalid("task owner and callback must be managed objects")),
|
||||
}
|
||||
}
|
||||
/// 取得 owner 关联的托管任务作用域,供 key 替换和取消使用。
|
||||
fn scope(store: &ObjectStore, owner: ObjectRef) -> Result<Option<ObjectRef>, VmError> {
|
||||
let payload = store.object(owner)?.payload();
|
||||
if let Some(value) = payload.as_any().downcast_ref::<Block>() {
|
||||
@@ -132,6 +147,7 @@ fn scope(store: &ObjectStore, owner: ObjectRef) -> Result<Option<ObjectRef>, VmE
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
/// 为可拥有任务的对象安装作用域,真实引用仍由托管图追踪。
|
||||
fn install_scope(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
@@ -155,6 +171,7 @@ fn install_scope(
|
||||
}
|
||||
cx.set_attr(owner, SCOPE, Value::Object(scope))
|
||||
}
|
||||
/// 检查任务的 owner 仍有效且任务具备交付条件,避免晚到结果复活已关闭页面。
|
||||
fn eligible(store: &ObjectStore, owner: ObjectRef, desktop: ObjectRef) -> Result<bool, VmError> {
|
||||
let payload = store.object(owner)?.payload();
|
||||
if let Some(mount) = payload.as_any().downcast_ref::<Mount>() {
|
||||
@@ -194,6 +211,7 @@ fn eligible(store: &ObjectStore, owner: ObjectRef, desktop: ObjectRef) -> Result
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// 检查弱索引对应的任务是否仍是 owner/key 当前登记的那一项。
|
||||
fn current(
|
||||
store: &ObjectStore,
|
||||
owner: ObjectRef,
|
||||
@@ -210,6 +228,7 @@ fn current(
|
||||
|
||||
/// Called at the actual close/detach boundary, including close-then-reopen in
|
||||
/// one language input. A later appearance never revives these identities.
|
||||
/// 取消某个 owner 的全部任务,防止页面/窗口生命周期结束后继续执行回调。
|
||||
pub fn cancel_owner(cx: &mut NativeContext<'_>, owner: ObjectRef) -> Result<(), VmError> {
|
||||
let Some(scope) = scope(cx.store(), owner)? else {
|
||||
return Ok(());
|
||||
@@ -233,6 +252,7 @@ pub fn cancel_owner(cx: &mut NativeContext<'_>, owner: ObjectRef) -> Result<(),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// 当前宿主 API 的操作分类,分派代码负责参数校验与状态更新。
|
||||
enum Operation {
|
||||
Timer,
|
||||
Sleep,
|
||||
@@ -241,6 +261,7 @@ enum Operation {
|
||||
Spawn,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 创建定时/I/O/协作任务时的原生调用适配器。
|
||||
struct After {
|
||||
operation: Operation,
|
||||
provider: crate::io_tasks::Provider,
|
||||
@@ -251,13 +272,16 @@ struct After {
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for After {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(self.signature.arity)
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
if self.quiescing.load(Ordering::Acquire) {
|
||||
return Err(invalid("runtime is exiting; new tasks are forbidden"));
|
||||
@@ -459,6 +483,7 @@ impl NativeCallable for After {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// 该原生能力的查询/操作分类,方法包装用它选择具体实现。
|
||||
enum Query {
|
||||
Cancel,
|
||||
Status,
|
||||
@@ -467,19 +492,23 @@ enum Query {
|
||||
Error,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
/// 保存 owner、签名或操作枚举的原生方法适配器,统一由 VM 调用。
|
||||
struct Method {
|
||||
signature: endor_core::CallSignature,
|
||||
query: Query,
|
||||
desktop: ObjectRef,
|
||||
}
|
||||
impl NativeCallable for Method {
|
||||
/// 给出参数与返回值的托管类型契约;实际操作在 invoke 中执行。
|
||||
fn signature(&self) -> Option<endor_core::CallSignature> {
|
||||
Some(self.signature.clone())
|
||||
}
|
||||
/// 声明宿主可调用对象接受的参数个数,供 VM 在执行前检查。
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(1))
|
||||
}
|
||||
|
||||
/// VM 调入本宿主能力的入口;按所属操作解码参数并返回托管值或后续调用。
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [value] = args else {
|
||||
return Err(invalid("Task methods accept no arguments"));
|
||||
@@ -561,6 +590,7 @@ impl NativeCallable for Method {
|
||||
}
|
||||
}
|
||||
|
||||
/// 弱任务索引、公平调度位置和 provider,索引不会单独把任务保活。
|
||||
pub struct Scheduler {
|
||||
index: Index,
|
||||
runnable: Arc<AtomicBool>,
|
||||
@@ -569,15 +599,18 @@ pub struct Scheduler {
|
||||
provider: crate::io_tasks::Provider,
|
||||
}
|
||||
impl Scheduler {
|
||||
/// 停止接收该域的新任务工作,为关闭/替换运行时做准备。
|
||||
pub fn quiesce(&self, vm: &mut Vm) -> Result<usize> {
|
||||
self.quiescing.store(true, Ordering::Release);
|
||||
self.cancel_all(vm)
|
||||
}
|
||||
/// 检查是否有可推进的协程,而不是仅仅存在正在等待外部结果的任务。
|
||||
pub fn has_cooperative_work(&self) -> bool {
|
||||
self.runnable.load(Ordering::Acquire)
|
||||
}
|
||||
/// Mutation boundaries collect unreachable owner/callback cycles before
|
||||
/// this weak lookup. Idle timer polling does not rescan the entire heap.
|
||||
/// 在当前 VM 中有界交付就绪任务、执行回调或推进协程;开始布局后由外层刷新流程继续。
|
||||
pub fn poll(&self, vm: &mut Vm, limit: usize) -> Result<usize> {
|
||||
anyhow::ensure!((1..=MAX_READY).contains(&limit), "invalid task poll limit");
|
||||
// Provider threads only fill the mailbox. Only this VM worker updates
|
||||
@@ -731,6 +764,7 @@ impl Scheduler {
|
||||
self.prune(vm)?;
|
||||
Ok(work)
|
||||
}
|
||||
/// 取消当前域全部任务,切断回调、协程和等待交付资格。
|
||||
pub fn cancel_all(&self, vm: &mut Vm) -> Result<usize> {
|
||||
let snapshot = self
|
||||
.index
|
||||
@@ -753,6 +787,7 @@ impl Scheduler {
|
||||
self.runnable.store(false, Ordering::Release);
|
||||
Ok(count)
|
||||
}
|
||||
/// 移除已释放的弱任务索引,索引本身不让任务或其 owner 存活。
|
||||
fn prune(&self, vm: &mut Vm) -> Result<()> {
|
||||
let mut index = self
|
||||
.index
|
||||
@@ -789,6 +824,7 @@ impl Scheduler {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// 沿等待链检查任务依赖环,防止 await 永远无法被满足。
|
||||
fn cyclic_wait(vm: &Vm, source: ObjectRef, mut target: ObjectRef) -> Result<bool> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
loop {
|
||||
@@ -805,6 +841,7 @@ fn cyclic_wait(vm: &Vm, source: ObjectRef, mut target: ObjectRef) -> Result<bool
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复已保存的源函数帧,处理完成、软量子挂起或 await 等待,保留已发生的副作用。
|
||||
fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> {
|
||||
let mut task = vm.payload::<Task>(reference, "Task")?.clone();
|
||||
let coroutine = if let Some(coroutine) = task.coroutine {
|
||||
@@ -897,6 +934,7 @@ fn advance_coroutine(vm: &mut Vm, reference: ObjectRef) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录回调执行失败并沿运行时故障规则停止后续不安全的交付。
|
||||
fn fail_callback(vm: &mut Vm, reference: ObjectRef, message: &str) -> Result<()> {
|
||||
let mut task = vm.payload::<Task>(reference, "Task")?.clone();
|
||||
task.error = Some(message.chars().take(1024).collect::<String>().into());
|
||||
@@ -904,6 +942,7 @@ fn fail_callback(vm: &mut Vm, reference: ObjectRef, message: &str) -> Result<()>
|
||||
finish(vm, reference, State::Failed)
|
||||
}
|
||||
|
||||
/// 将任务转入终态并释放回调、provider 租约、协程和等待引用;取消还会清除保存的结果。
|
||||
fn finish(vm: &mut Vm, reference: ObjectRef, state: State) -> Result<()> {
|
||||
let mut task = vm.payload::<Task>(reference, "Task")?.clone();
|
||||
if !matches!(task.state, State::Pending | State::Running) {
|
||||
@@ -934,6 +973,7 @@ fn finish(vm: &mut Vm, reference: ObjectRef, state: State) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把本模块的类型、方法或能力注册到所属运行时上下文。
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! 构造共享 VM 调用序列与同帧循环驱动的字节码。
|
||||
//! 用托管函数串联步骤,避免为每个布局节点反复创建独立解释器调用;重复驱动会保留同一个执行帧和预算域。
|
||||
//!
|
||||
//! Validated call sequences share the current VM budget and explicit frames.
|
||||
use endor_core::{
|
||||
CodeLayout, EndorCode, EndorCodeObject, Instruction, NativeContext, ObjectRef, Opcode, Value,
|
||||
VmError,
|
||||
};
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub fn build(
|
||||
cx: &mut NativeContext<'_>,
|
||||
calls: Vec<ObjectRef>,
|
||||
@@ -36,6 +40,7 @@ pub fn build(
|
||||
|
||||
/// A fixed-size driver. Each callback returns whether another occurrence is
|
||||
/// needed; delegated source calls and all iterations share this VM budget.
|
||||
/// 构造重复调用同一托管函数的驱动,保持共享参数与引用生命周期。
|
||||
pub fn repeat_call(
|
||||
cx: &mut NativeContext<'_>,
|
||||
callback: ObjectRef,
|
||||
@@ -63,6 +68,7 @@ pub fn repeat_call(
|
||||
|
||||
/// Prepare only the current occurrence, collect its result, then continue the
|
||||
/// outer driver. The helper frame and its managed captures die before the next.
|
||||
/// 将一步调用结果收集到驱动状态,再安排下一步调用。
|
||||
pub fn collect_and_continue(
|
||||
cx: &mut NativeContext<'_>,
|
||||
collector: ObjectRef,
|
||||
@@ -94,6 +100,7 @@ pub fn collect_and_continue(
|
||||
driver(cx, instructions, constants, name, desktop)
|
||||
}
|
||||
|
||||
/// 建立承载整个步骤序列的托管函数,使续体可以在同一个 VM 帧中恢复。
|
||||
fn driver(
|
||||
cx: &mut NativeContext<'_>,
|
||||
instructions: Vec<Instruction>,
|
||||
@@ -112,6 +119,7 @@ fn driver(
|
||||
|
||||
/// Consume the condition on the stack: true repeats this same frame from zero,
|
||||
/// false falls through. Operand widths include EXTENDED_ARG code units.
|
||||
/// 构造由继续条件决定是否再执行一轮的同帧循环。
|
||||
pub fn repeat_while(instructions: &mut Vec<Instruction>) -> Result<(), VmError> {
|
||||
let body = Instruction::encode_all(instructions)?.len() / 2;
|
||||
let mut back = Instruction::new(Opcode::JumpBackward, 0);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 有界 HTTP 活动记录。保存请求/响应的可显示预览供 Network 工具读取,不负责执行请求。
|
||||
//! 正文预览和允许显示的头部受限制,避免活动面板成为无限日志或暴露不必要数据的通道。
|
||||
//!
|
||||
//! Bounded observation of this process's local HTTP API, never external traffic.
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
@@ -10,6 +13,7 @@ pub const BODY_PREVIEW: usize = 32 * 1024;
|
||||
const MAX_RECORDS: usize = 80;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// 一次 HTTP 请求/响应的有界观察记录,供 Network 工具查看。
|
||||
pub struct Exchange {
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
@@ -26,11 +30,13 @@ pub struct Exchange {
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// 按容量维护近期 HTTP 活动与请求身份,防止无限日志积累。
|
||||
pub struct ActivityLog {
|
||||
pub records: VecDeque<Exchange>,
|
||||
next_id: u64,
|
||||
}
|
||||
impl ActivityLog {
|
||||
/// 为请求分配活动 ID 并登记其方法、来源和受限头部。
|
||||
pub fn begin(&mut self, method: String, path: String, origin: String, headers: String) -> u64 {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
@@ -63,11 +69,13 @@ impl ActivityLog {
|
||||
}
|
||||
id
|
||||
}
|
||||
/// 更新请求正文预览,内容长度仍由活动记录策略限制。
|
||||
pub fn body(&mut self, id: u64, body: &str) {
|
||||
if let Some(record) = self.records.iter_mut().find(|r| r.id == id) {
|
||||
record.request = preview(body);
|
||||
}
|
||||
}
|
||||
/// 完成本模块当前阶段的结果整理,把后续工作交回调用方。
|
||||
pub fn finish(&mut self, id: u64, status: u16, body: &str, headers: String) {
|
||||
if let Some(record) = self.records.iter_mut().find(|r| r.id == id) {
|
||||
record.status = Some(status);
|
||||
@@ -78,6 +86,7 @@ impl ActivityLog {
|
||||
}
|
||||
}
|
||||
|
||||
/// 截取可显示的受限文本预览,避免日志无限保留完整大正文。
|
||||
pub fn preview(value: &str) -> String {
|
||||
if value.len() <= BODY_PREVIEW {
|
||||
return value.to_owned();
|
||||
|
||||
+430
-640
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
//! 把 UTF-8 文本读写适配到当前平台的系统剪贴板命令。
|
||||
//! 外部命令失败作为普通错误交给调用方处理;这里不解析或执行剪贴板中的程序文本。
|
||||
//!
|
||||
//! Clipboard text is always UTF-8, including Finder/LaunchServices launches without LANG.
|
||||
use anyhow::{Result, bail};
|
||||
#[cfg(target_os = "macos")]
|
||||
/// 运行选定平台的剪贴板命令并检查其退出状态。
|
||||
fn command(program: &str) -> std::process::Command {
|
||||
let mut cmd = std::process::Command::new(program);
|
||||
cmd.env("LC_ALL", "en_US.UTF-8")
|
||||
@@ -8,6 +12,7 @@ fn command(program: &str) -> std::process::Command {
|
||||
.env("LANG", "en_US.UTF-8");
|
||||
cmd
|
||||
}
|
||||
/// 取得系统剪贴板的 UTF-8 文本,失败作为可显示错误返回。
|
||||
pub fn read() -> Result<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -26,6 +31,7 @@ pub fn read() -> Result<String> {
|
||||
bail!("system clipboard adapter is not available on this platform")
|
||||
}
|
||||
}
|
||||
/// 将提供的文本交给系统剪贴板命令,不执行文本内容。
|
||||
pub fn write(value: &str) -> Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
//! Console/Sources/Network/Settings 的宿主交互协调与绘制状态。
|
||||
//! 工具标签有独立焦点、滚动和文本选择状态;源程序与结果来自当前发布文档,提交动作通过本地 API 进入 worker。
|
||||
//! 输入法预编辑、光标、选择和控件捕获分别处理,避免把界面操作直接当成业务模型写入。
|
||||
//!
|
||||
//! Native developer workspace. Coordinates and scroll offsets are logical pixels.
|
||||
use crate::{
|
||||
activity::{Activity, Exchange},
|
||||
@@ -32,6 +36,7 @@ use selection::SourceSelection;
|
||||
const ROW: f32 = 19.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
/// 当前管理工具种类,决定使用 Console/Sources/Network/Settings 哪份状态。
|
||||
pub enum Tab {
|
||||
#[default]
|
||||
Console,
|
||||
@@ -40,6 +45,7 @@ pub enum Tab {
|
||||
Settings,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
/// 管理面板中可区分的输入/列表/详情区域。
|
||||
pub enum Region {
|
||||
Transcript = 0,
|
||||
Tree,
|
||||
@@ -48,11 +54,13 @@ pub enum Region {
|
||||
Details,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
/// 工具区域保留的滚动偏移与内容范围。
|
||||
struct Scroll {
|
||||
x: f32,
|
||||
y: f32,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
/// 当前工具内接受键盘输入的目标类别。
|
||||
pub enum Focus {
|
||||
Source,
|
||||
Input,
|
||||
@@ -60,6 +68,7 @@ pub enum Focus {
|
||||
Custom,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
/// 工具控件激活后执行的管理动作,避免在绘制时产生业务副作用。
|
||||
pub enum Action {
|
||||
ClearConsole,
|
||||
ClearNetwork,
|
||||
@@ -75,11 +84,13 @@ pub enum Action {
|
||||
Theme(usize),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
/// 工具画面分隔布局的可调整方向/区域。
|
||||
pub enum Split {
|
||||
Tree,
|
||||
Network,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
/// 正在捕获的工具拖动操作,移动时保持原目标。
|
||||
enum Drag {
|
||||
Split(Split),
|
||||
Scroll(Region, bool, f32),
|
||||
@@ -87,11 +98,13 @@ enum Drag {
|
||||
|
||||
/// Each workspace keeps its own input, focus, selection, scroll and request state.
|
||||
/// Only appearance and the window-level launcher are shared.
|
||||
/// 多工具页的状态拥有者,切换时保存/恢复当前页数据。
|
||||
pub struct Workspace {
|
||||
states: [DeveloperToolsState; 4],
|
||||
active: usize,
|
||||
}
|
||||
impl Workspace {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(proxy: winit::event_loop::EventLoopProxy<AppEvent>) -> Self {
|
||||
let states = [Tab::Console, Tab::Sources, Tab::Network, Tab::Settings].map(|tab| {
|
||||
DeveloperToolsState {
|
||||
@@ -102,6 +115,7 @@ impl Workspace {
|
||||
});
|
||||
Self { states, active: 0 }
|
||||
}
|
||||
/// 选择一个工具页并恢复它各自保留的输入、焦点和滚动状态。
|
||||
pub fn select(&mut self, name: Option<&str>) {
|
||||
let next = match name {
|
||||
Some("Sources") => 1,
|
||||
@@ -120,17 +134,20 @@ impl Workspace {
|
||||
}
|
||||
impl std::ops::Deref for Workspace {
|
||||
type Target = DeveloperToolsState;
|
||||
/// 将包装类型的只读访问转交给内部当前上下文。
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.states[self.active]
|
||||
}
|
||||
}
|
||||
impl std::ops::DerefMut for Workspace {
|
||||
/// 将包装类型的可变访问转交给内部当前上下文。
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.states[self.active]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 一份工具页面的输入、选择、滚动、焦点和显示配置。
|
||||
pub struct DeveloperToolsState {
|
||||
pub open: bool,
|
||||
pointer: Point,
|
||||
@@ -167,6 +184,7 @@ pub struct DeveloperToolsState {
|
||||
draft: String,
|
||||
}
|
||||
impl Default for DeveloperToolsState {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
@@ -206,6 +224,7 @@ impl Default for DeveloperToolsState {
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
/// 本帧工具面板与子区域的几何,用于绘制和原生事件命中。
|
||||
pub struct DeveloperToolsLayout {
|
||||
pub panel: Rect,
|
||||
pub widgets: Vec<Target<Action>>,
|
||||
@@ -216,6 +235,7 @@ pub struct DeveloperToolsLayout {
|
||||
source: Option<(Rect, Point, Vec<String>)>,
|
||||
}
|
||||
impl DeveloperToolsLayout {
|
||||
/// 将工具局部绘制/交互布局平移到实际窗口位置。
|
||||
pub fn translate(&mut self, dx: f32, dy: f32) {
|
||||
self.panel.x += dx;
|
||||
self.panel.y += dy;
|
||||
@@ -250,11 +270,13 @@ impl DeveloperToolsLayout {
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
/// Console 文字输入、光标与可见范围的本帧排版。
|
||||
struct ConsoleInputLayout {
|
||||
viewport: Rect,
|
||||
origin: Point,
|
||||
}
|
||||
impl DeveloperToolsState {
|
||||
/// 用最新完整源文档更新工具投影,维护 Sources 与 Console 历史的一致性。
|
||||
pub fn document_updated(&mut self, doc: &RenderDocument) {
|
||||
if self
|
||||
.selected_node_id
|
||||
@@ -264,6 +286,7 @@ impl DeveloperToolsState {
|
||||
self.selected_node_id = doc.root.as_ref().map(|n| n.id.clone());
|
||||
}
|
||||
}
|
||||
/// 根据当前工具命中区域选择文本、拖动或默认系统光标。
|
||||
pub fn cursor_kind(&self, layout: &DeveloperToolsLayout) -> winit::window::CursorIcon {
|
||||
use winit::window::CursorIcon;
|
||||
if !self.open {
|
||||
@@ -286,15 +309,18 @@ impl DeveloperToolsState {
|
||||
Cursor::Disabled => CursorIcon::NotAllowed,
|
||||
}
|
||||
}
|
||||
/// 清理指针或焦点离开后的悬停/捕获状态,避免按下状态残留。
|
||||
pub fn pointer_left(&mut self) {
|
||||
self.interaction.pointer_left();
|
||||
}
|
||||
/// 清理指针或焦点离开后的悬停/捕获状态,避免按下状态残留。
|
||||
pub fn blur(&mut self) {
|
||||
self.interaction.blur();
|
||||
self.drag = None;
|
||||
self.focus = None;
|
||||
self.preedit.clear();
|
||||
}
|
||||
/// 记录按下时的控件或文本选择目标,为释放激活和拖动保留捕获。
|
||||
pub fn press(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) {
|
||||
if !self.open || !layout.panel.contains(p) {
|
||||
return;
|
||||
@@ -333,6 +359,7 @@ impl DeveloperToolsState {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 执行已确认激活的管理控件动作,区分工具切换、源码操作和设置。
|
||||
fn activate(
|
||||
&mut self,
|
||||
action: Option<Action>,
|
||||
@@ -423,6 +450,7 @@ impl DeveloperToolsState {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
/// 结束捕获并按同一目标/启用条件决定是否执行操作。
|
||||
pub fn release(&mut self, layout: &DeveloperToolsLayout, p: Point, activity: &Activity) {
|
||||
self.pointer = p;
|
||||
self.interaction.pointer_moved(p, &layout.widgets);
|
||||
@@ -433,6 +461,7 @@ impl DeveloperToolsState {
|
||||
self.activate(action, layout, p, activity);
|
||||
}
|
||||
}
|
||||
/// 按工具控件焦点处理 Tab、确认等管理键盘交互。
|
||||
pub fn keyboard_control(
|
||||
&mut self,
|
||||
event: &winit::event::KeyEvent,
|
||||
@@ -491,6 +520,7 @@ impl DeveloperToolsState {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// 更新工具内悬停、拖动、选择或分隔条位置。
|
||||
pub fn move_pointer(
|
||||
&mut self,
|
||||
p: Point,
|
||||
@@ -535,6 +565,7 @@ impl DeveloperToolsState {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
/// 将滚轮作用到当前工具的可滚动区域,钳制本工具偏移。
|
||||
pub fn wheel(&mut self, layout: &DeveloperToolsLayout, p: Point, dx: f32, dy: f32) {
|
||||
if let Some((_, r, _, _)) = layout
|
||||
.regions
|
||||
@@ -550,6 +581,7 @@ impl DeveloperToolsState {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 在当前选择/光标处插入输入文本,并更新光标与可见范围。
|
||||
pub fn insert(&mut self, value: &str) {
|
||||
match self.focus {
|
||||
Some(Focus::Input) => {
|
||||
@@ -591,9 +623,11 @@ impl DeveloperToolsState {
|
||||
Some(Focus::Source) | None => {}
|
||||
}
|
||||
}
|
||||
/// 调整输入区滚动位置,让当前光标保持在可见范围内。
|
||||
pub(crate) fn reveal_cursor(&mut self) {
|
||||
self.reveal_input = true;
|
||||
}
|
||||
/// 返回输入法候选窗应跟随的逻辑光标位置。
|
||||
pub fn ime_position(&self, layout: &DeveloperToolsLayout) -> Point {
|
||||
if let Some(input) = layout.input {
|
||||
let before = &self.input[..self.cursor];
|
||||
@@ -608,6 +642,7 @@ impl DeveloperToolsState {
|
||||
}
|
||||
Point::new(layout.panel.x + 20.0, 60.0)
|
||||
}
|
||||
/// 取得当前工具文本选择内容,供复制命令使用。
|
||||
pub fn selected_text(&self) -> String {
|
||||
if self.focus == Some(Focus::Source) {
|
||||
return self.source_selection.text(self.selection);
|
||||
@@ -622,6 +657,7 @@ impl DeveloperToolsState {
|
||||
Some(Focus::Source) | None => String::new(),
|
||||
}
|
||||
}
|
||||
/// 处理编辑键和导航键,维护 UTF-8 光标/选择边界。
|
||||
pub fn key(&mut self, key: &winit::keyboard::NamedKey, shift: bool, command: bool) {
|
||||
use winit::keyboard::NamedKey::*;
|
||||
if *key == Escape {
|
||||
@@ -792,6 +828,7 @@ impl DeveloperToolsState {
|
||||
}
|
||||
self.reveal_cursor();
|
||||
}
|
||||
/// 初始化内置工具对应的页面和展示状态,避免重复建立同一组工具。
|
||||
pub fn initialize_tools(&mut self, name: &str) {
|
||||
let name = name.to_owned();
|
||||
let proxy = self.proxy.clone();
|
||||
@@ -823,9 +860,11 @@ impl DeveloperToolsState {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// 把当前工具输入按所选模式提交给本地 API,并异步反馈结果。
|
||||
fn submit(&mut self) {
|
||||
self.submit_console(false);
|
||||
}
|
||||
/// 提交 Console 输入到持久运行时,而不是把全部历史重新执行一遍。
|
||||
fn submit_console(&mut self, reset: bool) {
|
||||
if !reset && self.input.trim().is_empty() {
|
||||
return;
|
||||
@@ -867,6 +906,7 @@ impl DeveloperToolsState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub fn build(
|
||||
width: u32,
|
||||
height: u32,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! 绘制 Console 输入、结果历史和对应命中区域。
|
||||
//! 这里消费工具状态并生成显示列表;用户程序的编译执行由提交路径和 worker 完成。
|
||||
//!
|
||||
use super::*;
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub(super) fn build(
|
||||
p: &mut Painter,
|
||||
state: &mut DeveloperToolsState,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! 绘制已观察到的 HTTP 活动、请求列表与详情。
|
||||
//! 筛选、选择和滚动是工具视图状态;展示一条请求不会重发该请求。
|
||||
//!
|
||||
use super::*;
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub(super) fn build(
|
||||
p: &mut Painter,
|
||||
state: &mut DeveloperToolsState,
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
//! 开发工具画面的绘制辅助:文字行、按钮、分隔线和滚动条。
|
||||
//! 绘制命令与可交互区域同步登记,保证屏幕上的管理控件能用同一几何命中。
|
||||
//!
|
||||
use super::*;
|
||||
|
||||
/// 开发工具的绘制助手,把布局结果写入同帧显示列表。
|
||||
pub(super) struct Painter {
|
||||
pub(super) ui: Ui<Action>,
|
||||
pub(super) layout: DeveloperToolsLayout,
|
||||
}
|
||||
impl Painter {
|
||||
/// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。
|
||||
pub(super) fn fill(&mut self, r: Rect, c: Color) {
|
||||
self.ui.fill(r, c);
|
||||
}
|
||||
/// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。
|
||||
pub(super) fn label(&mut self, x: f32, y: f32, value: &str, c: Color, code: bool) {
|
||||
self.ui.label(x, y, value, c, code);
|
||||
}
|
||||
/// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。
|
||||
pub(super) fn button(&mut self, r: Rect, label: &str, action: Action, active: bool) {
|
||||
let role = match action {
|
||||
Action::Follow | Action::Snapshot | Action::Cloud => Role::Toggle,
|
||||
@@ -19,14 +26,17 @@ impl Painter {
|
||||
self.ui
|
||||
.button(r, action, Button::new(label).selected(active).role(role));
|
||||
}
|
||||
/// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。
|
||||
pub(super) fn enabled_button(&mut self, r: Rect, label: &str, action: Action, enabled: bool) {
|
||||
self.ui
|
||||
.button(r, action, Button::new(label).enabled(enabled));
|
||||
}
|
||||
/// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。
|
||||
pub(super) fn split(&mut self, r: Rect, kind: Split) {
|
||||
// Paint and register splitters after all pane content, in one overlay pass.
|
||||
self.layout.splits.push((r, kind));
|
||||
}
|
||||
/// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。
|
||||
pub(super) fn lines(
|
||||
&mut self,
|
||||
area: Rect,
|
||||
@@ -132,6 +142,7 @@ impl Painter {
|
||||
self.scrollbar(area, region, false, s.y, content_h);
|
||||
self.scrollbar(area, region, true, s.x, content_w);
|
||||
}
|
||||
/// 绘制对应管理界面原语,并按控件需要同步登记命中和滚动区域。
|
||||
pub(super) fn scrollbar(
|
||||
&mut self,
|
||||
r: Rect,
|
||||
@@ -170,6 +181,7 @@ impl Painter {
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
/// 一行可绘制、可关联源码位置的工具文本。
|
||||
pub(super) struct Line {
|
||||
pub(super) value: String,
|
||||
pub(super) color: Color,
|
||||
@@ -178,11 +190,13 @@ pub(super) struct Line {
|
||||
pub(super) action: Option<Action>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
/// 管理画面内分隔条方向/位置描述。
|
||||
pub(super) enum Divider {
|
||||
Accent,
|
||||
Neutral,
|
||||
}
|
||||
impl Line {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub(super) fn new(value: impl Into<String>, color: Color) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
@@ -193,6 +207,7 @@ impl Line {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 按行绘制工具文本,返回后续布局需要的占用范围。
|
||||
pub(super) fn multiline(out: &mut Vec<Line>, value: &str, color: Color) {
|
||||
out.extend(
|
||||
value
|
||||
@@ -201,6 +216,7 @@ pub(super) fn multiline(out: &mut Vec<Line>, value: &str, color: Color) {
|
||||
.map(|s| Line::new(s, color)),
|
||||
);
|
||||
}
|
||||
/// 把一条 Console 结果投影为可滚动、可选择的文字画面。
|
||||
pub(super) fn console_result(out: &mut Vec<Line>, body: &str, color: Color) {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
if value["execution"] == "repl" {
|
||||
@@ -263,6 +279,7 @@ pub(super) fn console_result(out: &mut Vec<Line>, body: &str, color: Color) {
|
||||
}
|
||||
multiline(out, body, color);
|
||||
}
|
||||
/// 将代码层次展开为工具可绘制的行数据,保留节点与源范围关联。
|
||||
pub(super) fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize, &'a CodeNode)>) {
|
||||
out.push((depth, node));
|
||||
for child in &node.children {
|
||||
@@ -270,6 +287,7 @@ pub(super) fn flatten<'a>(node: &'a CodeNode, depth: usize, out: &mut Vec<(usize
|
||||
}
|
||||
}
|
||||
|
||||
/// 绘制选中请求的头部、正文预览与响应信息,不重新执行该请求。
|
||||
pub(super) fn request_details(out: &mut Vec<Line>, r: &Exchange) {
|
||||
multiline(
|
||||
out,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 多行文本选择和光标导航。选择端点映射到合法字符边界,避免把 UTF-8 字节中间当成一个字符。
|
||||
//! 工具只保留当前文本行和选择范围;复制操作取得的是所选内容,不是整份运行时状态。
|
||||
//!
|
||||
//! Read-only, grapheme-aware source selection, independent of console editing.
|
||||
use crate::{
|
||||
geometry::Point,
|
||||
@@ -6,6 +9,7 @@ use crate::{
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
/// 行文本、锚点与选择端点,既供鼠标拖选也供键盘导航使用。
|
||||
pub(super) struct SourceSelection {
|
||||
lines: Vec<String>,
|
||||
anchor: (usize, usize),
|
||||
@@ -13,6 +17,7 @@ pub(super) struct SourceSelection {
|
||||
pub dragging: bool,
|
||||
}
|
||||
impl SourceSelection {
|
||||
/// 替换可选择文本行,并把旧选择限制在新文本仍有效的位置。
|
||||
pub fn set_lines(&mut self, lines: &[String]) {
|
||||
if self.lines != lines {
|
||||
self.lines = lines.to_vec();
|
||||
@@ -21,6 +26,7 @@ impl SourceSelection {
|
||||
self.dragging = false;
|
||||
}
|
||||
}
|
||||
/// 把工具指针位置换算成合法行号与文本字符边界。
|
||||
fn locate(p: Point, origin: Point, lines: &[String]) -> (usize, usize) {
|
||||
let row = (((p.y - origin.y) / super::ROW).floor().max(0.0) as usize)
|
||||
.min(lines.len().saturating_sub(1));
|
||||
@@ -39,14 +45,17 @@ impl SourceSelection {
|
||||
}
|
||||
(row, byte)
|
||||
}
|
||||
/// 在指针位置建立文本选择锚点,开始一次拖动选择。
|
||||
pub fn begin(&mut self, p: Point, origin: Point, lines: &[String]) {
|
||||
self.anchor = Self::locate(p, origin, lines);
|
||||
self.end = self.anchor;
|
||||
self.dragging = true;
|
||||
}
|
||||
/// 保持原锚点,随指针更新选择另一端。
|
||||
pub fn extend(&mut self, p: Point, origin: Point, lines: &[String]) {
|
||||
self.end = Self::locate(p, origin, lines);
|
||||
}
|
||||
/// 用键盘移动文本选择端点,按扩展模式决定是否保留锚点。
|
||||
pub fn navigate(
|
||||
&mut self,
|
||||
key: &winit::keyboard::NamedKey,
|
||||
@@ -109,6 +118,7 @@ impl SourceSelection {
|
||||
}
|
||||
Some(row)
|
||||
}
|
||||
/// 返回排序后的有效选择范围,隐藏拖动方向差异。
|
||||
fn range(&self, all: bool) -> ((usize, usize), (usize, usize)) {
|
||||
if all {
|
||||
return (
|
||||
@@ -121,6 +131,7 @@ impl SourceSelection {
|
||||
}
|
||||
(self.anchor.min(self.end), self.anchor.max(self.end))
|
||||
}
|
||||
/// 拼接当前选择范围内的实际文本,保持多行换行与 UTF-8 边界。
|
||||
pub fn text(&self, all: bool) -> String {
|
||||
let (a, b) = self.range(all);
|
||||
self.lines
|
||||
@@ -135,6 +146,7 @@ impl SourceSelection {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
/// 取得某行的可见选择片段,供高亮绘制复用。
|
||||
pub fn span(&self, row: usize, all: bool, lines: &[String]) -> Option<(f32, f32)> {
|
||||
let (a, b) = self.range(all);
|
||||
if row < a.0 || row > b.0 || a == b {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! 绘制宿主设置页面和主题选择区域。
|
||||
//! 这里只投影当前设置和交互目标,平台原生窗口行为由对应系统适配层负责。
|
||||
//!
|
||||
use super::*;
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub(super) fn build(p: &mut Painter, state: &mut DeveloperToolsState, viewport: Rect) {
|
||||
let x = viewport.x;
|
||||
let w = viewport.width;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! 绘制 AST、当前源码快照和云文件视图。
|
||||
//! 当前快照是发布源的只读投影;云文件 provider 的可用性与打开状态分别反馈,不把查看代码当成执行代码。
|
||||
//!
|
||||
use super::*;
|
||||
|
||||
/// 根据当前输入构造本模块的派生结果,具体的验证与资源边界见函数内步骤。
|
||||
pub(super) fn build(
|
||||
p: &mut Painter,
|
||||
state: &mut DeveloperToolsState,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 原生宿主模块入口。窗口、管理控件、系统适配和开发工具状态与用户业务界面分开维护。
|
||||
//!
|
||||
// Module ownership follows the directory; crate-root aliases keep internal call sites stable.
|
||||
pub(crate) mod activity;
|
||||
pub(crate) mod app;
|
||||
@@ -5,6 +7,7 @@ pub(crate) mod clipboard;
|
||||
pub(crate) mod developer_tools;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod native_chrome;
|
||||
pub(crate) mod sidebar;
|
||||
pub(crate) mod sources;
|
||||
pub(crate) mod system_ui;
|
||||
pub(crate) mod tabs;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! macOS 原生标题栏适配。定位系统窗口按钮并沿用用户的标题栏双击设置。
|
||||
//! 仅在 macOS 编译;Objective-C 交互局限在原生窗口层,不参与 Endor 布局与事件分发。
|
||||
//!
|
||||
//! Keep AppKit's real window buttons aligned with the custom tab strip.
|
||||
use objc2_app_kit::{NSApplication, NSView, NSWindowButton, NSWindowStyleMask};
|
||||
use objc2_foundation::{MainThreadMarker, NSPoint, NSString, NSUserDefaults};
|
||||
@@ -8,6 +11,7 @@ use winit::{
|
||||
|
||||
/// The custom draggable row does not receive AppKit's titlebar double-click
|
||||
/// action automatically. Use the native click count and native window actions.
|
||||
/// 读取系统偏好的标题栏双击行为,委托原生窗口执行相应动作。
|
||||
pub fn titlebar_double_click(window: &Window) -> bool {
|
||||
let Some(main_thread) = MainThreadMarker::new() else {
|
||||
return false;
|
||||
@@ -49,6 +53,7 @@ pub fn titlebar_double_click(window: &Window) -> bool {
|
||||
|
||||
/// Called only on winit's main thread, after native creation and before drawing.
|
||||
/// AppKit can restore its default button positions when laying out the title bar.
|
||||
/// 调整真实 macOS 窗口三键的位置,与全尺寸内容和侧栏控制区对齐。
|
||||
pub fn align_controls(window: &Window) {
|
||||
let Ok(handle) = window.window_handle() else {
|
||||
return;
|
||||
@@ -68,7 +73,7 @@ pub fn align_controls(window: &Window) {
|
||||
return;
|
||||
}
|
||||
let bounds = view.bounds();
|
||||
let center = f64::from(crate::tabs::HEIGHT) / 2.0;
|
||||
let center = f64::from(crate::sidebar::HEADER) / 2.0;
|
||||
let content_y = if view.isFlipped() {
|
||||
bounds.origin.y + center
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 宿主管理工具和页面列表的唯一侧栏。展开时占据页面左侧空间,折叠后只保留展开按钮区域。
|
||||
//! 页面标题和身份来自发布帧;实际切换/关闭通过带版本的事件进入运行时,不在绘制时修改业务对象。
|
||||
//!
|
||||
//! The only persistent host toolbar. A collapsed sidebar owns just its toggle
|
||||
//! and the native traffic-light safe area; the page owns the rest of the window.
|
||||
use crate::{
|
||||
display_list::{DisplayList, DrawCommand},
|
||||
geometry::{Point, Rect},
|
||||
system_ui::{
|
||||
Button, Interaction, Target, Ui,
|
||||
theme::{self, Theme},
|
||||
},
|
||||
tabs::Tab,
|
||||
};
|
||||
|
||||
pub const WIDTH: f32 = 256.0;
|
||||
pub const HEADER: f32 = 40.0;
|
||||
pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 };
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
/// 侧栏释放激活后要执行的管理动作,绘制本身只登记它。
|
||||
pub enum Action {
|
||||
Toggle,
|
||||
NewPage,
|
||||
Tool(&'static str),
|
||||
Activate(u64),
|
||||
Close(u64),
|
||||
}
|
||||
|
||||
/// 侧栏开合、控件交互和本帧目标集合,决定实际占用页面宽度。
|
||||
pub struct State {
|
||||
pub open: bool,
|
||||
pub interaction: Interaction,
|
||||
pub targets: Vec<Target<Action>>,
|
||||
pub scroll: f32,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
/// 建立类型约定的初始值,供尚未发布或尚未交互的状态使用。
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: true,
|
||||
interaction: Interaction::default(),
|
||||
targets: Vec::new(),
|
||||
scroll: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// 返回展开侧栏占据的逻辑宽度,折叠时页面收回这部分空间。
|
||||
pub fn left(&self) -> f32 {
|
||||
if self.open { WIDTH } else { 0.0 }
|
||||
}
|
||||
|
||||
/// 判断指针是否落入宿主侧栏/展开按钮区域,保护页面输入边界。
|
||||
pub fn covers(&self, point: Point) -> bool {
|
||||
if self.open {
|
||||
point.x >= 0.0 && point.x < WIDTH
|
||||
} else {
|
||||
Rect::new(0.0, 0.0, CONTROLS + 44.0, HEADER).contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
/// 绘制工具入口、页面列表和展开控制,同时登记稳定的交互目标。
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
tabs: &[Tab],
|
||||
height: f32,
|
||||
accent: crate::display_list::Color,
|
||||
) -> DisplayList {
|
||||
let mut ui = Ui::new(Theme::new(accent), self.interaction.clone());
|
||||
if self.open {
|
||||
ui.fill(Rect::new(0.0, 0.0, WIDTH, height), theme::BAR);
|
||||
}
|
||||
let toggle_x = if self.open {
|
||||
WIDTH - 40.0
|
||||
} else {
|
||||
CONTROLS + 4.0
|
||||
};
|
||||
// Draw a sidebar icon with primitives so its shape does not depend on fonts.
|
||||
ui.button(
|
||||
Rect::new(toggle_x, 6.0, 30.0, 28.0),
|
||||
Action::Toggle,
|
||||
Button::new(""),
|
||||
);
|
||||
ui.list.push(DrawCommand::StrokeRoundedRect {
|
||||
rect: Rect::new(toggle_x + 7.0, 13.0, 16.0, 13.0),
|
||||
radius: 2.0,
|
||||
width: 1.0,
|
||||
color: theme::MUTED,
|
||||
});
|
||||
ui.fill(Rect::new(toggle_x + 12.0, 14.0, 1.0, 11.0), theme::MUTED);
|
||||
if self.open {
|
||||
ui.label(20.0, 61.0, "ENDORPHIN", theme::TEXT, false);
|
||||
ui.button(
|
||||
Rect::new(12.0, 95.0, WIDTH - 24.0, 34.0),
|
||||
Action::NewPage,
|
||||
Button::new("+ 新建页面"),
|
||||
);
|
||||
for (index, (name, label)) in [
|
||||
("Console", "Console"),
|
||||
("Sources", "Sources"),
|
||||
("Network", "Network"),
|
||||
("Settings", "Settings"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let selected = tabs
|
||||
.iter()
|
||||
.any(|tab| tab.active && tab.developer_tool.as_deref() == Some(name));
|
||||
ui.button(
|
||||
Rect::new(12.0, 147.0 + index as f32 * 34.0, WIDTH - 24.0, 30.0),
|
||||
Action::Tool(name),
|
||||
Button::new(label).selected(selected),
|
||||
);
|
||||
}
|
||||
ui.label(20.0, 303.0, "页面", theme::MUTED, false);
|
||||
let area = Rect::new(8.0, 333.0, WIDTH - 16.0, (height - 383.0).max(0.0));
|
||||
let pages: Vec<_> = tabs
|
||||
.iter()
|
||||
.filter(|tab| tab.developer_tool.is_none())
|
||||
.collect();
|
||||
self.scroll = self
|
||||
.scroll
|
||||
.clamp(0.0, (pages.len() as f32 * 36.0 - area.height).max(0.0));
|
||||
ui.list.push(DrawCommand::PushClip(area));
|
||||
for (index, tab) in pages.into_iter().enumerate() {
|
||||
let y = area.y + index as f32 * 36.0 - self.scroll;
|
||||
// Register only fully visible rows: paint clipping must match input.
|
||||
if y < area.y || y + 32.0 > area.y + area.height {
|
||||
continue;
|
||||
}
|
||||
ui.button(
|
||||
Rect::new(12.0, y, WIDTH - 56.0, 32.0),
|
||||
Action::Activate(tab.id),
|
||||
Button::new(&tab.title).selected(tab.active),
|
||||
);
|
||||
ui.button(
|
||||
Rect::new(WIDTH - 40.0, y, 28.0, 32.0),
|
||||
Action::Close(tab.id),
|
||||
Button::new("×"),
|
||||
);
|
||||
}
|
||||
ui.list.push(DrawCommand::PopClip);
|
||||
ui.label(20.0, height - 32.0, "本地工作区", theme::MUTED, false);
|
||||
}
|
||||
self.targets = ui.targets;
|
||||
self.interaction.reconcile(&self.targets);
|
||||
ui.list
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn collapsing_removes_every_host_target_except_the_toggle() {
|
||||
let mut sidebar = State::default();
|
||||
sidebar.draw(&[], 800.0, theme::PALETTE[0]);
|
||||
assert!(sidebar.targets.len() > 1);
|
||||
sidebar.open = false;
|
||||
sidebar.draw(&[], 800.0, theme::PALETTE[0]);
|
||||
assert_eq!(sidebar.targets.len(), 1);
|
||||
assert_eq!(sidebar.targets[0].action, Some(Action::Toggle));
|
||||
assert_eq!(sidebar.left(), 0.0);
|
||||
assert!(!sidebar.covers(Point::new(400.0, 10.0)));
|
||||
assert!(!sidebar.covers(Point::new(10.0, 80.0)));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,32 @@
|
||||
//! 云文件浏览的 provider 抽象与工作区状态。默认 provider 未连接,不假装已有外部文件。
|
||||
//! 列出和打开文件的结果作为工具数据展示,错误保持在当前工具状态中供界面说明。
|
||||
//!
|
||||
//! Cloud source integration seam. The host installs a provider when one exists;
|
||||
//! the default workspace stays disconnected and never fabricates or stores files.
|
||||
use anyhow::Result;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// provider 提供的一条文件元数据,供云文件列表显示。
|
||||
pub struct SourceFile {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
/// 已经取得的源文件内容与标识,不代表它已经被执行。
|
||||
pub struct OpenedSource {
|
||||
pub path: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// 外部文件能力接口;只负责列出和读取,不与 VM 全局环境耦合。
|
||||
pub trait CloudSourceProvider: std::fmt::Debug + Send {
|
||||
/// 通过当前 provider 请求文件列表;默认未连接实现返回明确不可用状态。
|
||||
fn list_files(&mut self) -> Result<Vec<SourceFile>>;
|
||||
/// 通过 provider 取得指定文件内容,不在工具查看动作中执行该内容。
|
||||
fn open_file(&mut self, id: &str) -> Result<OpenedSource>;
|
||||
}
|
||||
#[derive(Debug, Default)]
|
||||
/// Sources 云文件页面保留的 provider、列表、选择、内容与错误状态。
|
||||
pub struct SourceWorkspace {
|
||||
pub provider: Option<Box<dyn CloudSourceProvider>>,
|
||||
pub files: Vec<SourceFile>,
|
||||
@@ -25,6 +34,7 @@ pub struct SourceWorkspace {
|
||||
pub error: String,
|
||||
}
|
||||
impl SourceWorkspace {
|
||||
/// 更新云文件列表并保存结果或错误,供 Sources 页面显示。
|
||||
pub fn refresh(&mut self) {
|
||||
let Some(provider) = &mut self.provider else {
|
||||
self.error = "No cloud file provider configured".into();
|
||||
@@ -38,6 +48,7 @@ impl SourceWorkspace {
|
||||
Err(error) => self.error = error.to_string(),
|
||||
}
|
||||
}
|
||||
/// 更新选中文件的内容与打开状态,失败保持可读错误信息。
|
||||
pub fn open(&mut self, id: &str) {
|
||||
let Some(provider) = &mut self.provider else {
|
||||
return;
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
//! 即时绘制控件的跨帧交互状态:稳定 WidgetId、悬停、按下捕获、键盘焦点和释放激活。
|
||||
//! 控件用语义 key 标识,而不是屏幕位置或行号。释放时必须仍命中同一个启用目标,拖出控件不能意外触发点击。
|
||||
//!
|
||||
use crate::geometry::{Point, Rect};
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// Stable across frames, resizing and text changes. Use semantic keys, never row indices.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
/// 由语义 key 生成的稳定控件身份,跨帧移动和文字变化不应改变它。
|
||||
pub struct WidgetId(u64);
|
||||
impl WidgetId {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(key: impl Hash) -> Self {
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut hash);
|
||||
@@ -12,11 +17,13 @@ impl WidgetId {
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// 分隔条和滚动条的操作方向。
|
||||
pub enum Axis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// 管理控件角色,决定焦点资格和系统光标。
|
||||
pub enum Role {
|
||||
Button,
|
||||
Tab,
|
||||
@@ -28,11 +35,13 @@ pub enum Role {
|
||||
Scrollbar(Axis),
|
||||
}
|
||||
impl Role {
|
||||
/// 判断控件角色是否可以接受键盘焦点。
|
||||
fn focusable(self) -> bool {
|
||||
!matches!(self, Self::Splitter(_) | Self::Scrollbar(_) | Self::Row)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// 宿主控件请求的系统光标种类。
|
||||
pub enum Cursor {
|
||||
Default,
|
||||
Pointer,
|
||||
@@ -43,6 +52,7 @@ pub enum Cursor {
|
||||
Disabled,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
/// 本帧可命中的管理控件,绑定几何、角色、启用条件与可选 action。
|
||||
pub struct Target<A> {
|
||||
pub id: WidgetId,
|
||||
pub rect: Rect,
|
||||
@@ -51,6 +61,7 @@ pub struct Target<A> {
|
||||
pub action: Option<A>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
/// 绘制使用的组合交互状态,避免按钮自己重新推断焦点/捕获。
|
||||
pub struct VisualState {
|
||||
pub hovered: bool,
|
||||
pub pressed: bool,
|
||||
@@ -62,6 +73,7 @@ pub struct VisualState {
|
||||
/// Retained input state for an immediate-mode frame. Actions fire on release inside
|
||||
/// the same enabled control, so dragging away cancels a click.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
/// 即时绘制之外保留的输入状态,确保按下与释放跨帧仍匹配同一控件。
|
||||
pub struct Interaction {
|
||||
pointer: Option<Point>,
|
||||
hovered: Option<WidgetId>,
|
||||
@@ -71,18 +83,22 @@ pub struct Interaction {
|
||||
keyboard_focus: bool,
|
||||
}
|
||||
impl Interaction {
|
||||
/// 按逆绘制顺序选择指针处的管理控件,避免重叠控件同时激活。
|
||||
pub fn hit<'a, A>(&self, targets: &'a [Target<A>]) -> Option<&'a Target<A>> {
|
||||
let p = self.pointer?;
|
||||
targets.iter().rev().find(|t| t.rect.contains(p))
|
||||
}
|
||||
/// 更新指针与悬停目标,但不在移动时直接触发点击。
|
||||
pub fn pointer_moved<A>(&mut self, p: Point, targets: &[Target<A>]) {
|
||||
self.pointer = Some(p);
|
||||
self.hovered = self.hit(targets).map(|t| t.id);
|
||||
}
|
||||
/// 清除离开窗口后的指针/悬停状态。
|
||||
pub fn pointer_left(&mut self) {
|
||||
self.pointer = None;
|
||||
self.hovered = None;
|
||||
}
|
||||
/// 捕获启用控件的稳定身份,并按角色更新键盘焦点。
|
||||
pub fn press<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<Target<A>> {
|
||||
self.key_active = None;
|
||||
self.keyboard_focus = false;
|
||||
@@ -91,22 +107,26 @@ impl Interaction {
|
||||
self.focused = target.as_ref().filter(|t| t.role.focusable()).map(|t| t.id);
|
||||
target
|
||||
}
|
||||
/// 只有释放时仍处于原启用目标内才返回 action,拖出后释放不触发。
|
||||
pub fn release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
|
||||
let active = self.pointer_active.take()?;
|
||||
self.hit(targets)
|
||||
.filter(|t| t.enabled && t.id == active && t.role != Role::TextField)
|
||||
.and_then(|t| t.action.clone())
|
||||
}
|
||||
/// 撤销当前操作的后续交付资格,并执行对应取消清理。
|
||||
pub fn cancel(&mut self) {
|
||||
self.pointer_active = None;
|
||||
self.key_active = None;
|
||||
}
|
||||
/// 撤销失焦后的键盘/指针激活状态,防止回到窗口时补发旧操作。
|
||||
pub fn blur(&mut self) {
|
||||
self.cancel();
|
||||
self.focused = None;
|
||||
self.keyboard_focus = false;
|
||||
self.pointer_left();
|
||||
}
|
||||
/// 用本帧目标集合校正跨帧焦点和捕获,已消失控件不保留可激活身份。
|
||||
pub fn reconcile<A>(&mut self, targets: &[Target<A>]) -> bool {
|
||||
let before = (
|
||||
self.hovered,
|
||||
@@ -127,11 +147,13 @@ impl Interaction {
|
||||
self.key_active,
|
||||
)
|
||||
}
|
||||
/// 返回当前仍持有焦点的稳定控件身份。
|
||||
pub fn focused<'a, A>(&self, targets: &'a [Target<A>]) -> Option<&'a Target<A>> {
|
||||
targets
|
||||
.iter()
|
||||
.find(|t| Some(t.id) == self.focused && t.enabled)
|
||||
}
|
||||
/// 沿启用且可聚焦的目标循环导航,支持正向与反向遍历。
|
||||
pub fn focus_next<A: Clone>(&mut self, targets: &[Target<A>], back: bool) -> Option<Target<A>> {
|
||||
self.cancel();
|
||||
self.keyboard_focus = true;
|
||||
@@ -154,18 +176,21 @@ impl Interaction {
|
||||
self.focused = Some(target.id);
|
||||
Some(target.clone())
|
||||
}
|
||||
/// 为当前焦点控件记录键盘激活,后续释放仍须匹配目标。
|
||||
pub fn key_press<A>(&mut self, targets: &[Target<A>]) {
|
||||
self.key_active = self
|
||||
.focused(targets)
|
||||
.filter(|t| t.role != Role::TextField)
|
||||
.map(|t| t.id);
|
||||
}
|
||||
/// 结束键盘激活并按有效控件身份返回操作。
|
||||
pub fn key_release<A: Clone>(&mut self, targets: &[Target<A>]) -> Option<A> {
|
||||
let active = self.key_active.take()?;
|
||||
self.focused(targets)
|
||||
.filter(|t| t.id == active)
|
||||
.and_then(|t| t.action.clone())
|
||||
}
|
||||
/// 组合悬停、按下、焦点、选中和启用状态,供控件选择正确样式。
|
||||
pub fn visual(&self, id: WidgetId, selected: bool, enabled: bool) -> VisualState {
|
||||
let hovered = enabled && self.hovered == Some(id);
|
||||
VisualState {
|
||||
@@ -177,9 +202,11 @@ impl Interaction {
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
/// 返回本次拖动/按压捕获的控件,移动过程中不随悬停目标跳变。
|
||||
pub fn captured(&self, id: WidgetId) -> bool {
|
||||
self.pointer_active == Some(id)
|
||||
}
|
||||
/// 按命中角色选择系统光标,例如文本、分隔条或可点击按钮。
|
||||
pub fn cursor<A>(&self, targets: &[Target<A>]) -> Cursor {
|
||||
let target = self
|
||||
.pointer_active
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 宿主管理控件的共享组件入口。主题、绘制原语和跨帧交互状态分离,用户 Endor 页面不依赖这里的控件树。
|
||||
//!
|
||||
//! Tessera's host management widgets. No Endor AST, VM, UiNode or ui_bridge dependency.
|
||||
//! Shares only low-level drawing, font shaping and geometry with guest content.
|
||||
mod interaction;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! 宿主控件的颜色与状态调色。根据背景、强调色和交互状态生成可读颜色,不改变业务状态。
|
||||
//!
|
||||
use crate::display_list::Color;
|
||||
pub const BG: Color = Color::rgb(255, 255, 255);
|
||||
// A visibly separate chrome layer against the warm, near-white content canvas.
|
||||
@@ -16,25 +18,31 @@ pub const PALETTE: [Color; 6] = [
|
||||
Color::rgb(52, 123, 78),
|
||||
];
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
/// 当前管理界面的配色基础,派生状态颜色由方法统一计算。
|
||||
pub struct Theme {
|
||||
pub accent: Color,
|
||||
}
|
||||
impl Theme {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(accent: Color) -> Self {
|
||||
Self {
|
||||
accent: readable_accent(accent),
|
||||
}
|
||||
}
|
||||
/// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。
|
||||
pub fn soft(self) -> Color {
|
||||
mix(BG, self.accent, 0.10)
|
||||
}
|
||||
/// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。
|
||||
pub fn hover(self) -> Color {
|
||||
mix(BG, self.accent, 0.17)
|
||||
}
|
||||
/// 从当前主题生成对应交互状态颜色,保持控件在状态变化时的视觉一致性。
|
||||
pub fn pressed(self) -> Color {
|
||||
mix(BG, self.accent, 0.29)
|
||||
}
|
||||
}
|
||||
/// 按比例混合颜色通道,作为状态颜色的基础运算。
|
||||
pub fn mix(a: Color, b: Color, amount: f32) -> Color {
|
||||
let channel =
|
||||
|a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * amount.clamp(0.0, 1.0)).round() as u8;
|
||||
@@ -44,6 +52,7 @@ pub fn mix(a: Color, b: Color, amount: f32) -> Color {
|
||||
channel(a.blue, b.blue),
|
||||
)
|
||||
}
|
||||
/// 根据背景选择可读的强调色,供选中与焦点状态使用。
|
||||
pub fn readable_accent(mut c: Color) -> Color {
|
||||
while (c.red as u32 * 299 + c.green as u32 * 587 + c.blue as u32 * 114) / 1000 > 155 {
|
||||
c = Color::rgb(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! 用统一主题绘制宿主管理控件,并同步注册交互目标。
|
||||
//! 每个控件的图形、角色、启用状态和 action 一起登记,绘制几何与命中几何保持一致。
|
||||
//!
|
||||
use super::{
|
||||
Axis, Interaction, Role, Target, VisualState, WidgetId,
|
||||
theme::{self, Theme},
|
||||
@@ -9,6 +12,7 @@ use crate::{
|
||||
};
|
||||
use std::hash::Hash;
|
||||
|
||||
/// 一次按钮绘制的借用描述,包括显示、角色和启用/选中状态。
|
||||
pub struct Button<'a> {
|
||||
pub label: &'a str,
|
||||
pub selected: bool,
|
||||
@@ -16,6 +20,7 @@ pub struct Button<'a> {
|
||||
pub role: Role,
|
||||
}
|
||||
impl<'a> Button<'a> {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
@@ -24,20 +29,24 @@ impl<'a> Button<'a> {
|
||||
role: Role::Button,
|
||||
}
|
||||
}
|
||||
/// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。
|
||||
pub fn selected(mut self, value: bool) -> Self {
|
||||
self.selected = value;
|
||||
self
|
||||
}
|
||||
/// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。
|
||||
pub fn enabled(mut self, value: bool) -> Self {
|
||||
self.enabled = value;
|
||||
self
|
||||
}
|
||||
/// 设置当前控件描述的选择、启用或角色信息,供后续绘制和目标登记使用。
|
||||
pub fn role(mut self, value: Role) -> Self {
|
||||
self.role = value;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 本帧宿主控件绘制上下文,共同拥有显示列表与交互目标登记入口。
|
||||
pub struct Ui<A> {
|
||||
pub list: DisplayList,
|
||||
pub targets: Vec<Target<A>>,
|
||||
@@ -45,6 +54,7 @@ pub struct Ui<A> {
|
||||
interaction: Interaction,
|
||||
}
|
||||
impl<A: Hash> Ui<A> {
|
||||
/// 构造本类型并初始化其状态;资源建立和默认字段以函数体为准。
|
||||
pub fn new(theme: Theme, interaction: Interaction) -> Self {
|
||||
Self {
|
||||
list: DisplayList::default(),
|
||||
@@ -53,6 +63,7 @@ impl<A: Hash> Ui<A> {
|
||||
interaction,
|
||||
}
|
||||
}
|
||||
/// 使用当前主题绘制控件填充、轮廓或文字原语。
|
||||
pub fn fill(&mut self, r: Rect, color: Color) {
|
||||
self.list.push(DrawCommand::FillRoundedRect {
|
||||
rect: r,
|
||||
@@ -60,6 +71,7 @@ impl<A: Hash> Ui<A> {
|
||||
color,
|
||||
});
|
||||
}
|
||||
/// 使用当前主题绘制控件填充、轮廓或文字原语。
|
||||
fn outline(&mut self, r: Rect, color: Color, width: f32) {
|
||||
self.list.push(DrawCommand::StrokeRoundedRect {
|
||||
rect: Rect::new(
|
||||
@@ -73,6 +85,7 @@ impl<A: Hash> Ui<A> {
|
||||
color,
|
||||
});
|
||||
}
|
||||
/// 使用当前主题绘制控件填充、轮廓或文字原语。
|
||||
pub fn label(&mut self, x: f32, y: f32, value: &str, color: Color, code: bool) {
|
||||
self.list.push(DrawCommand::Text {
|
||||
origin: Point::new(x, y),
|
||||
@@ -89,6 +102,7 @@ impl<A: Hash> Ui<A> {
|
||||
color,
|
||||
});
|
||||
}
|
||||
/// 把控件身份、几何、角色、启用状态和 action 一起加入本帧交互目标。
|
||||
fn register(
|
||||
&mut self,
|
||||
id: WidgetId,
|
||||
@@ -108,6 +122,7 @@ impl<A: Hash> Ui<A> {
|
||||
});
|
||||
response
|
||||
}
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
fn surface(&mut self, rect: Rect, s: VisualState, base: Option<Color>) {
|
||||
let color = if !s.enabled {
|
||||
base
|
||||
@@ -127,6 +142,7 @@ impl<A: Hash> Ui<A> {
|
||||
self.outline(rect, self.theme.accent, 1.5);
|
||||
}
|
||||
}
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
pub fn button(&mut self, rect: Rect, action: A, button: Button<'_>) -> VisualState {
|
||||
let id = WidgetId::new(&action);
|
||||
let s = self.register(
|
||||
@@ -168,6 +184,7 @@ impl<A: Hash> Ui<A> {
|
||||
self.list.push(DrawCommand::PopClip);
|
||||
s
|
||||
}
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
pub fn text_field(&mut self, rect: Rect, action: A, focused: bool) {
|
||||
let s = self.register(
|
||||
WidgetId::new(&action),
|
||||
@@ -191,6 +208,7 @@ impl<A: Hash> Ui<A> {
|
||||
);
|
||||
}
|
||||
/// A terminal prompt shares its surface with output; its caret is the focus cue.
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
pub fn inline_input(&mut self, rect: Rect, action: A) {
|
||||
self.register(
|
||||
WidgetId::new(&action),
|
||||
@@ -201,6 +219,7 @@ impl<A: Hash> Ui<A> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
pub fn row(&mut self, paint: Rect, hit: Rect, action: A, selected: bool) {
|
||||
let s = self.register(
|
||||
WidgetId::new(&action),
|
||||
@@ -212,6 +231,7 @@ impl<A: Hash> Ui<A> {
|
||||
);
|
||||
self.surface(paint, s, None);
|
||||
}
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
pub fn swatch(&mut self, rect: Rect, action: A, color: Color, selected: bool) {
|
||||
let s = self.register(
|
||||
WidgetId::new(&action),
|
||||
@@ -249,6 +269,7 @@ impl<A: Hash> Ui<A> {
|
||||
self.label(rect.x + 10.0, rect.y + 6.0, "✓", theme::BG, false);
|
||||
}
|
||||
}
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
pub fn splitter(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
|
||||
let hit = Rect::new(
|
||||
rect.x - 3.0,
|
||||
@@ -273,6 +294,7 @@ impl<A: Hash> Ui<A> {
|
||||
};
|
||||
self.fill(line, color);
|
||||
}
|
||||
/// 绘制对应宿主管理控件,并使用同一矩形注册命中/捕获所需的交互信息。
|
||||
pub fn scrollbar(&mut self, rect: Rect, id: WidgetId, axis: Axis) {
|
||||
let mut s = self.register(id, rect, Role::Scrollbar(axis), None, false, true);
|
||||
s.pressed |= self.interaction.captured(id);
|
||||
|
||||
+5
-669
@@ -1,31 +1,11 @@
|
||||
//! Immutable tab chrome. IDs are session-local projections, never heap handles.
|
||||
use crate::{
|
||||
display_list::{Color, DisplayList, DrawCommand},
|
||||
geometry::{Point, Rect},
|
||||
text::TextStyle,
|
||||
};
|
||||
//! 发布页面标签的数据结构。保留稳定页面身份、标题、选中状态和开发工具类别;页面的托管所有权由模型层管理。
|
||||
//!
|
||||
//! Immutable page registry projections. Host navigation is rendered in sidebar.
|
||||
//! IDs are session-local data and never own managed VM objects.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hover;
|
||||
mod motion;
|
||||
pub use motion::{Motion, Visual};
|
||||
|
||||
// Native traffic lights and all tab controls share this row's center line.
|
||||
pub const HEIGHT: f32 = 40.0;
|
||||
pub const INSET: f32 = 8.0;
|
||||
const NEW_TAB_SPACE: f32 = 44.0;
|
||||
const MIN_WIDTH: f32 = 88.0;
|
||||
pub const RAIL: f32 = 56.0;
|
||||
pub const CONTROLS: f32 = if cfg!(target_os = "macos") { 86.0 } else { 0.0 };
|
||||
pub const LEADING: f32 = CONTROLS + 12.0;
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Chrome {
|
||||
pub offset: Option<f32>,
|
||||
pub insertion: Option<usize>,
|
||||
pub pointer: Option<Point>,
|
||||
pub visual: Visual,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
/// 托管页面注册表的不可变投影,原生工具只使用稳定 ID 与显示元数据。
|
||||
pub struct Tab {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
@@ -33,647 +13,3 @@ pub struct Tab {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub developer_tool: Option<String>,
|
||||
}
|
||||
|
||||
pub fn height(tabs: &[Tab]) -> f32 {
|
||||
let _ = tabs;
|
||||
HEIGHT
|
||||
}
|
||||
|
||||
/// Scroll offsets are logical pixels, never a discrete page index.
|
||||
fn viewport(width: f32) -> Rect {
|
||||
Rect::new(
|
||||
LEADING,
|
||||
0.0,
|
||||
(width - NEW_TAB_SPACE - LEADING).max(0.0),
|
||||
HEIGHT,
|
||||
)
|
||||
}
|
||||
fn tab_width(tabs: &[Tab], width: f32) -> f32 {
|
||||
(viewport(width).width / tabs.len().max(1) as f32)
|
||||
.clamp(MIN_WIDTH, 208.0)
|
||||
.min(viewport(width).width)
|
||||
}
|
||||
fn scroll_offset(tabs: &[Tab], width: f32, start: Option<f32>) -> f32 {
|
||||
let tab_width = tab_width(tabs, width);
|
||||
let available = viewport(width).width;
|
||||
let maximum = (tabs.len() as f32 * tab_width - available).max(0.0);
|
||||
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0);
|
||||
start
|
||||
.unwrap_or(((active + 1) as f32 * tab_width - available).max(0.0))
|
||||
.clamp(0.0, maximum)
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn visible_range(tabs: &[Tab], width: f32, start: Option<f32>) -> std::ops::Range<usize> {
|
||||
let tab_width = tab_width(tabs, width);
|
||||
if tab_width <= 0.0 {
|
||||
return 0..0;
|
||||
}
|
||||
let offset = scroll_offset(tabs, width, start);
|
||||
(offset / tab_width).floor() as usize
|
||||
..(((offset + viewport(width).width) / tab_width).ceil() as usize).min(tabs.len())
|
||||
}
|
||||
|
||||
/// Invisible drag edge zones only; ordinary clicks never scroll or activate another tab.
|
||||
pub fn edge_direction(tabs: &[Tab], width: f32, start: Option<f32>, point: Point) -> i8 {
|
||||
if !viewport(width).contains(point) || point.y >= HEIGHT {
|
||||
return 0;
|
||||
}
|
||||
let offset = scroll_offset(tabs, width, start);
|
||||
let maximum = (tabs.len() as f32 * tab_width(tabs, width) - viewport(width).width).max(0.0);
|
||||
if point.x < LEADING + 24.0 && offset > 0.0 {
|
||||
-1
|
||||
} else if point.x > width - NEW_TAB_SPACE - 24.0 && offset < maximum {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
pub fn wheel_offset(tabs: &[Tab], width: f32, start: Option<f32>, delta: f32) -> f32 {
|
||||
scroll_offset(tabs, width, Some(scroll_offset(tabs, width, start) + delta))
|
||||
}
|
||||
pub fn scroll_start(tabs: &[Tab], width: f32, start: Option<f32>, direction: i8) -> Option<f32> {
|
||||
let current = scroll_offset(tabs, width, start);
|
||||
let next = wheel_offset(tabs, width, Some(current), direction as f32 * MIN_WIDTH);
|
||||
(next != current).then_some(next)
|
||||
}
|
||||
struct Item<'a> {
|
||||
tab: &'a Tab,
|
||||
rect: Rect,
|
||||
}
|
||||
/// Retain all IDs, including clipped tabs, for continuous movement through the viewport.
|
||||
fn tab_rects(start: Option<f32>, tabs: &[Tab], width: f32) -> Vec<(u64, Rect)> {
|
||||
let tab_width = tab_width(tabs, width);
|
||||
let offset = scroll_offset(tabs, width, start);
|
||||
tabs.iter()
|
||||
.enumerate()
|
||||
.map(|(index, tab)| {
|
||||
(
|
||||
tab.id,
|
||||
Rect::new(
|
||||
LEADING + index as f32 * tab_width - offset,
|
||||
0.0,
|
||||
tab_width,
|
||||
HEIGHT,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn items(start: Option<f32>, tabs: &[Tab], width: f32) -> Vec<Item<'_>> {
|
||||
let viewport = viewport(width);
|
||||
tabs.iter()
|
||||
.zip(tab_rects(start, tabs, width))
|
||||
.filter(|(_, (_, rect))| {
|
||||
rect.x + rect.width > viewport.x && rect.x < viewport.x + viewport.width
|
||||
})
|
||||
.map(|(tab, (_, rect))| Item { tab, rect })
|
||||
.collect()
|
||||
}
|
||||
fn pill_rect(rect: Rect) -> Rect {
|
||||
Rect::new(rect.x + 2.0, 4.0, (rect.width - 4.0).max(0.0), HEIGHT - 8.0)
|
||||
}
|
||||
fn close_rect(rect: Rect) -> Rect {
|
||||
Rect::new(
|
||||
rect.x + rect.width - 25.0,
|
||||
(HEIGHT - 18.0) / 2.0,
|
||||
18.0,
|
||||
18.0,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn hit(start: Option<f32>, tabs: &[Tab], width: f32, point: Point) -> Option<(u64, bool)> {
|
||||
if point.x < LEADING
|
||||
|| point.x >= width - NEW_TAB_SPACE
|
||||
|| point.y < 0.0
|
||||
|| point.y >= height(tabs)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
items(start, tabs, width)
|
||||
.into_iter()
|
||||
.find(|item| pill_rect(item.rect).contains(point))
|
||||
.map(|item| (item.tab.id, close_rect(item.rect).contains(point)))
|
||||
}
|
||||
|
||||
/// Gap index in the complete published order, including overflow-hidden tabs.
|
||||
pub fn insertion_index(
|
||||
start: Option<f32>,
|
||||
tabs: &[Tab],
|
||||
width: f32,
|
||||
point: Point,
|
||||
) -> Option<usize> {
|
||||
if point.x < LEADING || point.x >= width - NEW_TAB_SPACE || point.y < 0.0 || point.y >= HEIGHT {
|
||||
return None;
|
||||
}
|
||||
if tabs.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
let visible: Vec<_> = items(start, tabs, width).into_iter().collect();
|
||||
for item in &visible {
|
||||
if point.x < item.rect.x + item.rect.width / 2.0 {
|
||||
return tabs.iter().position(|tab| tab.id == item.tab.id);
|
||||
}
|
||||
}
|
||||
visible
|
||||
.last()
|
||||
.and_then(|last| tabs.iter().position(|tab| tab.id == last.tab.id))
|
||||
.map(|index| index + 1)
|
||||
}
|
||||
|
||||
pub fn drag_anchor(
|
||||
start: Option<f32>,
|
||||
tabs: &[Tab],
|
||||
width: f32,
|
||||
id: u64,
|
||||
point: Point,
|
||||
) -> Option<Point> {
|
||||
if !viewport(width).contains(point) {
|
||||
return None;
|
||||
}
|
||||
let item = items(start, tabs, width)
|
||||
.into_iter()
|
||||
.find(|item| item.tab.id == id && pill_rect(item.rect).contains(point))?;
|
||||
(!close_rect(item.rect).contains(point)).then(|| Point::new(point.x - item.rect.x, point.y))
|
||||
}
|
||||
|
||||
pub fn draw_insertion(
|
||||
start: Option<f32>,
|
||||
list: &mut DisplayList,
|
||||
tabs: &[Tab],
|
||||
width: f32,
|
||||
index: usize,
|
||||
scale: f32,
|
||||
) {
|
||||
let visible: Vec<_> = items(start, tabs, width).into_iter().collect();
|
||||
let x = if tabs.is_empty() {
|
||||
Some(LEADING + 8.0)
|
||||
} else {
|
||||
visible
|
||||
.iter()
|
||||
.find(|item| tabs.get(index).is_some_and(|tab| tab.id == item.tab.id))
|
||||
.map(|item| item.rect.x)
|
||||
.or_else(|| {
|
||||
visible
|
||||
.last()
|
||||
.filter(|item| {
|
||||
tabs.iter().position(|tab| tab.id == item.tab.id) == index.checked_sub(1)
|
||||
})
|
||||
.map(|item| item.rect.x + item.rect.width)
|
||||
})
|
||||
};
|
||||
if let Some(x) = x {
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: Rect::new(
|
||||
x.clamp(1.5, (width - 1.5).max(1.5)) * scale - 1.5 * scale,
|
||||
3.0 * scale,
|
||||
3.0 * scale,
|
||||
(HEIGHT - 6.0) * scale,
|
||||
),
|
||||
radius: scale,
|
||||
color: Color::rgb(56, 95, 220),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plus_rect(start: Option<f32>, tabs: &[Tab], width: f32) -> Rect {
|
||||
if tabs.len() as f32 * tab_width(tabs, width) > viewport(width).width {
|
||||
return Rect::new(width - 34.0, (HEIGHT - 28.0) / 2.0, 24.0, 28.0);
|
||||
}
|
||||
let end = items(start, tabs, width)
|
||||
.iter()
|
||||
.map(|item| item.rect.x + item.rect.width)
|
||||
.fold(LEADING, f32::max);
|
||||
Rect::new(
|
||||
(end + 4.0).min(width - 34.0),
|
||||
(HEIGHT - 28.0) / 2.0,
|
||||
24.0,
|
||||
28.0,
|
||||
)
|
||||
}
|
||||
|
||||
fn tab_style() -> TextStyle {
|
||||
TextStyle {
|
||||
font_size: 13.0,
|
||||
line_height: 18.0,
|
||||
..TextStyle::ui(1)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
_start: Option<f32>,
|
||||
list: &mut DisplayList,
|
||||
tabs: &[Tab],
|
||||
width: f32,
|
||||
scale: f32,
|
||||
_pointer: Option<Point>,
|
||||
visual: &Visual,
|
||||
) {
|
||||
let scaled = |rect: Rect| {
|
||||
Rect::new(
|
||||
rect.x * scale,
|
||||
rect.y * scale,
|
||||
rect.width * scale,
|
||||
rect.height * scale,
|
||||
)
|
||||
};
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: scaled(Rect::new(0.0, 0.0, width, HEIGHT)),
|
||||
radius: 0.0,
|
||||
color: crate::system_ui::theme::BAR,
|
||||
});
|
||||
let plus = visual.plus;
|
||||
if visual.plus_hover > 0.0 {
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: scaled(plus),
|
||||
radius: 6.0 * scale,
|
||||
color: crate::system_ui::theme::mix(
|
||||
crate::system_ui::theme::BAR,
|
||||
crate::system_ui::theme::TEXT,
|
||||
0.06 * visual.plus_hover,
|
||||
),
|
||||
});
|
||||
}
|
||||
list.push(DrawCommand::Text {
|
||||
origin: Point::new(
|
||||
(plus.x + (plus.width - crate::text::measure("+", tab_style()).width) / 2.0) * scale,
|
||||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||||
),
|
||||
value: "+".into(),
|
||||
style: tab_style().scaled(scale),
|
||||
color: crate::system_ui::theme::MUTED,
|
||||
});
|
||||
let mut paint_items: Vec<_> = tabs
|
||||
.iter()
|
||||
.map(|tab| Item {
|
||||
tab,
|
||||
rect: visual.rect(tab.id, Rect::default()),
|
||||
})
|
||||
.collect();
|
||||
paint_items.extend(
|
||||
visual
|
||||
.exiting
|
||||
.iter()
|
||||
.map(|(tab, rect)| Item { tab, rect: *rect }),
|
||||
);
|
||||
for item in paint_items {
|
||||
{
|
||||
if item.rect.width <= 0.0
|
||||
|| item.rect.x + item.rect.width <= visual.viewport.x
|
||||
|| item.rect.x >= visual.viewport.x + visual.viewport.width
|
||||
{
|
||||
continue;
|
||||
}
|
||||
list.push(DrawCommand::PushClip(scaled(visual.viewport)));
|
||||
list.push(DrawCommand::PushClip(scaled(pill_rect(item.rect))));
|
||||
}
|
||||
let pill = scaled(pill_rect(item.rect));
|
||||
let active = item.tab.active;
|
||||
let hover = visual.hover(item.tab.id);
|
||||
let background = if active {
|
||||
crate::system_ui::theme::BG
|
||||
} else {
|
||||
crate::system_ui::theme::mix(
|
||||
crate::system_ui::theme::BAR,
|
||||
crate::system_ui::theme::TEXT,
|
||||
0.06 * hover,
|
||||
)
|
||||
};
|
||||
if active || hover > 0.0 {
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: pill,
|
||||
radius: 6.0 * scale,
|
||||
color: background,
|
||||
});
|
||||
}
|
||||
{
|
||||
// Match the close button with an 18px slot and 7px edge inset.
|
||||
let icon = "◇";
|
||||
list.push(DrawCommand::Text {
|
||||
origin: Point::new(
|
||||
(item.rect.x
|
||||
+ 7.0
|
||||
+ (18.0 - crate::text::measure(icon, tab_style()).width) / 2.0)
|
||||
* scale,
|
||||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||||
),
|
||||
value: icon.into(),
|
||||
style: tab_style().scaled(scale),
|
||||
color: crate::system_ui::theme::MUTED,
|
||||
});
|
||||
}
|
||||
let title = &item.tab.title;
|
||||
let text_rect = Rect::new(
|
||||
item.rect.x + 30.0,
|
||||
(HEIGHT - tab_style().line_height) / 2.0,
|
||||
(item.rect.width - 58.0).max(0.0),
|
||||
tab_style().line_height,
|
||||
);
|
||||
list.push(DrawCommand::PushClip(scaled(text_rect)));
|
||||
list.push(DrawCommand::Text {
|
||||
origin: Point::new(
|
||||
(item.rect.x + 30.0) * scale,
|
||||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||||
),
|
||||
value: title.into(),
|
||||
style: tab_style().scaled(scale),
|
||||
color: if active {
|
||||
crate::system_ui::theme::TEXT
|
||||
} else {
|
||||
crate::system_ui::theme::MUTED
|
||||
},
|
||||
});
|
||||
list.push(DrawCommand::PopClip);
|
||||
if item.rect.width >= 44.0 {
|
||||
let close_hover = visual.close_hover(item.tab.id);
|
||||
if close_hover > 0.0 {
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: scaled(close_rect(item.rect)),
|
||||
radius: 5.0 * scale,
|
||||
color: crate::system_ui::theme::mix(
|
||||
background,
|
||||
crate::system_ui::theme::mix(
|
||||
crate::system_ui::theme::BAR,
|
||||
crate::system_ui::theme::TEXT,
|
||||
0.12,
|
||||
),
|
||||
close_hover,
|
||||
),
|
||||
});
|
||||
}
|
||||
list.push(DrawCommand::Text {
|
||||
origin: Point::new(
|
||||
(close_rect(item.rect).x
|
||||
+ (close_rect(item.rect).width
|
||||
- crate::text::measure("×", tab_style()).width)
|
||||
/ 2.0)
|
||||
* scale,
|
||||
((HEIGHT - tab_style().line_height) / 2.0) * scale,
|
||||
),
|
||||
value: "×".into(),
|
||||
style: tab_style().scaled(scale),
|
||||
color: crate::system_ui::theme::MUTED,
|
||||
});
|
||||
}
|
||||
{
|
||||
list.push(DrawCommand::PopClip);
|
||||
list.push(DrawCommand::PopClip);
|
||||
}
|
||||
}
|
||||
// Quiet boundaries only between two idle tabs; the active/hovered pill owns its edges.
|
||||
list.push(DrawCommand::PushClip(scaled(visual.viewport)));
|
||||
for pair in tabs.windows(2) {
|
||||
let left = visual.rect(pair[0].id, Rect::default());
|
||||
let hover = visual.hover(pair[0].id).max(visual.hover(pair[1].id));
|
||||
if !pair[0].active && !pair[1].active && hover < 1.0 {
|
||||
list.push(DrawCommand::FillRoundedRect {
|
||||
rect: scaled(Rect::new(
|
||||
left.x + left.width - 0.5,
|
||||
(HEIGHT - 12.0) / 2.0,
|
||||
1.0,
|
||||
12.0,
|
||||
)),
|
||||
radius: 0.5 * scale,
|
||||
color: crate::system_ui::theme::mix(
|
||||
crate::system_ui::theme::BAR,
|
||||
crate::system_ui::theme::TEXT,
|
||||
0.18 * (1.0 - hover),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
list.push(DrawCommand::PopClip);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn insertion_gaps_share_visible_tab_geometry_and_exclude_close_drags() {
|
||||
let tabs: Vec<_> = (1..=4)
|
||||
.map(|id| Tab {
|
||||
id,
|
||||
title: id.to_string(),
|
||||
developer_tool: None,
|
||||
active: id == 1,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
insertion_index(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||||
Point::new(LEADING + 10.0, 18.0)
|
||||
),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
insertion_index(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||||
Point::new(LEADING + 215.0, 18.0)
|
||||
),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
insertion_index(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||||
Point::new(LEADING + 990.0, 18.0)
|
||||
),
|
||||
Some(4)
|
||||
);
|
||||
assert!(
|
||||
drag_anchor(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||||
1,
|
||||
Point::new(LEADING + 190.0, 18.0)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
drag_anchor(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||||
1,
|
||||
Point::new(LEADING + 25.0, 18.0)
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
insertion_index(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 1000.0,
|
||||
Point::new(LEADING + 20.0, HEIGHT)
|
||||
),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
drag_anchor(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 400.0,
|
||||
3,
|
||||
Point::new(LEADING + 390.0, 18.0)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
insertion_index(
|
||||
None,
|
||||
&[],
|
||||
LEADING + NEW_TAB_SPACE + 720.0,
|
||||
Point::new(LEADING + 20.0, 18.0)
|
||||
),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_keeps_active_page_visible_without_navigation_buttons() {
|
||||
let tabs: Vec<_> = (1..=64)
|
||||
.map(|id| Tab {
|
||||
id,
|
||||
title: format!("Page {id}"),
|
||||
developer_tool: None,
|
||||
active: id == 32,
|
||||
})
|
||||
.collect();
|
||||
let visible = items(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0);
|
||||
assert!(visible.iter().any(|item| item.tab.active));
|
||||
for item in &visible {
|
||||
let point = Point::new((item.rect.x + 12.0).max(LEADING + 3.0), 18.0);
|
||||
assert_eq!(
|
||||
hit(None, &tabs, LEADING + NEW_TAB_SPACE + 720.0, point),
|
||||
Some((item.tab.id, close_rect(item.rect).contains(point)))
|
||||
);
|
||||
}
|
||||
let tab = visible.iter().find(|item| item.tab.active).unwrap();
|
||||
assert_eq!(
|
||||
hit(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 720.0,
|
||||
Point::new(tab.rect.x + tab.rect.width - 15.0, 18.0)
|
||||
),
|
||||
Some((32, true))
|
||||
);
|
||||
assert_eq!(
|
||||
hit(
|
||||
None,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 720.0,
|
||||
Point::new(LEADING + 50.0, HEIGHT)
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_viewport_reaches_hidden_ends_without_changing_active_page() {
|
||||
let tabs: Vec<_> = (1..=64)
|
||||
.map(|id| Tab {
|
||||
id,
|
||||
title: id.to_string(),
|
||||
developer_tool: None,
|
||||
active: id == 32,
|
||||
})
|
||||
.collect();
|
||||
let width = LEADING + NEW_TAB_SPACE + 720.0;
|
||||
let mut start = None;
|
||||
while let Some(next) = scroll_start(&tabs, width, start, 1) {
|
||||
start = Some(next);
|
||||
}
|
||||
assert_eq!(visible_range(&tabs, width, start), 55..64);
|
||||
assert_eq!(
|
||||
edge_direction(&tabs, width, start, Point::new(LEADING + 710.0, 18.0)),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
insertion_index(start, &tabs, width, Point::new(LEADING + 710.0, 18.0)),
|
||||
Some(64)
|
||||
);
|
||||
while let Some(next) = scroll_start(&tabs, width, start, -1) {
|
||||
start = Some(next);
|
||||
}
|
||||
assert_eq!(visible_range(&tabs, width, start), 0..9);
|
||||
assert_eq!(
|
||||
edge_direction(&tabs, width, start, Point::new(LEADING + 10.0, 18.0)),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
insertion_index(start, &tabs, width, Point::new(LEADING + 10.0, 18.0)),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
tabs.iter()
|
||||
.filter(|tab| tab.active)
|
||||
.map(|tab| tab.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![32]
|
||||
);
|
||||
assert!(items(None, &tabs, width).iter().any(|item| item.tab.active));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolled_hit_and_gap_geometry_survive_resize_and_list_shrink() {
|
||||
let tabs: Vec<_> = (1..=16)
|
||||
.map(|id| Tab {
|
||||
id,
|
||||
title: id.to_string(),
|
||||
developer_tool: None,
|
||||
active: id == 1,
|
||||
})
|
||||
.collect();
|
||||
let start = Some(4.0 * MIN_WIDTH);
|
||||
assert_eq!(
|
||||
hit(
|
||||
start,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 720.0,
|
||||
Point::new(LEADING + 40.0, 18.0)
|
||||
),
|
||||
Some((5, false))
|
||||
);
|
||||
assert_eq!(
|
||||
insertion_index(
|
||||
start,
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 720.0,
|
||||
Point::new(LEADING + 40.0, 18.0)
|
||||
),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
visible_range(&tabs[..3], LEADING + NEW_TAB_SPACE + 720.0, start),
|
||||
0..3
|
||||
);
|
||||
assert_eq!(
|
||||
visible_range(&tabs, LEADING + NEW_TAB_SPACE + 1920.0, start),
|
||||
0..16
|
||||
);
|
||||
assert_eq!(
|
||||
edge_direction(
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 720.0,
|
||||
start,
|
||||
Point::new(LEADING + 710.0, HEIGHT)
|
||||
),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
edge_direction(
|
||||
&tabs,
|
||||
LEADING + NEW_TAB_SPACE + 720.0,
|
||||
start,
|
||||
Point::new(LEADING + 725.0, 18.0)
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
//! Hover color transitions are independent of tab layout and selected state.
|
||||
use super::{Point, Tab, Visual};
|
||||
use std::{collections::BTreeMap, time::Instant};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fade {
|
||||
from: f32,
|
||||
target: f32,
|
||||
started: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Fade {
|
||||
fn sample(&mut self, hovered: bool, now: Instant) -> (f32, bool) {
|
||||
let t = self.started.map_or(1.0, |start| {
|
||||
(now.duration_since(start).as_secs_f32() / 0.16).min(1.0)
|
||||
});
|
||||
let value = self.from + (self.target - self.from) * (1.0 - (1.0 - t).powi(3));
|
||||
let target = if hovered { 1.0 } else { 0.0 };
|
||||
if target != self.target {
|
||||
// Reversing mid-fade starts at the currently visible color.
|
||||
self.from = value;
|
||||
self.target = target;
|
||||
self.started = Some(now);
|
||||
(value, true)
|
||||
} else {
|
||||
(value, t < 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Hover {
|
||||
tabs: BTreeMap<u64, (Fade, Fade)>,
|
||||
plus: Fade,
|
||||
}
|
||||
|
||||
impl Hover {
|
||||
pub fn sample(
|
||||
&mut self,
|
||||
visual: &mut Visual,
|
||||
tabs: &[Tab],
|
||||
pointer: Option<Point>,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
let hit = pointer.and_then(|point| visual.hit(point));
|
||||
let (plus, mut animating) = self.plus.sample(
|
||||
pointer.is_some_and(|point| visual.plus.contains(point)),
|
||||
now,
|
||||
);
|
||||
visual.plus_hover = plus;
|
||||
self.tabs
|
||||
.retain(|id, _| tabs.iter().any(|tab| tab.id == *id));
|
||||
visual.hovers.clear();
|
||||
for tab in tabs {
|
||||
let (body, close) = self.tabs.entry(tab.id).or_default();
|
||||
let (body, body_animating) = body.sample(hit.is_some_and(|hit| hit.0 == tab.id), now);
|
||||
let (close, close_animating) = close.sample(hit == Some((tab.id, true)), now);
|
||||
visual.hovers.push((tab.id, body, close));
|
||||
animating |= body_animating || close_animating;
|
||||
}
|
||||
animating
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
//! Interpolated chrome geometry is shared by painting and pointer hit testing.
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Visual {
|
||||
pub rects: Vec<(u64, Rect)>,
|
||||
pub exiting: Vec<(Tab, Rect)>,
|
||||
pub viewport: Rect,
|
||||
pub plus: Rect,
|
||||
pub hovers: Vec<(u64, f32, f32)>,
|
||||
pub plus_hover: f32,
|
||||
}
|
||||
impl Visual {
|
||||
pub fn hover(&self, id: u64) -> f32 {
|
||||
self.hovers
|
||||
.iter()
|
||||
.find(|entry| entry.0 == id)
|
||||
.map_or(0.0, |entry| entry.1)
|
||||
}
|
||||
pub fn close_hover(&self, id: u64) -> f32 {
|
||||
self.hovers
|
||||
.iter()
|
||||
.find(|entry| entry.0 == id)
|
||||
.map_or(0.0, |entry| entry.2)
|
||||
}
|
||||
pub fn rect(&self, id: u64, fallback: Rect) -> Rect {
|
||||
self.rects
|
||||
.iter()
|
||||
.find(|(key, _)| *key == id)
|
||||
.map_or(fallback, |(_, rect)| *rect)
|
||||
}
|
||||
pub fn hit(&self, point: Point) -> Option<(u64, bool)> {
|
||||
if !self.viewport.contains(point) {
|
||||
return None;
|
||||
}
|
||||
self.rects
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(_, rect)| pill_rect(*rect).contains(point))
|
||||
.map(|(id, rect)| (*id, close_rect(*rect).contains(point)))
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Motion {
|
||||
hover: super::hover::Hover,
|
||||
target: Visual,
|
||||
from: Visual,
|
||||
current: Visual,
|
||||
published: Vec<Tab>,
|
||||
initialized: bool,
|
||||
direct_scroll: bool,
|
||||
started: Option<Instant>,
|
||||
pub offset: Option<f32>,
|
||||
pub next_frame: Option<Instant>,
|
||||
}
|
||||
fn blend(a: Rect, b: Rect, t: f32) -> Rect {
|
||||
Rect::new(
|
||||
a.x + (b.x - a.x) * t,
|
||||
a.y + (b.y - a.y) * t,
|
||||
a.width + (b.width - a.width) * t,
|
||||
a.height + (b.height - a.height) * t,
|
||||
)
|
||||
}
|
||||
impl Motion {
|
||||
pub fn sample_hover(&mut self, visual: &mut Visual, tabs: &[Tab], pointer: Option<Point>) {
|
||||
let now = Instant::now();
|
||||
if self.hover.sample(visual, tabs, pointer, now) {
|
||||
let next = now + Duration::from_millis(16);
|
||||
self.next_frame = Some(self.next_frame.map_or(next, |old| old.min(next)));
|
||||
}
|
||||
}
|
||||
pub fn scroll_to(&mut self, offset: f32) {
|
||||
self.offset = Some(offset);
|
||||
self.direct_scroll = true;
|
||||
}
|
||||
|
||||
fn advance(&mut self, now: Instant) {
|
||||
let t = self.started.map_or(1.0, |start| {
|
||||
(now.duration_since(start).as_secs_f32() / 0.22).min(1.0)
|
||||
});
|
||||
let eased = 1.0 - (1.0 - t).powi(3);
|
||||
self.current = Visual {
|
||||
rects: self
|
||||
.target
|
||||
.rects
|
||||
.iter()
|
||||
.map(|(id, target)| (*id, blend(self.from.rect(*id, *target), *target, eased)))
|
||||
.collect(),
|
||||
exiting: self
|
||||
.target
|
||||
.exiting
|
||||
.iter()
|
||||
.filter(|_| t < 1.0)
|
||||
.map(|(tab, target)| {
|
||||
let from = self
|
||||
.from
|
||||
.exiting
|
||||
.iter()
|
||||
.find(|(old, _)| old.id == tab.id)
|
||||
.map_or(*target, |(_, rect)| *rect);
|
||||
(tab.clone(), blend(from, *target, eased))
|
||||
})
|
||||
.collect(),
|
||||
viewport: blend(self.from.viewport, self.target.viewport, eased),
|
||||
plus: blend(self.from.plus, self.target.plus, eased),
|
||||
..Visual::default()
|
||||
};
|
||||
self.next_frame = (t < 1.0).then(|| now + Duration::from_millis(16));
|
||||
}
|
||||
|
||||
pub fn sample(&mut self, tabs: &[Tab], offset: Option<f32>, width: f32) -> Visual {
|
||||
let now = Instant::now();
|
||||
// Retarget from the geometry at this instant, even during rapid repeated clicks.
|
||||
self.advance(now);
|
||||
let mut offset = scroll_offset(tabs, width, offset.or(self.offset));
|
||||
let selection_changed = self
|
||||
.published
|
||||
.iter()
|
||||
.find(|tab| tab.active)
|
||||
.map(|tab| tab.id)
|
||||
!= tabs.iter().find(|tab| tab.active).map(|tab| tab.id);
|
||||
if selection_changed {
|
||||
let active = tabs.iter().position(|tab| tab.active).unwrap_or(0) as f32;
|
||||
let tab_width = tab_width(tabs, width);
|
||||
let left = active * tab_width;
|
||||
let right = left + tab_width;
|
||||
offset = offset.min(left).max(right - viewport(width).width).max(0.0);
|
||||
}
|
||||
self.offset = Some(offset);
|
||||
let mut target = Visual {
|
||||
rects: tab_rects(Some(offset), tabs, width),
|
||||
viewport: viewport(width),
|
||||
plus: plus_rect(Some(offset), tabs, width),
|
||||
..Visual::default()
|
||||
};
|
||||
if !self.initialized || self.direct_scroll {
|
||||
self.direct_scroll = false;
|
||||
self.started = None;
|
||||
self.next_frame = None;
|
||||
// Startup is already laid out; it is not an insertion of every restored tab.
|
||||
self.initialized = true;
|
||||
self.current = target.clone();
|
||||
self.from = target.clone();
|
||||
self.target = target;
|
||||
self.published = tabs.to_vec();
|
||||
return self.current.clone();
|
||||
}
|
||||
if self.target.rects != target.rects
|
||||
|| self.target.plus != target.plus
|
||||
|| self.target.viewport != target.viewport
|
||||
{
|
||||
self.from = self.current.clone();
|
||||
for (index, (id, rect)) in target.rects.iter().enumerate() {
|
||||
if !self.from.rects.iter().any(|(old, _)| old == id) {
|
||||
// Grow out of the previous trailing edge, not the final compressed x.
|
||||
let x = target.rects[..index]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(previous, _)| {
|
||||
self.from
|
||||
.rects
|
||||
.iter()
|
||||
.find(|(old, _)| old == previous)
|
||||
.map(|(_, previous)| previous.x + previous.width)
|
||||
})
|
||||
.unwrap_or(rect.x);
|
||||
self.from
|
||||
.rects
|
||||
.push((*id, Rect::new(x, rect.y, 0.0, rect.height)));
|
||||
}
|
||||
}
|
||||
for tab in &self.published {
|
||||
if !tabs.iter().any(|live| live.id == tab.id) {
|
||||
self.from
|
||||
.exiting
|
||||
.push((tab.clone(), self.current.rect(tab.id, Rect::default())));
|
||||
}
|
||||
}
|
||||
// A closed tab shrinks with the gap; it never remains an interactive target.
|
||||
target.exiting = self
|
||||
.from
|
||||
.exiting
|
||||
.iter()
|
||||
.filter(|(tab, _)| !tabs.iter().any(|live| live.id == tab.id))
|
||||
.map(|(tab, rect)| {
|
||||
let next = self
|
||||
.published
|
||||
.iter()
|
||||
.position(|old| old.id == tab.id)
|
||||
.and_then(|index| {
|
||||
self.published[index + 1..]
|
||||
.iter()
|
||||
.find(|old| tabs.iter().any(|live| live.id == old.id))
|
||||
});
|
||||
let x = next.map_or(target.plus.x - 4.0, |next| target.rect(next.id, *rect).x);
|
||||
(tab.clone(), Rect::new(x, rect.y, 0.0, rect.height))
|
||||
})
|
||||
.collect();
|
||||
self.target = target;
|
||||
self.started = Some(now);
|
||||
}
|
||||
self.published = tabs.to_vec();
|
||||
self.advance(now);
|
||||
self.current.clone()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user