Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 741977c3fb | |||
| 32d5da5528 | |||
| 15fc01542c | |||
| ad2c01bc29 | |||
| 040b779f5f | |||
| 32d7ada3ab | |||
| 525097a0b2 | |||
| dff84f2b27 | |||
| 7007835541 | |||
| 82f9256a84 | |||
| e0eff7755f |
@@ -1,8 +0,0 @@
|
||||
.git
|
||||
**/.git
|
||||
target
|
||||
**/target
|
||||
**/AGENTS.md
|
||||
**/work
|
||||
**/ci-output
|
||||
.env
|
||||
@@ -1,26 +0,0 @@
|
||||
# Tessera GUI automation
|
||||
|
||||
[workflows](workflows/README.md) contains repository workflows.
|
||||
|
||||
## CI source checkout
|
||||
|
||||
All workflows use `runs-on: ces-control`; no shared Runner configuration is changed.
|
||||
`actions/checkout@v4` reads source over HTTPS using Gitea's automatically supplied
|
||||
`GITEA_TOKEN`. No additional SSH key or known_hosts secrets are required, and
|
||||
`persist-credentials: false` prevents credentials from being retained for later
|
||||
build steps.
|
||||
|
||||
Consumer workflows read the exact Aster commit from the checked-out project's
|
||||
`vendor/aster` gitlink, then use another standard checkout into that directory and
|
||||
verify the resulting HEAD. They do not follow Aster main automatically. Aster is
|
||||
currently a public repository, so reading it does not require private cross-repo
|
||||
authorization. If that visibility changes, diagnose the job token's read access
|
||||
instead of assuming an SSH key must be distributed.
|
||||
|
||||
The SSH URL in `.gitmodules` is retained for developer Git operations, which
|
||||
continue to use the dedicated codex-bot identity. CI's built-in job token is
|
||||
separate from local developer credentials.
|
||||
|
||||
Workflow definitions do not prove CI passed. CI/deployment state is not queried
|
||||
automatically. Local runtime checks are not performed for Aster, Lyra or Hestia;
|
||||
Tessera permits local native builds/window runs, with automated tests in CI.
|
||||
@@ -1,41 +0,0 @@
|
||||
# Tessera GUI workflows
|
||||
|
||||
[ci.yml](ci.yml) verifies this project on main pushes and manual dispatch from main. It runs on ces-control, with a 60-minute timeout. HTTPS checkout and job token details are in [the parent README](../README.md).
|
||||
|
||||
There is no production service deployment in this workflow.
|
||||
|
||||
The container includes the Endor examples used by the live guide and tests. It
|
||||
validates the actual Aster-backed source-to-UI path, snapshot lifetime, layout and
|
||||
execution budgets, failed-update preservation and stale-result invalidation, in
|
||||
addition to the existing rendering checks and Clippy. Cargo uses official sources.
|
||||
The primary example now executes top-level let statements and ui.page().render
|
||||
through Aster's Interactive interface. Function-entry compatibility, original
|
||||
source positions, page methods and page budgets are also checked. The workflow
|
||||
checks the macOS packaging script's syntax without running it on Linux.
|
||||
Docker is used only in this remote workflow; local compilation is native.
|
||||
|
||||
The test image installs DejaVu Sans/Mono and Noto CJK for font metrics and Chinese
|
||||
fallback checks. Text regression tests inspect glyph selection, measured advances,
|
||||
fractional scale, multiline bounds, Unicode truncation and antialiased opacity.
|
||||
|
||||
## Incremental verification
|
||||
|
||||
Gitea 1.26 concurrency groups cancel superseded runs on the same ref. Markdown-only
|
||||
pushes to main skip verification; pull requests do not trigger this workflow; executable Endor examples and Rust
|
||||
files (including embedded guide strings) still trigger checks. Manual dispatch
|
||||
runs verification only when main is selected. Existing queued runs created from older workflow
|
||||
revisions retain their old behavior.
|
||||
|
||||
The workflow invokes [verify-ci.sh](../../scripts/verify-ci.sh). Docker builds the
|
||||
Rust 1.91.1/system/font toolchain from Dockerfile.ci over stdin, without sending a
|
||||
source build context. The checkout is mounted read-only at /build. Named volumes
|
||||
persist Cargo registry/git downloads and /cargo-target across container removal,
|
||||
including failed tests and cancelled runs. Main push and manual runs use the main cache scope. Changing Rust/toolchain compatibility requires updating
|
||||
the image and cache prefix in the script. Cargo lockfiles and fingerprints decide
|
||||
which dependencies/artifacts are reusable; caches do not bypass checks.
|
||||
|
||||
A host flock serializes each scope's writable target cache. Signal handling
|
||||
removes only the current run's named container; it never prunes shared Docker
|
||||
resources or deletes cache volumes. The first run populates caches; later runs
|
||||
still execute fmt, all tests and Clippy. Cargo compilation remains single-job to
|
||||
limit memory pressure on the shared host. No CPU-time guarantee is implied.
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Verify Tessera GUI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore: ["**/*.md"]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: tessera-${{ gitea.workflow }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ces-control
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
DOCKER_BUILDKIT: "0"
|
||||
steps:
|
||||
- name: Checkout project
|
||||
id: checkout_project
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: ${{ gitea.server_url }}
|
||||
persist-credentials: false
|
||||
- name: Read pinned Aster revision
|
||||
id: aster_revision
|
||||
run: |
|
||||
set -eu
|
||||
aster_sha=$(git ls-tree HEAD -- vendor/aster | awk '$1 == "160000" && $2 == "commit" && $4 == "vendor/aster" { print $3 }')
|
||||
case "$aster_sha" in *[!0-9a-f]*|'') echo "Invalid Aster gitlink" >&2; exit 1;; esac
|
||||
test "${#aster_sha}" -eq 40
|
||||
printf 'sha=%s\n' "$aster_sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout pinned Aster
|
||||
id: checkout_aster
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: ${{ gitea.server_url }}
|
||||
repository: Zayden-Jung/endorphin-aster-vm
|
||||
ref: ${{ steps.aster_revision.outputs.sha }}
|
||||
path: vendor/aster
|
||||
persist-credentials: false
|
||||
- name: Verify Aster revision
|
||||
env:
|
||||
EXPECTED_ASTER_SHA: ${{ steps.aster_revision.outputs.sha }}
|
||||
run: test "$(git -C vendor/aster rev-parse HEAD)" = "$EXPECTED_ASTER_SHA"
|
||||
|
||||
- name: Verify portable GUI code with persistent Cargo caches
|
||||
env:
|
||||
CI_CACHE_SCOPE: ${{ gitea.event_name == 'pull_request' && format('pr-{0}', gitea.event.pull_request.number) || 'main' }}
|
||||
run: sh scripts/verify-ci.sh
|
||||
+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"
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
FROM rust:1.91.1-bookworm
|
||||
WORKDIR /build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libx11-dev libxkbcommon-dev libwayland-dev libudev-dev libegl1-mesa-dev libgbm-dev fonts-dejavu-core fonts-noto-cjk \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rustup component add rustfmt clippy
|
||||
ENV CARGO_BUILD_JOBS=1 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0
|
||||
ENV CARGO_TARGET_DIR=/cargo-target
|
||||
CMD ["sh", "-eu", "-c", "cargo fmt --check && cargo test --locked && cargo clippy --all-targets --locked -- -D warnings"]
|
||||
@@ -37,8 +37,9 @@ Cargo compiles the required Aster libraries together with Tessera. No separate
|
||||
Aster build, service or deployment is required. Local builds download dependencies
|
||||
from official crates.io by default. A temporary dependency mirror may be used
|
||||
when the official source is unavailable, without changing global configuration.
|
||||
Docker and automated tests
|
||||
are not used locally. The command above produces a native executable. To create a
|
||||
Builds and validation run locally using the native Rust toolchain; no CI/CD
|
||||
workflow or Docker toolchain is configured. The command above produces a native
|
||||
executable. To create a
|
||||
Finder-launchable development app on macOS, run:
|
||||
|
||||
```bash
|
||||
@@ -127,7 +128,7 @@ is 14 logical pixels with a 20-pixel line height; size 2 is 28/40. Row/Column la
|
||||
uses measured text advances and multiline height. Device scale remains fractional,
|
||||
and glyphs are rasterized at the actual display resolution rather than enlarged.
|
||||
The application does not bundle proprietary OS fonts. Linux installations need
|
||||
fonts such as DejaVu Sans/Mono and Noto CJK; the remote test image installs these.
|
||||
fonts such as DejaVu Sans/Mono and Noto CJK installed locally.
|
||||
|
||||
## Candidate execution and publication
|
||||
|
||||
@@ -170,7 +171,7 @@ candidate and returns 500, retaining the previous document.
|
||||
|
||||
This stage supports live mounted UI for both complete programs and Console inputs, with immutable window frames on the existing worker.
|
||||
Native mouse/keyboard activation and code-driven callbacks share onClick/click. Managed Mount objects expose each occurrence, and property dependencies and measurement caches avoid recomputing clean visual subtrees. Arrangement still traverses occurrences.
|
||||
Managed tab chrome and multiple windows are available. Typed capability contracts
|
||||
Sidebar page navigation and multiple windows are available. Typed capability contracts
|
||||
are installed for host protocols; the complete language/type/effect surface and
|
||||
process isolation remain incomplete. Worker threads,
|
||||
source budgets and VM instruction limits are not a complete untrusted-code sandbox.
|
||||
@@ -234,13 +235,13 @@ The solver detects a previously visited nonconsecutive state as a cycle. It has
|
||||
|
||||
Mount.bounds() rejects reads during measurement, including old geometry on an existing Mount. The current occurrence's allocated Bounds become available while its arrange method runs. Structural contexts are prepared before layout, geometry is committed after successful layout, and onMount/onUnmount notifications then run before paint/publication. Both layout phases share one read-only VM call and instruction budget; temporary results can be modified, existing business state cannot. Read versions are captured before hooks, so a hook changing a measured dependency schedules another pass before publication. Dependencies are currently tracked per object. Source measurement results are cached by Mount identity, method, child sizes, constraints and actual object reads, including decoding an existing returned MeasureResult. Cache entries contain borrowed identities and versions, adding no GC roots. Mount-reading measurements currently bypass the cache conservatively because object-level tracking cannot distinguish layoutVersion/geometry from stable structure. Global dictionary writes or changes to another field on a read object can also cause conservative misses. Native measurements keep their shape cache, and arrangement still visits each active occurrence. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses; diagnostic statistics are rejected inside read-only rendering because allocation/count observations are not stable layout inputs.
|
||||
|
||||
`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Dragging a Tab away from its strip invokes that same Page method. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor).
|
||||
`Window: Object` represents a native window resource. `Window()` creates another window in the same Aster VM, up to 16 open windows per runtime. `ui.window()` returns the current event/render window, with the main window as the top-level Console default. `page.window()` reports its actual owner or nil. `page.open(window)` and `page.moveToWindow(window)` move the same Page, including navigation and business references, to that window. Omitting the open target keeps an existing owner; an unowned Page uses the calling view. `page.detachToWindow()` creates a new Window, moves the Page there and returns the resource. Moving a Page unmounts the old occurrence and creates destination Mounts. `window.pages()` returns a traced ObjectList in open order. See [windows.endor](examples/windows.endor).
|
||||
|
||||
`container.insert(position, block)` inserts at a zero-based position from 0 through the current child count and returns a new stable slot. Existing slots and attached Mount identities survive insertion. Invalid positions and cyclic child graphs fail before replacing the child list. `page.moveTab(position)` moves the same Page to a final zero-based position in its owning window; a code reorder does not implicitly activate it.
|
||||
|
||||
Native dragging starts after 12 logical pixels and shows a blue insertion marker. Same-window drops call the actual Page `moveTab` and `activate`; cross-window drops first call `moveToWindow`. Source overrides participate. An override that declines or redirects the window move prevents the remaining destination reorder. Page identity and Page-owned tasks survive movement, while a cross-window move replaces Mounts and cancels old Mount tasks. Invalid source/target generations, layout versions or unpublished tab orders are rejected before callbacks. Callback failure stops the domain; earlier writes do not roll back. Escape cancels a drag. Overflow drops use visible gaps; drag auto-scroll remains pending. See [tab-placement.endor](examples/tab-placement.endor).
|
||||
The version-checked tab-drop transport remains available for managed Page movement. Same-window requests call moveTab/activate and cross-window requests call moveToWindow first. Source overrides participate; generation/layout mismatches reject before callbacks. The sidebar currently supports selection/closure, with pointer dragging deferred. See [tab-placement.endor](examples/tab-placement.endor).
|
||||
|
||||
`window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. A detached tab uses the release point and saved grab offset to place its new window. The window manager may adjust placement. Global coordinates are required for cross-window drops; unsupported native placement reports a diagnostic. This host tracks its own focus order when resolving overlapping windows; external-app occlusion and mixed-DPI dragging have not been fully verified.
|
||||
`window.moveTo(x, y)` queues a native outer-window origin in signed physical desktop pixels, within +/-1000000 per axis. The window manager can adjust placement; unsupported native placement reports a diagnostic. Sidebar cross-window pointer dragging and mixed-DPI gestures remain unverified.
|
||||
|
||||
`window.close()` and a secondary native close button call the same managed method. Closing first removes Page ownership, then commits unmounts and removes that context from the runtime registry; the native resource disappears after successful publication. Retained Pages can be opened in another live Window; otherwise Aster reclaims them normally. Window resource objects may remain referenced after closing, but reject resource operations; id/pages remain queryable. A retained leaf Mount uses a weak context link and cannot keep a closed window alive through its query methods. The primary shell still defines the application's lifetime: closing its OS window exits Tessera. Full application-exit notifications remain follow-up work.
|
||||
|
||||
@@ -248,9 +249,9 @@ A secondary window can also be closed after a callback/rendering failure or Cons
|
||||
|
||||
Each window has its own Presentation, layout/Mount/focus state, caches and native viewport. A single VM change batch fans out to all views, all affected frames settle before publication, and shared business references are never copied. Frames and native inputs carry window/session/layout identities; stale requests are rejected. Existing event clients default to window 1, exposed by X-Tessera-Window. Secondary native resize feeds the same worker and updates only its window constraints. Console reset preserves all old pictures with input disabled; replacement sessions discard the old native resources before projecting new resources with the same numeric IDs.
|
||||
|
||||
Open Pages appear in the native shell tab bar. Set `page.title` to label a tab; omitted titles use their open-order number. Clicking a tab or its close control calls the actual Page's `activate()` or `close()` method in the existing VM; source overrides participate. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, and Cmd/Ctrl+W closes the active page when the page has keyboard focus. Overflow arrows expose additional pages. Closed Pages follow managed references and GC; a retained Page can be reopened. The tab projection contains opaque session-local IDs and text, with weak reverse lookup in the runtime, and adds no Page roots. Requests carry the published layout version; an old tab event is rejected. Switching creates new presentation Mounts while preserving the business objects. See [tabs.endor](examples/tabs.endor).
|
||||
Open Pages appear in the left sidebar. Set `page.title` to label a page; clicking its row or close control calls the actual Page `activate()` or `close()` method in the same VM. Ctrl+Tab / Ctrl+Shift+Tab cycle pages, Cmd/Ctrl+T creates one and Cmd/Ctrl+W closes the active page. Sidebar rows scroll when needed. Source overrides, managed references, GC and stale-event rejection remain in use. See [tabs.endor](examples/tabs.endor).
|
||||
|
||||
The macOS title strip occupies 44 logical pixels and shares its row with the native window controls. The left rail and title strip form one neutral chrome surface. Content is inset by a 56-pixel rail, an 8-pixel top gap, and 8 pixels at the right and bottom. Page constraints use the remaining height; published Mount geometry and event coordinates remain relative to the content area. Window viewport snapshots continue to report the full native inner size. The host translates drawing and native pointer coordinates at this boundary. Console reset preserves the last picture with input disabled until a new live frame is published.
|
||||
The host has one collapsible left sidebar, with page navigation and Console/Sources/Network/Settings. Cmd/Ctrl+B toggles it. Collapsed, the host draws only its expand button alongside the three real macOS window controls; content fills the native inner window with no top strip or surrounding inset. Only this small control area intercepts page input. `ui.viewport()` / `window.viewport()` and the API viewport headers report the available page area, including the width recovered when the sidebar collapses. Page drawings and hit tests share one camera transform. See [page surfaces](docs/page-surfaces.md).
|
||||
|
||||
`window.resize(width, height)`, `setTitle(text)` and `focus()` project requests to the GUI thread. `id()` returns an opaque runtime resource ID and `viewport()` reports actual logical dimensions. Requests are asynchronous and coalesced by kind; queried dimensions change when the native window reports them, and platform bounds can adjust requested sizes. This host accepts sizes from 1 to 4096 logical pixels per axis, with a native minimum of 720 by 480. Titles are UTF-8 text up to 4096 bytes with no control characters. See [window.endor](examples/window.endor).
|
||||
|
||||
@@ -258,7 +259,9 @@ Native creation and commands remain local until the input/candidate succeeds. Fa
|
||||
|
||||
The native window publishes its current logical content size to the existing VM worker on startup, resize and scale changes. The worker coalesces the latest size, updates the managed viewport input, and refreshes the same runtime without adding Console source/history entries. ui.viewport() returns a fresh managed width/height snapshot; reads during rendering depend on the private input object. User changes to a returned snapshot do not resize the window. Unchanged sizes cause no frame; a fresh or unopened Console runtime updates its inputs without replacing a preserved displayed page. Failed resize layouts stop the affected runtime and preserve the displayed frame.
|
||||
|
||||
The default Column/Button policy propagates available cross-axis width minus padding and leaves height unconstrained; Row propagates available height and leaves width unconstrained. Page uses both viewport bounds. Native and source desired sizes are clamped to their supplied constraints, while source arrange can still allocate explicit child bounds. The window clips overflow and pointer targeting to its extent; TextBlock wraps to its available width; source layout now settles constraints over repeated measurement/arrangement passes as described below. See [layout-cache.endor](examples/layout-cache.endor). GET code also exposes X-Tessera-Viewport-Width/Height headers without evaluating source.
|
||||
Row, Column and Overlay can nest with main/cross alignment, per-child alignment, margin, asymmetric border/padding, fixed/auto/fill sizes and min/max constraints. Page owns the root; scrolling is a finite ScrollView around ordinary layout. Scene2D owns world positions and CanvasViewport provides a finite UI box with an explicit Camera2D. Multiple views can share a scene while retaining independent cameras; assigning the same camera explicitly shares navigation. The old `Page.mode` API remains for compatibility. See [scene architecture](docs/scene-architecture.md), [box layout](examples/box-layout.endor) and [scene viewports](examples/scene-viewports.endor).
|
||||
|
||||
Scene3D, Group3D, mesh geometry, lights and programmable perspective/orthographic cameras render through finite Viewport3D boxes. OrbitController handles rotation, pan and zoom and can be replaced by an Endor controller. Ray picking and world-space Panel3D UI use the same transforms and depth visibility. A panel can contain ordinary UI, a 2D canvas or another 3D viewport. Rendering currently uses the existing CPU compositor with bounded depth/texture surfaces; this is not a GPU renderer. See [3D scenes](examples/scene3d.endor) and [world UI](examples/world-ui.endor).
|
||||
|
||||
Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor).
|
||||
|
||||
@@ -274,25 +277,11 @@ A source subclass can override `paint(self, mount, geometry): DisplayList`. The
|
||||
|
||||
Renderer-dispatched paint runs in a read-only Aster call: existing heap objects cannot be modified; fresh result graphs may be allocated and filled. Existing globals, model fields, containers, Mounts and types remain protected. invalidate is rejected in this phase. All overrides share one VM budget. Read dependencies are currently recorded per object, with versions used to detect changes without depending on bounded change-notification delivery. Native frames keep their existing dependency/cache behavior. Derived allocations do not produce application change notifications, preventing temporary results from causing redraw feedback. This protects managed heap writes; trusted Rust host implementations must also respect read-only calls for external state and I/O. Paint errors stop the affected runtime before publication; failed PUT candidates preserve the previously running heap and frame.
|
||||
|
||||
The application starts with one managed New tab. The + button and Cmd/Ctrl+T
|
||||
create additional managed Pages; Cmd/Ctrl+W closes the active Page. New tabs expand
|
||||
from left to right while existing tabs interpolate to their new bounds when space
|
||||
becomes limited. Hover and selection use the same capsule geometry, and animated
|
||||
pointer hits use the rendered bounds. Source and page identity remain in the VM;
|
||||
chrome animation retains only projected scalar IDs and rectangles. Tabs are capped at
|
||||
208 logical pixels wide, with a 40-pixel strip and equal 4-pixel vertical insets.
|
||||
The leading page symbol and close control have matching slots and edge spacing.
|
||||
|
||||
The default page is implemented in `src/bridge/ui_bridge/start_page.endor`, compiled once
|
||||
per VM into a private namespace. Its buttons create a Page, open a working shared
|
||||
counter, or show composed Blocks. `ui.newTab()` constructs the same ordinary Page
|
||||
used by native + and the keyboard shortcut. Layout, callbacks, state and bindings
|
||||
are Endor objects; the host does not paint an introduction over the guest page.
|
||||
The application starts with an Endor Page containing a Scene2D and CanvasViewport. Its four actions demonstrate a window-sized layout, vertically scrolling Column, horizontally scrolling Row and independent canvas. The default page is implemented in [start_page.endor](src/bridge/ui_bridge/start_page.endor), compiled once per VM in a private namespace. `ui.newTab()` and the sidebar New page action use the same factory. Cards, copy, layout, actions and the shared-state example are Endor objects. The host owns the sidebar and native window controls.
|
||||
|
||||
Console, Sources, Network and Settings are independent managed Pages with separate
|
||||
view modules and window-local input/focus/scroll/selection state. They are not
|
||||
opened by default and have no permanent rail/menu launchers. Their future
|
||||
discovery UI is intentionally deferred. Existing integrations can call
|
||||
opened by default; the left sidebar provides their launchers. Existing integrations can call
|
||||
`ui.developerTools("Console")`, `"Sources"`, `"Network"` or `"Settings"`.
|
||||
F12, Cmd-Option-I and Cmd-Shift-I on macOS, or Ctrl-Shift-I elsewhere,
|
||||
open/focus Console. Page closure, placement and GC use the ordinary Page methods.
|
||||
@@ -410,17 +399,24 @@ UTF-8 even when launched from Finder, and decoding errors are surfaced.
|
||||
|
||||
## Validation
|
||||
|
||||
Local native builds and window runs are allowed; do not run local automated tests
|
||||
or Docker. [Remote CI](.gitea/README.md) on ces-control runs the existing renderer
|
||||
checks and the new Aster script/module execution, page rendering, UI layout, source-range, lifetime, budget,
|
||||
failed-update and deletion-invalidation regressions, plus Clippy.
|
||||
The API regressions use actual HTTP requests, including DELETE while the worker
|
||||
is occupied and a queued PUT is superseded.
|
||||
Linux CI does not verify macOS window presentation. Aster has no standalone CI/CD; Lyra CI
|
||||
explicitly validates the pinned engine itself. CI uses official Cargo sources
|
||||
and standard HTTPS checkout without custom SSH secrets. Font checks cover
|
||||
proportional/monospace advances, fractional scaling, multiline height, Chinese
|
||||
fallback, grapheme-safe truncation, antialiasing and alpha blending.
|
||||
Run checks from the repository root after initializing the pinned Aster submodule:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --locked
|
||||
cargo clippy --all-targets --locked -- -D warnings
|
||||
sh -n scripts/build-macos-app.sh
|
||||
```
|
||||
|
||||
The existing tests cover rendering, Aster execution, page layout, source ranges,
|
||||
lifetime, budgets, failed-update preservation and deletion invalidation. API tests
|
||||
use loopback HTTP, so allow local socket access. Font checks use installed system
|
||||
fonts. Automated checks do not replace native window verification: launch Tessera
|
||||
and check the start page, shared-object example, tab creation/closure and developer
|
||||
tools. Build the macOS bundle separately with `sh scripts/build-macos-app.sh`.
|
||||
|
||||
There is no repository CI/CD workflow. Commits and pull requests do not trigger
|
||||
builds or deployment; record the actual local check results with each delivery.
|
||||
|
||||
|
||||
### Source function types
|
||||
@@ -599,7 +595,7 @@ Inside a function run by ui.spawn, `value = await task` waits for any timer/file
|
||||
A waiting coroutine retains its evaluated operand and frames as managed edges; waiting performs no VM execution or active polling. Source side effects and IO initiation before await are not replayed. When the dependency completes, the owning VM worker resumes the waiter through the existing fair quantum scheduler. Cancelling/replacing/closing the waiter releases its frames and operand; it does not automatically cancel an independently owned or shared dependency. Dependency cancellation propagates cancellation to its waiters without running the remainder. An unhandled dependency failure becomes a source await error (with the original provider message), fails the waiter and follows the existing shared-runtime fault rule; provider failure alone still only fails its own Task. Self-await and cyclic Task waits fail explicitly. Read-only layout/paint cannot await. Waiting state is reported as running, while active() remains true; result() is unavailable until completion. These APIs do not introduce try/finally or change the existing exception model.
|
||||
|
||||
|
||||
While a native tab drag is held over an overflow arrow area, the strip waits 350ms then reveals one hidden tab every 120ms, including in a compatible destination window. The preview scroll does not activate Pages or change business state. Drawing, hit testing and insertion gaps share the same transient visible range; release uses its full-order insertion index. Leaving the strip stops the timer; release, Escape, a new press or runtime generation change restores the normal active-tab-centered strip. A resize or changing tab count clamps the range. Native drag/mixed-DPI behavior still requires platform validation; application-exit cleanup is described below.
|
||||
The former horizontal tab-strip gestures and animation have been replaced by sidebar page navigation. Page.moveTab, moveToWindow and detachToWindow remain available through Endor/API; sidebar pointer drag/reorder and cross-window drag are not implemented in this revision.
|
||||
|
||||
|
||||
Application exit now uses a private VM-worker control channel, independent of HTTP queue capacity. Main native close and a managed primary Window.close request asynchronous cleanup; duplicate requests coalesce and mutation requests are rejected with 503 while exiting. An unpublished candidate/input refresh is cancelled without replay. The current VM cancels all Task entries, closes managed windows, detaches occurrences child-before-parent per window, and invokes normal onUnmount hooks once. It does not run layout/paint during shutdown. All windows are closed before hooks run; new windows and tasks are forbidden. A stopped/faulted VM detaches without source hooks; reset/discarded pictures cannot revive an old VM. A hook error aborts remaining source hooks, releases prepared hook roots, drops the VM and reports failure before native exit. Completed shutdown is acknowledged to the GUI, which then ends the event loop. An OS exit bypassing CloseRequested uses a five-second bounded final fallback; non-cooperative native calls remain atomic and force-kill/crash cannot guarantee notifications. Native stderr records the window/mount/task-entry counts and whether source hooks were enabled, or the cleanup error. See examples/exit-lifecycle.endor. Cross-platform exit/drag validation remains broader follow-up work.
|
||||
|
||||
@@ -7,7 +7,7 @@ Tessera/
|
||||
├── Cargo.toml / Cargo.lock Rust 依赖和固定解析结果
|
||||
├── src/
|
||||
│ ├── main.rs 组合入口、事件循环和共享发布状态
|
||||
│ ├── shell/ 原生窗口、标签、开发工具、管理控件
|
||||
│ ├── shell/ 原生窗口、可收起侧栏、开发工具、管理控件
|
||||
│ ├── runtime/ 本机 API、VM worker、执行、事件与异步任务
|
||||
│ ├── model/ 托管对象、挂载、绑定、依赖和发布数据
|
||||
│ ├── render/ 布局、绘制、命中、字体与软件光栅化
|
||||
@@ -16,7 +16,7 @@ Tessera/
|
||||
├── docs/
|
||||
│ ├── architecture/ 本套架构说明和 UML 源文件
|
||||
│ └── media/ 已有建模视频与字幕
|
||||
├── scripts/ 原生打包及已有远程检查入口
|
||||
├── scripts/ 本地原生打包及 UML 文档渲染
|
||||
└── vendor/aster/ 独立 Git 子模块:语言、编译器、VM
|
||||
```
|
||||
|
||||
@@ -36,7 +36,7 @@ Tessera/
|
||||
|
||||
| 执行位置 | 核心所有物 | 交互方式 |
|
||||
| --- | --- | --- |
|
||||
| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、标签动画、工具交互 | 读已发布快照;发 viewport、输入和窗口命令 |
|
||||
| GUI / winit 主线程 | TesseraApp、原生 Window、Surface、侧栏、页面视角交互 | 读已发布快照;发 viewport、输入和窗口命令 |
|
||||
| `tessera-agent-api` | tiny_http 接收循环、请求观察 | 对写入做校验并进入最多 8 条的构建队列;读取当前发布状态 |
|
||||
| `tessera-endor-worker` | 活跃 ReplSession、候选 VM、托管对象图 | 串行执行程序、回调、布局/绘制协议及任务完成交付 |
|
||||
| 输入发送线程 | 有界输入客户端 | 把原生输入转为本机 HTTP 请求;不执行 VM |
|
||||
|
||||
@@ -26,7 +26,17 @@
|
||||
4. [建模视频](../media/endor-campus-modeling.mp4) 与 [中文字幕](../media/endor-campus-modeling.zh.srt):已有实际窗口操作记录,静音、采样帧率有说明。
|
||||
5. `GET /agent`:运行中的实时 API 指南,优先于长期静态样例推断接口。
|
||||
|
||||
## 本轮验证方式
|
||||
## 当前本地开发方式(2026-11-10)
|
||||
|
||||
页面/宿主设计更新:支持 window、vertical、horizontal、canvas 四种空间;顶部标签条由可收起左侧栏替代。实现、测试与原生验收边界见 [页面空间说明](../page-surfaces.md)。下文 150 项测试及原窗口结构属于前一次移除 CI 的交付记录。
|
||||
|
||||
项目已移除 CI/CD 工作流、Docker 工具链定义及远程验证脚本。开发、构建、现有测试与原生窗口验收改在本机执行,命令见 [项目验证说明](../../README.md#validation)。以下保留 2026-10-25 整理时的验收记录,不代表本次检查结果。
|
||||
|
||||
本次使用 macOS / Rust 1.91.1:格式检查、150 项现有测试、all-target Clippy(拒绝 warnings)、打包脚本语法检查及 release app 构建均通过。应用已启动,`GET /agent` 与 `GET /code` 返回正常,窗口报告 1280 × 800 的逻辑 viewport;通过实际 REPL 创建/关闭临时标签页,页面数为 1 → 2 → 1。首次页面查询误用了不存在的 `ui.pages()`,返回 422;改为 `ui.window().pages().count` 后通过。原生 UI 检查工具启动失败,本次没有窗口外观、真实鼠标交互或跨屏验收证据。Aster 保持固定版本,未修改源码或 gitlink。
|
||||
|
||||
下一步优先补齐真实窗口的共享对象、标签拖放和退出清理验收,然后定义本地 `.endor` 文件的打开、保存及重启恢复方式。当前 Sources 的云文件 provider 默认断开,源码/设置尚不持久化。此外,在线 API 指南仍有旧的标签高度、overflow 箭头和增量布局状态说明,需要与现有实现及测试统一。
|
||||
|
||||
## 2026-10-25 整理验证方式
|
||||
|
||||
本轮是整理,不扩展业务功能。沿用工作约定:格式、`cargo clippy --all-targets --offline -- -D warnings`、原生 release 打包、真实窗口和少量人工 API 操作;不执行 cargo test、自动 smoke 脚本或 Docker。Clippy 会编译检查已有测试代码,但不代表测试已经执行。
|
||||
|
||||
|
||||
@@ -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,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,空闲连续采样的版本保持一致。保留应用窗口供用户直接操作。
|
||||
+8
-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. |
|
||||
@@ -26,8 +30,10 @@
|
||||
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,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)
|
||||
+6
-9
@@ -9,19 +9,16 @@ when official crates.io is unavailable. Defaults remain the official source.
|
||||
|
||||
The local bundle has identifier `com.entroia.tessera` and can be opened from Finder.
|
||||
It is a development app, without a distribution signature or notarization.
|
||||
The CI workflow only checks this script's shell syntax on Linux; native bundle
|
||||
construction and window presentation are verified on macOS.
|
||||
Check shell syntax with `sh -n scripts/build-macos-app.sh`; native bundle
|
||||
construction and window presentation are verified locally on macOS.
|
||||
|
||||
Return to the [project guide](../README.md).
|
||||
|
||||
## Remote verification
|
||||
## Local verification
|
||||
|
||||
[verify-ci.sh](verify-ci.sh) runs only on the Linux ces-control host. It builds the
|
||||
source-independent [toolchain image](../Dockerfile.ci), mounts the checkout
|
||||
read-only and retains Cargo downloads/build output in project-specific Docker
|
||||
volumes. CI_CACHE_SCOPE selects main/manual or a PR cache namespace. It acquires
|
||||
a host lock and removes its own container on interruption; cached volumes survive.
|
||||
See [workflow details](../.gitea/workflows/README.md) for invalidation and triggers.
|
||||
Build and validate with the native Rust toolchain. The project has no CI/CD
|
||||
workflow or Docker build image. See the [validation commands](../README.md#validation)
|
||||
for formatting, tests and Clippy; window behavior is checked in the running app.
|
||||
|
||||
|
||||
## UML 文档渲染
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Run only on the Linux CI host with Docker; never bake source into cache images.
|
||||
set -eu
|
||||
scope=${CI_CACHE_SCOPE:-manual}
|
||||
case "$scope" in *[!a-zA-Z0-9_-]*|'') echo "Invalid cache scope" >&2; exit 1;; esac
|
||||
root=$(pwd -P)
|
||||
image=endorphin-tessera-gui-ci:rust-1.91.1
|
||||
prefix=tessera-ci-rust-1-91-1-$scope
|
||||
# Serialize access to each target directory, including manually started checks.
|
||||
exec 9>"/tmp/$prefix.lock"
|
||||
flock 9
|
||||
sh -n scripts/build-macos-app.sh
|
||||
# Empty context: only the toolchain and OS dependencies become image layers.
|
||||
docker build -t "$image" - < Dockerfile.ci
|
||||
name="$prefix-${GITHUB_RUN_ID:-$$}"
|
||||
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
docker run --name "$name" --rm --init \
|
||||
--mount "type=bind,src=$root,dst=/build,readonly" \
|
||||
--mount "type=volume,src=$prefix-target,dst=/cargo-target" \
|
||||
--mount "type=volume,src=$prefix-registry,dst=/usr/local/cargo/registry" \
|
||||
--mount "type=volume,src=$prefix-git,dst=/usr/local/cargo/git" \
|
||||
"$image" &
|
||||
run_pid=$!
|
||||
wait "$run_pid"
|
||||
+6
-3
@@ -23,7 +23,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 +61,8 @@
|
||||
| [geometry](render/geometry.rs) | 点和矩形值 |
|
||||
| [layout](render/layout.rs) | 原生测量、排列、出现几何及预算 |
|
||||
| [native_visual](render/native_visual.rs) | 托管原生视觉协议、值转换和签名检查 |
|
||||
| [scene](render/scene.rs) | 页面、标签、插入标记和开发工具画面合成 |
|
||||
| [scene](render/scene.rs) | 页面视角、可见裁剪、侧栏和开发工具画面合成 |
|
||||
| [page](render/page.rs) | 四种页面空间、约束、平移缩放及正逆坐标变换 |
|
||||
| [scroll](render/scroll.rs) | 逐 Mount 偏移、嵌套滚动和剪裁投影 |
|
||||
| [software_renderer](render/software_renderer.rs) | 软件光栅化、抗锯齿覆盖和嵌套剪裁 |
|
||||
| [source_hit](render/source_hit.rs) | 源/原生命中协议与匹配目标 |
|
||||
@@ -79,8 +81,9 @@
|
||||
- HTTP 路由:`runtime/api.rs`;worker:`runtime/api/worker.rs`;在线指南:`runtime/api/guide.rs`。
|
||||
- Bridge 安装:`bridge/ui_bridge/bootstrap.rs`;Block 调用:`calls.rs`;签名:`signatures.rs`;不可变树:`tree.rs`。
|
||||
- 默认页:`bridge/ui_bridge/start_page.endor`;Console/Sources/Network/Settings:`shell/developer_tools/`。
|
||||
- 页面模式与宿主边界见 [页面空间说明](../docs/page-surfaces.md);相机输入:`bridge/ui_bridge/page_input.rs`。
|
||||
- 现有 API 和宿主测试:`runtime/api/tests.rs`、`runtime/endor_host/tests.rs`;其他模块中也保留原单元测试。
|
||||
|
||||
VM 和托管根只由执行 worker 操作。原生窗口收到已发布值,不收到 ObjectRef。完整 PUT 成功才接替活跃程序;Console 保留实际 VM;失败和清理语义见架构文档。
|
||||
|
||||
本机遵循既有检查约定:格式、离线 Clippy、原生 release 构建和人工运行检查;不执行 cargo test、自动 smoke 脚本或 Docker。已有远程检查脚本保留原位置,此次不运行或追逐 CI。
|
||||
当前开发与验证在本机使用原生 Rust 工具链完成:格式、现有测试、Clippy、原生构建和人工运行检查。CI/CD 工作流与 Docker 检查入口已移除;具体命令见 [项目验证说明](../README.md#validation)。历史验收记录只代表当时实际执行的检查。
|
||||
|
||||
+133
-20
@@ -2,6 +2,7 @@
|
||||
mod bootstrap;
|
||||
mod calls;
|
||||
mod developer_pages;
|
||||
mod page_input;
|
||||
mod signatures;
|
||||
mod tree;
|
||||
|
||||
@@ -37,11 +38,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",
|
||||
@@ -142,6 +163,15 @@ enum Operation {
|
||||
Scroll,
|
||||
Row,
|
||||
Column,
|
||||
Overlay,
|
||||
Scene2D,
|
||||
CanvasViewport,
|
||||
Camera2D,
|
||||
SceneObject(crate::scene3d_model::Constructor),
|
||||
Viewport3D,
|
||||
CameraControl,
|
||||
Translate3D,
|
||||
Rotate3D,
|
||||
Button,
|
||||
Page,
|
||||
SetText,
|
||||
@@ -181,11 +211,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
|
||||
@@ -411,6 +448,31 @@ 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)
|
||||
}
|
||||
|
||||
fn new_typed_block(
|
||||
cx: &mut NativeContext<'_>,
|
||||
data: Data,
|
||||
@@ -424,6 +486,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",
|
||||
};
|
||||
@@ -658,6 +724,7 @@ pub enum LayoutProgress {
|
||||
}
|
||||
|
||||
struct LayoutContext {
|
||||
surface: crate::page::Surface,
|
||||
root: Arc<UiNode>,
|
||||
tabs: Vec<crate::tabs::Tab>,
|
||||
full_viewport: Option<crate::layout::Size>,
|
||||
@@ -1189,13 +1256,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 +1290,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 +1305,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 +1315,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 +1341,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 +1358,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");
|
||||
}
|
||||
@@ -1477,6 +1577,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.
|
||||
@@ -1649,10 +1750,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 +1787,7 @@ impl UiBridge {
|
||||
(hooks, state.mounts.ordered(), checkpoint)
|
||||
};
|
||||
let context = LayoutContext {
|
||||
surface,
|
||||
root: root.clone(),
|
||||
tabs,
|
||||
full_viewport,
|
||||
@@ -1736,6 +1836,7 @@ impl UiBridge {
|
||||
|
||||
fn discard_layout_context(&self, vm: &mut Vm, context: LayoutContext) -> Result<()> {
|
||||
let LayoutContext {
|
||||
surface: _,
|
||||
desktop,
|
||||
window,
|
||||
hooks,
|
||||
@@ -1824,6 +1925,7 @@ impl UiBridge {
|
||||
draft: crate::source_layout::Draft,
|
||||
) -> Result<crate::document::Frame> {
|
||||
let LayoutContext {
|
||||
mut surface,
|
||||
root,
|
||||
tabs,
|
||||
full_viewport,
|
||||
@@ -1846,11 +1948,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
|
||||
|
||||
@@ -43,6 +43,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 +88,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 +153,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 +202,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 +263,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 +375,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),
|
||||
|
||||
@@ -16,6 +16,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 +382,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 +398,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,
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Camera mutations run on the owning VM worker, just like other page input.
|
||||
use super::*;
|
||||
|
||||
impl UiBridge {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,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")?,
|
||||
@@ -133,11 +139,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 +158,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,39 +1,66 @@
|
||||
// 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.
|
||||
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 {
|
||||
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 {
|
||||
home: Page
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -41,40 +68,50 @@ class StartActions {
|
||||
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)
|
||||
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)
|
||||
view = CanvasViewport(1024, 800, world)
|
||||
view.flex = 1
|
||||
page.crossAlign = "stretch"
|
||||
page.render(view)
|
||||
return page
|
||||
}
|
||||
|
||||
@@ -3,9 +3,18 @@ use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiNode {
|
||||
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,
|
||||
pub block_id: String,
|
||||
pub protocol_key: String,
|
||||
pub slots: Vec<u64>,
|
||||
@@ -17,6 +26,11 @@ 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,
|
||||
@@ -25,13 +39,33 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
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 +82,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,
|
||||
@@ -124,6 +199,32 @@ 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,
|
||||
|
||||
+6
-3
@@ -7,19 +7,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};
|
||||
|
||||
|
||||
+331
-1
@@ -32,6 +32,16 @@ pub enum Kind {
|
||||
},
|
||||
Row,
|
||||
Column,
|
||||
Overlay,
|
||||
Scene2D,
|
||||
Viewport3D {
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
CanvasViewport {
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
Button,
|
||||
Page,
|
||||
}
|
||||
@@ -120,7 +130,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,
|
||||
@@ -149,7 +172,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,
|
||||
@@ -163,7 +199,14 @@ impl Data {
|
||||
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 { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -179,6 +222,15 @@ impl ObjectPayload for Data {
|
||||
"tessera-block-data"
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -320,6 +372,8 @@ pub fn native_properties(data: &Data) -> Vec<(&'static str, 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 +384,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,6 +480,7 @@ 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
|
||||
@@ -378,6 +498,49 @@ 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)
|
||||
}
|
||||
|
||||
@@ -393,7 +556,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 +730,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 +753,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 +798,7 @@ 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")
|
||||
}
|
||||
|
||||
+153
-10
@@ -113,6 +113,18 @@ 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 +156,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 +185,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 +195,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)
|
||||
@@ -217,7 +245,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 +263,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 +272,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(),
|
||||
@@ -322,6 +369,10 @@ 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 +382,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,
|
||||
@@ -347,10 +402,14 @@ 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,
|
||||
@@ -364,6 +423,18 @@ impl Default for Frame {
|
||||
/// A position in a published frame. Identifiers are opaque data, never VM handles.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
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 +452,35 @@ pub struct MountGeometry {
|
||||
}
|
||||
|
||||
impl MountGeometry {
|
||||
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))
|
||||
}
|
||||
pub fn logical_point(&self, point: Point) -> Option<Point> {
|
||||
let point = self.view_point(point, true)?;
|
||||
Some(self.view.unwrap_or_default().inverse(point))
|
||||
}
|
||||
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,13 +488,29 @@ 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))
|
||||
}
|
||||
}
|
||||
@@ -409,9 +523,16 @@ 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")]
|
||||
pub enum RenderCommand {
|
||||
Scene3D {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
view: Arc<crate::scene3d::View>,
|
||||
},
|
||||
RoundedRect {
|
||||
x: f32,
|
||||
y: f32,
|
||||
@@ -447,6 +568,18 @@ 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,
|
||||
@@ -507,6 +640,16 @@ impl RenderCommand {
|
||||
|
||||
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,
|
||||
|
||||
@@ -6,5 +6,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;
|
||||
|
||||
+98
-4
@@ -55,6 +55,10 @@ enum Query {
|
||||
ScrollBy,
|
||||
ScrollPosition,
|
||||
ScrollIntoView,
|
||||
FitView,
|
||||
Pick3D,
|
||||
ToView,
|
||||
ToLocal,
|
||||
Parent,
|
||||
FirstChild,
|
||||
NextSibling,
|
||||
@@ -79,7 +83,12 @@ impl NativeCallable for Method {
|
||||
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 {
|
||||
@@ -99,6 +108,10 @@ impl NativeCallable for Method {
|
||||
| Query::ScrollTo
|
||||
| Query::ScrollBy
|
||||
| Query::ScrollIntoView
|
||||
| Query::FitView
|
||||
| Query::Pick3D
|
||||
| Query::ToView
|
||||
| Query::ToLocal
|
||||
| Query::ScrollPosition
|
||||
)
|
||||
{
|
||||
@@ -106,7 +119,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 +134,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 +195,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
|
||||
@@ -258,18 +338,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![]
|
||||
@@ -405,6 +495,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),
|
||||
|
||||
@@ -16,6 +16,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,
|
||||
@@ -122,6 +123,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(),
|
||||
@@ -177,9 +179,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(),
|
||||
|
||||
@@ -0,0 +1,884 @@
|
||||
//! 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)]
|
||||
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",
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)]
|
||||
pub enum NodeKind {
|
||||
Vector,
|
||||
Triangle,
|
||||
Mesh,
|
||||
Group,
|
||||
Scene,
|
||||
Light,
|
||||
Perspective,
|
||||
Orthographic,
|
||||
Orbit,
|
||||
Panel,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Node(pub NodeKind);
|
||||
impl ObjectPayload for Node {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"scene-3d-object"
|
||||
}
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct Geometry(pub Arc<scene3d::Geometry>);
|
||||
impl ObjectPayload for Geometry {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"geometry-3d"
|
||||
}
|
||||
fn reference_assignment_only(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
fn object_ref(value: &Value) -> Result<ObjectRef, VmError> {
|
||||
if let Value::Object(object) = value {
|
||||
Ok(*object)
|
||||
} else {
|
||||
Err(invalid("3D field requires an object"))
|
||||
}
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
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.,
|
||||
])
|
||||
}
|
||||
|
||||
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<'_> {
|
||||
fn value(&mut self, object: ObjectRef, name: &str) -> Result<Value, VmError> {
|
||||
self.reads.insert((object, name.into()));
|
||||
self.store.get_attr(object, name)
|
||||
}
|
||||
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])?,
|
||||
))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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)]
|
||||
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(())
|
||||
}
|
||||
|
||||
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 {
|
||||
fn arity(&self) -> Option<endor_core::CallArity> {
|
||||
Some(endor_core::CallArity::exact(0))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
+108
-2
@@ -24,6 +24,7 @@ struct Dependency {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct RenderCache {
|
||||
scenes3d: HashMap<ObjectRef, Arc<crate::scene3d::Scene>>,
|
||||
entries: HashMap<ObjectRef, Cached>,
|
||||
dependents: HashMap<Dependency, HashSet<ObjectRef>>,
|
||||
parents: HashMap<ObjectRef, HashSet<ObjectRef>>,
|
||||
@@ -90,6 +91,8 @@ impl RenderCache {
|
||||
}
|
||||
|
||||
pub fn prune(&mut self, store: &ObjectStore) {
|
||||
self.scenes3d
|
||||
.retain(|object, _| store.object(*object).is_ok());
|
||||
let dead: Vec<_> = self
|
||||
.entries
|
||||
.keys()
|
||||
@@ -128,7 +131,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 +157,47 @@ 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
|
||||
};
|
||||
if !finish {
|
||||
if !path.insert(data_ref) {
|
||||
return Err(VmError::InvalidArgument("cyclic Block composition"));
|
||||
@@ -259,11 +303,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 +369,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,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! 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)]
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
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],
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ impl Color {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DrawCommand {
|
||||
Scene3D {
|
||||
rect: Rect,
|
||||
view: std::sync::Arc<crate::scene3d::View>,
|
||||
},
|
||||
Clear(Color),
|
||||
PushClip(Rect),
|
||||
PopClip,
|
||||
@@ -59,12 +63,72 @@ 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);
|
||||
}
|
||||
@@ -73,6 +137,7 @@ impl DisplayList {
|
||||
for command in &mut self.commands {
|
||||
match command {
|
||||
DrawCommand::WrappedText { rect, .. }
|
||||
| DrawCommand::Scene3D { rect, .. }
|
||||
| DrawCommand::PushClip(rect)
|
||||
| DrawCommand::FillRoundedRect { rect, .. }
|
||||
| DrawCommand::StrokeRoundedRect { rect, .. } => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#[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,
|
||||
|
||||
+514
-49
@@ -10,6 +10,72 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
const MAX_LAYOUT_VISITS: usize = 10_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
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)]
|
||||
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]
|
||||
@@ -59,7 +125,7 @@ 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,
|
||||
@@ -67,9 +133,11 @@ pub struct Size {
|
||||
|
||||
#[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)]
|
||||
@@ -90,6 +158,7 @@ pub struct LayoutRuntime {
|
||||
pub settle_passes: usize,
|
||||
pub slices: usize,
|
||||
pub viewport: Option<Size>,
|
||||
pub page_mode: Option<crate::page::Mode>,
|
||||
}
|
||||
|
||||
struct Item<'a> {
|
||||
@@ -107,6 +176,13 @@ 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 +190,31 @@ 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,33 +227,95 @@ 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.
|
||||
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)
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -167,12 +329,30 @@ 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.
|
||||
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,
|
||||
}
|
||||
@@ -184,6 +364,10 @@ pub fn main_axis(node: &UiNode) -> Option<usize> {
|
||||
|
||||
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 +386,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,
|
||||
@@ -228,15 +420,64 @@ fn measure_shape(shape: &Shape) -> Result<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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -256,14 +497,25 @@ pub struct Context {
|
||||
|
||||
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 +537,126 @@ 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.
|
||||
fn main_fill(child: &UiNode, row: bool) -> bool {
|
||||
(if row {
|
||||
child.sizing.width
|
||||
} else {
|
||||
child.sizing.height
|
||||
}) == crate::box_model::Dimension::Fill
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
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 +664,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 +678,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 +689,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 +702,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 +742,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;
|
||||
@@ -399,16 +759,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,26 +841,58 @@ 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);
|
||||
}
|
||||
@@ -495,7 +949,7 @@ impl LayoutRuntime {
|
||||
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 +987,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 +1024,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 +1051,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;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// 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;
|
||||
|
||||
@@ -202,8 +202,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 +214,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 +275,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,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 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")]
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
+38
-52
@@ -1,8 +1,9 @@
|
||||
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)]
|
||||
@@ -17,7 +18,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 +28,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 {
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
//! 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)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
pub struct View {
|
||||
pub scene: Arc<Scene>,
|
||||
pub camera: Camera,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct PaintView(pub Arc<View>);
|
||||
impl endor_core::ObjectPayload for PaintView {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
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) }; }
|
||||
struct Guard;
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
DEPTH.with(|depth| depth.set(depth.get() - 1));
|
||||
}
|
||||
}
|
||||
if !DEPTH.with(|depth| {
|
||||
if depth.get() >= 8 {
|
||||
false
|
||||
} else {
|
||||
depth.set(depth.get() + 1);
|
||||
true
|
||||
}
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
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
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
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)]
|
||||
struct Vertex {
|
||||
position: Vec3,
|
||||
uv: [f32; 2],
|
||||
}
|
||||
impl Vertex {
|
||||
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,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
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)],
|
||||
]
|
||||
}
|
||||
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]));
|
||||
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) {
|
||||
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,254 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::{Add, Mul, Sub};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
fn mul(self, b: f32) -> Self {
|
||||
Self::new(self.x * b, self.y * b, self.z * b)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
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,255 @@
|
||||
//! 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)]
|
||||
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.
|
||||
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,
|
||||
});
|
||||
}
|
||||
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"
|
||||
);
|
||||
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(())
|
||||
}
|
||||
|
||||
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,253 @@
|
||||
//! 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)]
|
||||
pub struct Bounds {
|
||||
pub min: Vec3,
|
||||
pub max: Vec3,
|
||||
}
|
||||
impl Bounds {
|
||||
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),
|
||||
),
|
||||
},
|
||||
))
|
||||
}
|
||||
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)]
|
||||
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
|
||||
}
|
||||
}
|
||||
+47
-12
@@ -76,17 +76,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 +107,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 +136,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 +170,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 +192,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 {
|
||||
@@ -246,6 +273,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 {
|
||||
|
||||
@@ -6,8 +6,50 @@ use crate::{
|
||||
|
||||
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) };
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
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 +60,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,6 +141,7 @@ impl SoftwareRenderer {
|
||||
} => canvas.draw_text(*origin, value, *style, *color),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +197,13 @@ 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)
|
||||
});
|
||||
}
|
||||
@@ -141,9 +223,20 @@ 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;
|
||||
}
|
||||
|
||||
@@ -245,7 +338,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 +363,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 +379,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 +392,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +79,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;
|
||||
}
|
||||
|
||||
+28
-14
@@ -165,7 +165,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 +215,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)
|
||||
};
|
||||
@@ -458,8 +461,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)?,
|
||||
];
|
||||
@@ -531,8 +534,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 +564,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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -844,7 +854,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 +886,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();
|
||||
|
||||
@@ -68,9 +68,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")?;
|
||||
@@ -399,7 +416,8 @@ fn finish(vm: &mut Vm, mut work: Work, reads: Vec<endor_core::ReadDependency>) -
|
||||
.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 +435,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 +445,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 +453,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)
|
||||
|
||||
+108
-49
@@ -198,71 +198,104 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +349,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,373 @@
|
||||
//! 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)]
|
||||
pub struct Camera2D;
|
||||
impl ObjectPayload for Camera2D {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"camera-2d"
|
||||
}
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn trace(&self, _: &mut dyn FnMut(ObjectRef)) {}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CameraView {
|
||||
pub identity: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub zoom: f32,
|
||||
}
|
||||
|
||||
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.
|
||||
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),
|
||||
]
|
||||
}
|
||||
|
||||
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,128 @@
|
||||
//! 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.)));
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,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 +99,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 +110,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 +151,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.
|
||||
|
||||
|
||||
|
||||
@@ -479,3 +479,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());
|
||||
}
|
||||
|
||||
@@ -336,6 +336,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 +429,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 +442,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 +464,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
|
||||
|
||||
+1562
-9
File diff suppressed because it is too large
Load Diff
@@ -223,7 +223,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()))?;
|
||||
|
||||
+154
-6
@@ -3,7 +3,10 @@ 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,
|
||||
};
|
||||
@@ -73,12 +76,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 +144,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 +153,25 @@ impl Input {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
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(),
|
||||
@@ -129,6 +186,48 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
|
||||
fn send(input: &Input) -> anyhow::Result<()> {
|
||||
let body = serde_json::to_string(input)?;
|
||||
let mut stream =
|
||||
@@ -165,6 +264,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());
|
||||
|
||||
+396
-640
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,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;
|
||||
|
||||
@@ -68,7 +68,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,162 @@
|
||||
//! 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)));
|
||||
}
|
||||
}
|
||||
+2
-669
@@ -1,30 +1,7 @@
|
||||
//! 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)]
|
||||
pub struct Tab {
|
||||
pub id: u64,
|
||||
@@ -33,647 +10,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()
|
||||
}
|
||||
}
|
||||
Vendored
+2
-2
@@ -43,7 +43,7 @@ not replace Tessera's lockfile.
|
||||
|
||||
In the designated build environment, building Tessera also builds the required
|
||||
Aster libraries. A separate Aster executable or service is unnecessary. Runtime
|
||||
validation follows the designated remote validation workflow; initializing the
|
||||
validation runs locally using the commands in the [project guide](../README.md#validation); initializing the
|
||||
submodule alone does not validate compilation or Endor execution.
|
||||
|
||||
## Updating Aster
|
||||
@@ -56,7 +56,7 @@ git -C vendor/aster fetch origin
|
||||
git -C vendor/aster checkout --detach <aster-commit>
|
||||
```
|
||||
|
||||
Review the Aster changes and reconcile Tessera's dependencies and lockfile as needed; local native compilation is allowed, while automated tests run in CI. Commit `vendor/aster` in Tessera
|
||||
Review the Aster changes and reconcile Tessera's dependencies and lockfile as needed; build and validate the integration locally. Commit `vendor/aster` in Tessera
|
||||
along with any integration changes; that records the new Aster commit. Aster
|
||||
updates do not automatically advance Tessera's recorded revision.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user