mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-05 10:47:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30cbbc4be1 | ||
|
|
4a196c9c23 | ||
|
|
a62a17a7bf | ||
|
|
b0530cdccb | ||
|
|
e483a4b683 | ||
|
|
23eaa5bc8b | ||
|
|
18286f689b | ||
|
|
319c550af4 | ||
|
|
c7608b6404 | ||
|
|
c71febbb23 | ||
|
|
5ab3bd6b97 | ||
|
|
c59caf7272 | ||
|
|
c36f11ab4f | ||
|
|
7189433e5b | ||
|
|
dadb9e45d4 | ||
|
|
c1fabc5b5c | ||
|
|
5def89f182 | ||
|
|
4bd9ca234e | ||
|
|
924229fafe | ||
|
|
2314fca8ee | ||
|
|
2210d1fa01 | ||
|
|
746d3cca9c | ||
|
|
7f5773fc3c | ||
|
|
90f56143c4 | ||
|
|
ab4b82e492 | ||
|
|
4a3d8e5ca2 | ||
|
|
7f7f57d06d | ||
|
|
c45aed105c | ||
|
|
7e829ef87b | ||
|
|
cc9940c705 | ||
|
|
bb0b5a9a27 | ||
|
|
5686e8785f | ||
|
|
3da62b654e | ||
|
|
b6fb974264 | ||
|
|
07bfd84810 | ||
|
|
2db54e0e19 | ||
|
|
a2252a2ede | ||
|
|
fcc581d48b | ||
|
|
ec3bc51346 | ||
|
|
08a14d79cb | ||
|
|
812063a320 | ||
|
|
983cafc9b9 | ||
|
|
5e05f0d354 | ||
|
|
73d98a3dc3 | ||
|
|
7a78e5e7a6 | ||
|
|
f6164e7cf0 | ||
|
|
43f88c7866 | ||
|
|
70602e4c91 | ||
|
|
653f32e08c | ||
|
|
a98068ba09 | ||
|
|
b2a23d60b8 | ||
|
|
8fddb0a94c | ||
|
|
15e12ec2b9 | ||
|
|
c5945f1a94 | ||
|
|
d4cb543090 | ||
|
|
63b430873d | ||
|
|
3a584bbe8b | ||
|
|
2873df25ae | ||
|
|
bff6b484e5 | ||
|
|
4371335b37 | ||
|
|
468c347357 | ||
|
|
65503e60b8 | ||
|
|
a832de5ce1 | ||
|
|
5caef6b2b3 | ||
|
|
b50081014e | ||
|
|
5dc3a4525a | ||
|
|
716a005da6 | ||
|
|
5890dd0fe3 | ||
|
|
29856d9bea | ||
|
|
5184d3ec24 | ||
|
|
0610460ebb | ||
|
|
8081fc13f6 | ||
|
|
3fec62fbc2 | ||
|
|
3f76f77e5b | ||
|
|
1f33506c13 | ||
|
|
44e09aa89f | ||
|
|
0870cd923a | ||
|
|
49b14337eb | ||
|
|
74f186136b | ||
|
|
bbaa04354c | ||
|
|
86c97cbad1 | ||
|
|
5a05663050 | ||
|
|
ebe1d67620 | ||
|
|
7eaebeb787 | ||
|
|
2f1f7d5ba5 | ||
|
|
92454ae8a1 |
@@ -61,3 +61,6 @@ bin/
|
||||
### Python ###
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Probe artifacts written to the repo root by demo --nav3test / --searchesctest / --dialoganimtest
|
||||
/*.bmp
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
Guidance for Claude Code working in this repository. This is the primary
|
||||
context — read it first, then look at the files it points to.
|
||||
|
||||
## Documentation map
|
||||
|
||||
- [README.md](README.md) — public overview + quickstart (bridge plugin,
|
||||
`nativeComposeWindow`, sample apps).
|
||||
- [RENDERER.md](RENDERER.md) — the rendering layer: architecture, the retained-
|
||||
layer engine, convergence decisions, learnings, and open work. Read it before
|
||||
touching any renderer, graphics-actual, or layer-engine code.
|
||||
- [TOOLING.md](TOOLING.md) — build/vendor/verify scripts and workflows
|
||||
(build-sdl, sync + drift checks, parity, probe, profiler, coverage,
|
||||
verify-mac) + the version map and the ref-bump / release runbooks.
|
||||
- [TODO.md](TODO.md) — audited list of no-ops, stubs, and hardcodes left in the
|
||||
port, with severity, plus what's missing for a stable 1.12 release.
|
||||
- This file — architecture, module layout, vendoring rules, source-set
|
||||
hierarchy, density flow, conventions, and common pitfalls.
|
||||
|
||||
## What this project is
|
||||
|
||||
**ComposeNativeSDL3** — a Kotlin/Native port of Compose Multiplatform running
|
||||
@@ -228,10 +243,23 @@ Two categories of code live in each module:
|
||||
NON-IDEMPOTENT.** Move it OUT of `src/vendor/` into the corresponding
|
||||
`src/{commonMain,nativeMain,…}` tree, add a header comment noting
|
||||
which upstream file it derived from and what changed, and comment
|
||||
its line out of `compose-fork.txt`. Now it's a project file — the
|
||||
next sync won't overwrite it, and future upstream changes to that
|
||||
file need to be reconciled by hand. This is fine; do it when the
|
||||
edit is small and the file is unlikely to churn upstream.
|
||||
its line out of `compose-fork.txt`. **ALWAYS also add the
|
||||
machine-readable provenance line** so the copy can be diffed against
|
||||
its base and against later upstream versions:
|
||||
|
||||
```
|
||||
// VENDOR-BASE: <upstream-repo-relative-path> @ <pinned-tag>
|
||||
// VENDOR-BASE(COMPOSE_REF): <path> @ <tag> ← for umbrella-repo files
|
||||
```
|
||||
|
||||
`scripts/compose-fork/check-vendor-drift.py` reads these at every ref
|
||||
bump: it flags any file whose recorded base lags the current pin and
|
||||
(with the local clone) reports whether the upstream base ACTUALLY
|
||||
changed base..pin — i.e. whether the copy needs hand-reconciling or
|
||||
just a ref re-stamp. Now it's a project file — the next sync won't
|
||||
overwrite it, and future upstream changes to that file need to be
|
||||
reconciled by hand. This is fine; do it when the edit is small and
|
||||
the file is unlikely to churn upstream.
|
||||
|
||||
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`, with
|
||||
an SDL3 equivalent in `:ui/src/sdlRendererMain/`.** When upstream
|
||||
@@ -354,19 +382,10 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||
|
||||
SDL3 + SDL3_ttf + SDL3_image + FreeType (+ image codecs) are **built from
|
||||
source as static libraries on every OS** and linked straight into the
|
||||
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib;
|
||||
a distributable is `<app>` + `data.kres`. One script does it all:
|
||||
`python scripts/build-sdl/build-all.py` (plain Python 3, no Git Bash needed)
|
||||
builds into the gitignored, in-repo `libs/` folder. Versions/URLs pinned in
|
||||
`scripts/build-sdl/build-sdl.properties`. Steps run in order freetype → sdl3
|
||||
→ sdl3-image → sdl3-ttf; pass step names to rebuild a subset
|
||||
(`python scripts/build-sdl/build-all.py sdl3-ttf`).
|
||||
|
||||
Needs everywhere: git, cmake, python 3 (ninja fetched automatically). Per
|
||||
host: macOS = Xcode CLT (Skia default; Skiko klibs from Maven); Linux =
|
||||
gcc/g++ + the X11/Wayland/audio dev headers SDL3's configure detects (apt
|
||||
list in .github/workflows/publish.yml); Windows = mingw-w64 g++ on PATH
|
||||
(C compiles with K/N's bundled mingw).
|
||||
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib; a
|
||||
distributable is `<app>` + `data.kres`. Build them once per host with
|
||||
`python scripts/build-sdl/build-all.py`. Per-host toolchain requirements and
|
||||
the step breakdown are in [TOOLING.md](TOOLING.md#native-libraries).
|
||||
|
||||
## Runtime bundling — data.kres
|
||||
|
||||
@@ -392,19 +411,13 @@ executable, loaded via `SDL_GetBasePath()`). Contents:
|
||||
```bash
|
||||
# Sync every module's compose-fork.txt against the pinned upstream ref.
|
||||
scripts/compose-fork/sync.sh
|
||||
|
||||
# Sync one module by direct manifest path (module names use ':' → '/' → skip
|
||||
# 'compose/' prefix — nested modules like compose/sdl/window pass the full path).
|
||||
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt
|
||||
|
||||
# Re-format a manifest (align columns, group by upstream folder).
|
||||
scripts/compose-fork/format-manifest.py --discover ../cmp-ref \
|
||||
--manifest compose/ui/compose-fork.txt
|
||||
```
|
||||
|
||||
Upstream ref: `scripts/compose-fork/compose.properties` — set to a specific commit
|
||||
Upstream ref: `scripts/compose-fork/compose.properties` — set to a durable tag
|
||||
of `JetBrains/compose-multiplatform-core`. Bump the ref → re-sync → let the
|
||||
build tell you what broke.
|
||||
build tell you what broke. Per-module sync, manifest re-formatting, and the
|
||||
drift / vendor-clean guardrails are in
|
||||
[TOOLING.md](TOOLING.md#vendoring-upstream-compose).
|
||||
|
||||
## Key files by area — start here when you need to find something
|
||||
|
||||
@@ -458,95 +471,20 @@ build tell you what broke.
|
||||
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
||||
ship drop-in equivalents for our anchor / scrim patterns).
|
||||
|
||||
## Tooling — what's available and when to reach for it
|
||||
## Tooling
|
||||
|
||||
Index of the repo's tooling. Each entry says when to use it and points at its
|
||||
own README for detail; the parity/profiler/probe entries are expanded below
|
||||
since they're newer.
|
||||
The full tooling reference is [TOOLING.md](TOOLING.md): the static-lib build,
|
||||
vendor sync + drift/clean guardrails, API coverage, the `verify-mac` runbook,
|
||||
the native-vs-JVM parity harness, the interaction probe, and the frame
|
||||
profiler. Quick rules of thumb:
|
||||
|
||||
| Tool | Reach for it when | Detail |
|
||||
|------|-------------------|--------|
|
||||
| `python scripts/build-sdl/build-all.py` | building/refreshing the static SDL3/TTF/image/FreeType libs under `libs/` (once per host, or after bumping `build-sdl.properties`) | "Building" above |
|
||||
| `scripts/compose-fork/sync.sh` | re-syncing vendored upstream `androidx.compose.*` after a fresh checkout or a `compose.properties` ref bump; `format-manifest.py` to re-align a `compose-fork.txt` | "Vendor sync workflow" above + `scripts/compose-fork/README.md` |
|
||||
| `./gradlew apiDump && python scripts/compose-coverage.py` | measuring how much upstream public API the port actually covers, per module (`--missing <module>` lists uncovered decls) | "Vendoring" above |
|
||||
| `scripts/generate-material-symbols.py` / `subset-material-symbols.py` | regenerating the Material Symbols codepoints, or hb-subsetting bundled icon fonts to used glyphs (the latter runs automatically in app Zip tasks under `-PsubsetIcons`) | — |
|
||||
| **`scripts/parity/parity.py`** | after ANY renderer/layout change: catch a screen that visually diverged native-vs-JVM (missing content, wrong shape/colour, broken clip) | `scripts/parity/README.md` + below |
|
||||
| **`scripts/probe/probe.py`** | reproducing a specific interaction bug (click/hover/hold at a point) or grabbing one screen's pixels deterministically | `scripts/probe/README.md` + below |
|
||||
| **`CDN_PROFILE=1 <app>`** | finding where a slow frame goes (per-phase main-loop timings) before optimizing | below |
|
||||
| bridge plugin (`com.bitsycore.compose-desktop-native.bridge`) | consuming the published klibs from a third-party app | `gradle-plugin/compose-desktop-native-bridge/README.md` |
|
||||
| `demo --screen=<Name>` / `--screenshot=` / `--nav3test` / `--backtest` / `--multiwintest` | driving one screen headless, or the regression probes for nav3 / predictive-back / multi-window | `demo/src/nativeMain/kotlin/MainNative.kt` |
|
||||
- Whole-project renderer/layout change → run **parity** (`scripts/parity/parity.py`),
|
||||
the broad net.
|
||||
- Chasing one reported interaction → run the **probe** (`scripts/probe/`), targeted.
|
||||
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
|
||||
- Any renderer change → the **`verify-mac`** runbook gates both legs before commit.
|
||||
|
||||
Whole-project renderer-touching change → run **parity** (broad net). Chasing
|
||||
one reported interaction → **probe** (targeted). Slow → **profiler** first,
|
||||
optimize second. See `ROADMAP.md` for the renderer work these support.
|
||||
|
||||
### Frame profiler — `CDN_PROFILE=1`
|
||||
|
||||
Set the env var (`CDN_PROFILE=1`, or `=<path>` for a specific file) and run any
|
||||
native app; every ~2 s of rendered frames it writes avg/max ms per main-loop
|
||||
phase (`events` / `app` pump / `pump` per-window / `render`) to a FILE
|
||||
(`cdn_profile.log` by default — works for GUI-subsystem apps like the demo that
|
||||
have no console). The line also carries render SUB-phases (`layout` / `draw` /
|
||||
`present`) and per-frame DRAW COUNTERS from `DrawStats` (`geo` =
|
||||
SDL_RenderGeometry submits, `verts`, `masks` = rounded-clip offscreen passes,
|
||||
`text`, `img` blits) — so you can see WHAT `draw` is doing, not just that it's
|
||||
slow. `CDN_FORCERENDER=1` renders every frame (bypasses idle-skip) so
|
||||
steady-state timings can be read on otherwise-idle static screens.
|
||||
|
||||
**Caveat learned the hard way:** `present` is vsync-blocking, so every timing is
|
||||
capped by the DISPLAY refresh of the machine running the app. A 75 Hz dev
|
||||
display makes every app look "75 fps, present-bound" no matter what — so profile
|
||||
on the TARGET refresh rate before concluding anything about a frame-rate gap
|
||||
(the demo-70 / apidemo-144 report could not be reproduced on a 75 Hz box, where
|
||||
both pinned at 75 fps and the demo's `draw` was actually the lighter of the two).
|
||||
|
||||
### Interaction probe — `scripts/probe/`
|
||||
|
||||
Launches a native app, sends **window-client-relative** input (click / hover /
|
||||
hold, fractional coords addressed by process name so it ignores window
|
||||
position/focus) and captures the client area via `PrintWindow` (works even
|
||||
when occluded). The packaged form of the rigs that reproduced the
|
||||
square-on-click and TLS-chain bugs. Windows-only. See its README.
|
||||
|
||||
### Parity harness — native-vs-JVM screenshot diff (`scripts/parity/`)
|
||||
|
||||
`:demo` renders the **same commonMain screens** on two stacks: native
|
||||
(SDL/Skia, Kotlin/Native) and a `jvm()` target on upstream Compose Desktop.
|
||||
`scripts/parity/parity.py` screenshots every screen on both and pixel-diffs
|
||||
them, so a screen that visually diverges is a **port regression** (missing
|
||||
content, wrong shape/colour, broken clip). Several past renderer regressions
|
||||
would have been caught here.
|
||||
|
||||
```bash
|
||||
python scripts/parity/parity.py # all screens (builds first)
|
||||
python scripts/parity/parity.py Buttons Shapes # a subset
|
||||
python scripts/parity/parity.py --no-build # reuse the last renders
|
||||
```
|
||||
|
||||
Mechanics: the JVM leg renders all screens headlessly via `ImageComposeScene`
|
||||
in ONE process (`:demo:run --args=--screenshot-all=<dir>`, wired in
|
||||
`MainJvm.kt`); the native leg launches the exe once per screen
|
||||
(`--screen=<Name> --screenshot=<x>.bmp`). Output lands in **`build/parity/`
|
||||
(gitignored)**: `<pct>_<Name>_diff.png` (amplified difference heatmap),
|
||||
`<pct>_<Name>_compare.png` (native ∣ jvm ∣ diff, side by side), and
|
||||
`report.txt` ranked worst-first. Windows-only for the native leg today; needs
|
||||
Pillow. See `scripts/parity/README.md`.
|
||||
|
||||
**What the number means — read this before trusting it.** The `%differ` is the
|
||||
fraction of pixels whose per-channel difference exceeds a tolerance. It is NOT
|
||||
a pass/fail score and pixel-perfection is not the goal: the two stacks use
|
||||
different default fonts, so **every screen carries a steady baseline
|
||||
difference** — in the heatmap, text shows as a faint *doubled ghost* from
|
||||
slightly different line metrics/baselines. A healthy full sweep is a smooth
|
||||
gradient (~2% for a sparse screen like Counter, up to ~32% for a text-dense
|
||||
one like Tabs). Known SDL parity gaps also inflate specific screens
|
||||
predictably (Brushes ~23% — gradients render solid on SDL; Shadows / Canvas /
|
||||
GraphicsLayer — effect differences). **The signal is the RANKING and the
|
||||
delta from a screen's own history**, not the absolute value: a text-light
|
||||
screen suddenly reading 60%, or a screen jumping far above its neighbours, is
|
||||
the bug. In a `_diff.png`: ghosted/doubled text + dark shapes = normal font
|
||||
drift; a **solid bright block, or a shape present on only one side** = a real
|
||||
regression — open the `_compare.png` to see which stack is wrong.
|
||||
See [RENDERER.md](RENDERER.md) for the renderer work these support.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
# Next session — renderer caching / retained layers
|
||||
|
||||
## RESOLVED: the 70-vs-144 "gap" was two monitors, not a bug
|
||||
|
||||
The user has a **75 Hz main screen + a 144 Hz second screen**. Each app's FPS is
|
||||
just the refresh of whichever monitor its window opened on (SDL locks vsync to
|
||||
the window's current display). demo happened to open on the 75 Hz screen,
|
||||
apidemo on the 144 Hz one. Move the demo to the 144 Hz screen and it hits ~144.
|
||||
|
||||
Instrumentation confirmed there is NO performance problem: both apps have only
|
||||
~3.7 ms `draw` per frame (demo geo=40 masks=0; apidemo geo=146 masks=74 —
|
||||
apidemo does MORE and is fine), which fits both the 13.3 ms (75 Hz) and 6.7 ms
|
||||
(144 Hz) budgets with large headroom. Everything is present/vsync-bound.
|
||||
|
||||
**Implication:** the retained-layers / dirty-region / cacheKey work is a
|
||||
SPECULATIVE optimization with NO demonstrated need right now — no app misses its
|
||||
frame budget. Don't spend the big content-redirect-primitive effort chasing a
|
||||
phantom. Revisit only if a real, animated, heavy screen is measured (with
|
||||
CDN_PROFILE, ON the target monitor) to exceed its budget.
|
||||
|
||||
Still worth doing regardless of perf (correctness / cleanup, small):
|
||||
- Delete the dead `cacheKey` scaffolding (`GraphicsLayerModifier` /
|
||||
`GraphicsLayerNode`, unread) OR wire it — and fix the demo's misleading
|
||||
"(cached)" label (it uses a plain `graphicsLayer()`).
|
||||
|
||||
--- Original investigation notes below (kept for context) ---
|
||||
|
||||
|
||||
Starting point after v0.1.19. Context for the perf work the profiling pointed at.
|
||||
|
||||
## The problem (measured)
|
||||
|
||||
The demo caps at ~70 fps on every screen while apidemo hits 144. Cause is NOT
|
||||
vsync (both use `SDL_SetRenderVSync(renderer, 1)`) — the demo is **draw-bound**:
|
||||
`CDN_PROFILE=1` showed draw ~34 ms cold / ~14 ms steady, present only ~1.3 ms.
|
||||
The renderer is **immediate-mode**: every rendered frame walks the layout tree
|
||||
and re-tessellates everything (text → glyph quads, shapes → triangles). The
|
||||
always-present sidebar (30+ text rows + icons) is re-tessellated each frame.
|
||||
|
||||
Two open threads:
|
||||
|
||||
### FINDING (updated) — the 70-vs-144 is NOT reproducible on a 75 Hz dev box
|
||||
|
||||
Instrumented the draw phase (`DrawStats`: geo/verts/masks/text/img) + added
|
||||
`CDN_FORCERENDER=1`. Measured both apps forced-continuous:
|
||||
- demo: draw ~3.7ms, geo=40, verts=6582, masks=0, text=51
|
||||
- apidemo: draw ~2.9ms, geo=146, verts=18576, masks=74, text=174
|
||||
BOTH pinned at exactly 150 frames/2s = 75 fps with present ~9ms — i.e. the DEV
|
||||
DISPLAY is 75 Hz, so everything is present/vsync-bound here and the demo's draw
|
||||
is actually LIGHTER than apidemo's. The "demo is draw-bound" theory is WRONG on
|
||||
this hardware; the 70-vs-144 split is specific to the user's 144 Hz monitor and
|
||||
can't be reproduced/diagnosed from a 75 Hz box.
|
||||
|
||||
NEXT STEP FOR THE USER (144 Hz machine): run both apps with
|
||||
`CDN_FORCERENDER=1 CDN_PROFILE=1` and share the `cdn_profile.log` lines. If demo
|
||||
shows present ~13ms (75 fps) while apidemo shows present ~7ms (144 fps) at
|
||||
similar draw times, it's a present/vsync-path difference (driver / swap
|
||||
interval / DWM), NOT draw cost — a different investigation than caching.
|
||||
|
||||
The demo idles correctly when static (verified: 2 frames headless, even while
|
||||
hovering the sidebar), so there's NO spurious-continuous-render bug — thread A
|
||||
below is resolved as a non-issue.
|
||||
|
||||
### A. Why does the demo render CONTINUOUSLY? (investigate FIRST — likely cheaper)
|
||||
|
||||
Headless the demo idled (2 frames); interactively you see a constant 70-71 fps,
|
||||
so something invalidates every frame. Prime suspect: a **hover self-loop** — the
|
||||
main loop dispatches a synthetic hover every rendered frame
|
||||
(`ComposeWindow.renderFrame`: `if (hasMousePos) host.onPointerRaw(...)`), and if
|
||||
that perpetually re-invalidates a hover-reactive sidebar row, the app never
|
||||
idles while the cursor is over it. If confirmed, the fix is to stop the spurious
|
||||
invalidation so static screens idle (→ sidebar stops re-tessellating because
|
||||
nothing renders) — small and targeted, no caching needed.
|
||||
- Quick test: does the demo's FPS-title stop updating when the mouse leaves the
|
||||
window? If yes → hover loop confirmed.
|
||||
- Look at: `renderFrame` synthetic hover; how hover state feeds `needsFrame` /
|
||||
`hasPendingWork` / `shouldRender()`.
|
||||
|
||||
### B. cacheKey / retained-layer texture caching (bigger — the "renderer rewrite")
|
||||
|
||||
`Modifier.graphicsLayer(cacheKey=…)` exists as API (`GraphicsLayerModifier` /
|
||||
`GraphicsLayerNode` in `element/ModifierElements.kt`) but is **dead
|
||||
scaffolding**: `cacheKey` is stored and never read by the renderer; the node is
|
||||
a bare `Modifier.Node` with no draw behaviour (comment: "stays dormant until the
|
||||
renderer rewrite drives it"). The demo's GraphicsLayer "(cached)" section uses a
|
||||
plain `graphicsLayer()` — the label is aspirational; nothing is cached.
|
||||
|
||||
Blocker for real texture caching: it needs to render a subtree's `drawContent()`
|
||||
into an **offscreen canvas**, and the renderer has no way to redirect
|
||||
`drawContent()` off the frame canvas. `GraphicsLayer.native.draw()` replays its
|
||||
recorded block against a canvas via `drawScope.draw(…, canvas, …)`, but
|
||||
`drawContent()` is bound to the outer `ContentDrawScope`'s canvas, which nothing
|
||||
swaps. The vector/icon offscreen path works only because it draws an explicit
|
||||
object, not opaque `drawContent()`.
|
||||
|
||||
So the real unit of work is a **content-redirect primitive**: make the
|
||||
`ContentDrawScope` canvas swappable (or add a coordinator-level "draw this
|
||||
subtree into canvas X"). That primitive is the foundation for BOTH cacheKey
|
||||
(retained layers) AND dirty-region rendering. Infrastructure that already
|
||||
exists to build on: `OffscreenRenderer` (`createImageBitmap` + `createCanvas`),
|
||||
`SdlImageBitmap` render-target textures + `drawImage` blit-back, and the
|
||||
`NativeReleaseQueue` for freeing cached textures.
|
||||
|
||||
Cleanup to do regardless: either implement `cacheKey` or delete the dead
|
||||
`GraphicsLayerModifier`/`GraphicsLayerNode` scaffolding and fix the demo's
|
||||
misleading "(cached)" label.
|
||||
|
||||
## Tooling ready for this work
|
||||
|
||||
- `CDN_PROFILE=1 <app>` → per-phase timings (layout/draw/present) to a file.
|
||||
- `scripts/parity/parity.py` → native-vs-JVM screenshot diff (regression net).
|
||||
- `scripts/probe/probe.py` → drive a native window (click/hover/hold) + capture.
|
||||
- See ROADMAP.md item 2 (dirty regions + retained layers promoted, with the
|
||||
demo evidence) and CLAUDE.md "Tooling".
|
||||
@@ -1,283 +1,180 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](LICENSE.md)
|
||||
|
||||
# Compose Desktop Native
|
||||
|
||||
A Kotlin/Native port of **Compose Multiplatform** running on **SDL3** for
|
||||
windowing and input — **no JVM**. Compiles to native binaries for macOS
|
||||
(arm64), Linux (x64/arm64), Windows (mingwX64).
|
||||
Run [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform)
|
||||
as a native binary, with no JVM. Compose Desktop Native compiles to a single
|
||||
executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
|
||||
[SDL3](https://libsdl.org) for windowing and input.
|
||||
|
||||
Rendering is pluggable behind one `RenderBackend`:
|
||||
<img src="screenshots/demo.png" width="100%" alt="Compose Desktop Native demo" />
|
||||
|
||||
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
|
||||
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
|
||||
when `-Prenderer=sdl3` is passed.
|
||||
## How it works
|
||||
|
||||
The Compose **runtime** is the official
|
||||
`org.jetbrains.compose.runtime:runtime` klibs from Maven — this project
|
||||
re-implements the layers on top of it (`androidx.compose.ui.*`,
|
||||
`.foundation.*`, `.animation.*`, `.material3.*`) by **vendoring
|
||||
upstream Compose Multiplatform verbatim** whenever possible and hand-rolling
|
||||
project actuals + SDL3 / Skia glue only where needed.
|
||||
- **The runtime is the real one.** Composition, snapshots, and the recomposer
|
||||
are the official `org.jetbrains.compose.runtime` klibs from Maven, never
|
||||
reimplemented.
|
||||
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
|
||||
`animation`, and `material3` are copied from Compose Multiplatform verbatim
|
||||
wherever they compile as is, with project code filling in only the native glue.
|
||||
- **Rendering is pluggable** behind one `RenderBackend`: Skia (via Skiko) on
|
||||
macOS and Linux, and a from-scratch SDL3 renderer (`SDL3_ttf` +
|
||||
`SDL_RenderGeometry`) on Windows, or anywhere with `-Prenderer=sdl3`.
|
||||
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
|
||||
and clipboard all go through SDL3, so one code path covers every OS.
|
||||
- **No runtime dependencies.** SDL3 and its codecs are built as static
|
||||
libraries and linked in. A distributable is just the executable plus a
|
||||
`data.kres` resource bundle.
|
||||
|
||||
## Module layout
|
||||
| Platform | Gradle target | Default renderer |
|
||||
|----------|---------------|------------------|
|
||||
| macOS arm64 | `macosArm64` | Skia (Metal) |
|
||||
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) |
|
||||
| Windows | `mingwX64` | SDL3 |
|
||||
|
||||
One Gradle module per upstream Compose artifact — the directory tree mirrors
|
||||
upstream's `compose/` layout, gradle paths stay short via `projectDir`
|
||||
redirects.
|
||||
## Quickstart
|
||||
|
||||
```
|
||||
compose/
|
||||
├── ui/
|
||||
│ ├── ui/ → :ui (androidx.compose.ui.* + renderers + cinterops)
|
||||
│ ├── ui-util/ → :ui-util
|
||||
│ ├── ui-geometry/ → :ui-geometry
|
||||
│ ├── ui-unit/ → :ui-unit
|
||||
│ ├── ui-backhandler/ → :ui-backhandler
|
||||
│ └── ui-tooling-preview/ → :ui-tooling-preview (common @Preview + PreviewParameterProvider —
|
||||
│ IDE-only metadata; previews render via the apps' jvm targets)
|
||||
├── animation/
|
||||
│ ├── animation-core/ → :animation-core
|
||||
│ ├── animation/ → :animation
|
||||
│ └── animation-graphics/ → :animation-graphics
|
||||
├── foundation/
|
||||
│ ├── foundation/ → :foundation
|
||||
│ └── foundation-layout/ → :foundation-layout
|
||||
├── material3/material3/ → :material3
|
||||
├── material/material-ripple/ → :material-ripple
|
||||
└── sdl/ (project modules — the SDL integration layer)
|
||||
└── window/ → :window (nativeComposeApp { Window(...) } + SDL3 main loop,
|
||||
per-window Lifecycle/ViewModelStore/SavedState owners)
|
||||
|
||||
utils/
|
||||
└── material-symbols/ → :material-symbols (Material Symbols: 4200+ codepoints + Outlined/Rounded/
|
||||
Sharp composables with the variable-font axes as
|
||||
parameters (FILL/wght/GRAD/opsz). Common API — native
|
||||
draws via the port's icon-font engine, the jvm target
|
||||
via Skiko, so shared app code uses it on both stacks)
|
||||
|
||||
components/
|
||||
└── resources/library/ → :components-resources (vendored official compose resources runtime —
|
||||
painterResource/stringResource/Res codegen; the Maven
|
||||
artifact ships no mingwX64/linux klibs)
|
||||
|
||||
navigation3/
|
||||
└── navigation3-ui/ → :navigation3-ui (vendored NavDisplay + scene machinery — the one
|
||||
Navigation 3 layer without a K/N desktop artifact)
|
||||
|
||||
gradle-plugin/
|
||||
└── compose-desktop-native-bridge/ → :compose-desktop-native-bridge (the consumer-side bridge, published
|
||||
as a Gradle plugin — see "Using it from your own build")
|
||||
|
||||
demo/ → :demo (showcase + CLI probe suite; multiplatform — the same screens
|
||||
also build for stock JVM Compose Desktop as a parity reference)
|
||||
apidemo/ → :apidemo (Postman-style REST client; multiplatform — same UI on JVM too)
|
||||
```
|
||||
|
||||
## demo — widget & feature showcase
|
||||
|
||||
<img src="screenshots/demo.png" width="100%" alt="ComposeNativeSDL3 demo" />
|
||||
|
||||
`:demo` is a full tour of the re-implemented Compose + Material 3 surface —
|
||||
30+ sidebar screens covering buttons, text fields, layout, modifiers, shapes,
|
||||
images, state & recomposition, scrolling & lazy lists, dialogs, icons, canvas,
|
||||
graphics layers, custom layout, animation and gestures.
|
||||
|
||||
```bash
|
||||
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
||||
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
||||
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
|
||||
./gradlew :demo:run # stock JVM Compose Desktop (parity reference)
|
||||
```
|
||||
|
||||
The same shared screens drive both stacks — the JVM target runs them on
|
||||
upstream Compose Desktop unchanged, so any visual or behavioural difference
|
||||
against the native build is a porting bug, not a demo artifact.
|
||||
|
||||
CLI: `--gpu=…`, `--screen=<Name>` (one screen, no sidebar),
|
||||
`--screenshot=out.bmp --frames=N`, `--width=W --height=H`.
|
||||
|
||||
## apidemo — API Manager
|
||||
|
||||
<img src="screenshots/apidemo.png" width="100%" alt="ComposeNativeSDL3 API Manager" />
|
||||
|
||||
`:apidemo` is a Postman-style REST client built entirely on the library:
|
||||
request collections (**packs**, nested sub-packs, linked copies), a
|
||||
**Session → Pack → Request** inheritance ladder for variables / headers /
|
||||
query params / client certs, syntax-highlighted JSON / XML / YAML / HTML body
|
||||
editors, a response viewer with timing, size and TLS-chain inspection, mTLS
|
||||
client certificates, drag-and-drop tree management, request history and
|
||||
file-based sessions.
|
||||
|
||||
```bash
|
||||
./gradlew :apidemo:runDebugExecutableMacosArm64
|
||||
./gradlew :apidemo:runDebugExecutableLinuxX64
|
||||
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||
./gradlew :apidemo:run # stock JVM Compose Desktop (parity reference)
|
||||
```
|
||||
|
||||
Like the demo, the whole UI lives in `commonMain` and also runs on upstream
|
||||
Compose Desktop (JVM); only the mTLS / TLS-chain features are native-only —
|
||||
they drive the bundled libcurl directly.
|
||||
|
||||
Add `-Prenderer=sdl3` on macOS/Linux to drop Skiko and use the pure-SDL3
|
||||
renderer everywhere.
|
||||
|
||||
## Minimal app
|
||||
### A window
|
||||
|
||||
```kotlin
|
||||
import androidx.compose.material3.Text
|
||||
import com.compose.sdl.nativeComposeWindow
|
||||
|
||||
fun main() = nativeComposeWindow(title = "Hello") {
|
||||
Text("Hello from ComposeNativeSDL3")
|
||||
Text("Hello from Compose Desktop Native")
|
||||
}
|
||||
```
|
||||
|
||||
The lambda runs with a `ComposeWindowScope` receiver exposing
|
||||
`window: ComposeNativeWindow` (`setTitle` / `setSize` / `minimize` /
|
||||
`maximize` / `setFullscreen` / `close` / …); the same handle is reachable
|
||||
from any nested composable via `LocalComposeNativeWindow.current`.
|
||||
The content lambda runs with a `ComposeWindowScope` receiver that exposes
|
||||
`window: ComposeNativeWindow` (`setTitle`, `setSize`, `minimize`, `maximize`,
|
||||
`setFullscreen`, `close`). The same handle is reachable from any nested
|
||||
composable via `LocalComposeNativeWindow.current`.
|
||||
|
||||
Add these to your module's `commonMain.dependencies`:
|
||||
For multiple windows, wrap them in `nativeComposeApp`, where the set of
|
||||
`Window(...)` calls is state driven (Compose Desktop style):
|
||||
|
||||
```kotlin
|
||||
implementation(project(":window")) // window + main loop
|
||||
implementation(project(":material3")) // Material 3 widgets
|
||||
implementation(project(":material-symbols")) // icon-font composables (optional)
|
||||
fun main() = nativeComposeApp {
|
||||
Window(title = "Main", onCloseRequest = ::exitApplication) { /* ... */ }
|
||||
if (showInspector) {
|
||||
Window(title = "Inspector", onCloseRequest = { showInspector = false }) { /* ... */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using it from your own build — the bridge plugin
|
||||
Each window gets its own `Lifecycle`, `ViewModelStore`, and `SavedStateRegistry`
|
||||
owners, driven by real SDL focus and visibility events, so ViewModels and
|
||||
saved state behave as they do on Android.
|
||||
|
||||
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`, and
|
||||
a companion Gradle plugin ships the "redirect trick" this repo uses
|
||||
internally: apply **`com.bitsycore.compose-desktop-native.bridge`** once in
|
||||
`settings.gradle.kts` and declare the **official Compose Multiplatform
|
||||
coordinates** in `commonMain` — the plugin substitutes the port's klibs on
|
||||
every native desktop configuration (mingwX64 / linuxX64 / linuxArm64 /
|
||||
macosArm64), while android / jvm / iOS / wasm keep resolving the official
|
||||
artifacts. One dependency list, all CMP platforms + the port's new ones.
|
||||
### Building in this repo
|
||||
|
||||
```kotlin
|
||||
// settings.gradle.kts
|
||||
plugins { id("com.bitsycore.compose-desktop-native.bridge") version "<release>" }
|
||||
|
||||
// build.gradle.kts — official coords, everywhere
|
||||
commonMain.dependencies {
|
||||
implementation("org.jetbrains.compose.runtime:runtime:<runtime-version>")
|
||||
implementation("org.jetbrains.compose.ui:ui:<cmp-version>")
|
||||
implementation("org.jetbrains.compose.foundation:foundation:<cmp-version>")
|
||||
implementation("org.jetbrains.compose.material3:material3:<cmp-m3-version>")
|
||||
implementation(project(":window")) // window shell + main loop
|
||||
implementation(project(":material3")) // Material 3 widgets
|
||||
implementation(project(":material-symbols")) // icon-font composables (optional)
|
||||
}
|
||||
```
|
||||
|
||||
Setup details (repositories, credentials, version pinning):
|
||||
### Building from your own project: the bridge plugin
|
||||
|
||||
The klibs publish to GitHub Packages under `com.bitsycore.compose.sdl:*`. Apply
|
||||
the bridge Gradle plugin once, declare the **official** Compose Multiplatform
|
||||
coordinates, and the plugin swaps in the port's klibs on native desktop targets
|
||||
while android, jvm, iOS, and wasm keep resolving the official artifacts.
|
||||
|
||||
```kotlin
|
||||
// build.gradle.kts, official coordinates everywhere
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
id("com.bitsycore.compose-desktop-native.bridge") version "<release>"
|
||||
}
|
||||
|
||||
commonMain.dependencies {
|
||||
// The plugin exposes the exact Compose versions the port tracks, so you
|
||||
// never hand-match them (material3 is versioned separately upstream).
|
||||
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
|
||||
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
|
||||
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
|
||||
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
|
||||
}
|
||||
```
|
||||
|
||||
Repositories, credentials, and version pinning:
|
||||
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||
|
||||
For a complete project that applies the bridge and builds one shared UI for
|
||||
Android, JVM, and native desktop, see the example repo:
|
||||
[bitsycore/compose-desktop-native-bridge-example](https://github.com/bitsycore/compose-desktop-native-bridge-example).
|
||||
|
||||
## Sample apps
|
||||
|
||||
Both live in `commonMain` and also build for stock JVM Compose Desktop, which
|
||||
serves as the visual and behavioural reference: any difference against the
|
||||
native build is a porting bug.
|
||||
|
||||
**`:demo`** is a tour of the re-implemented Compose and Material 3 surface,
|
||||
30-plus screens covering text, layout, shapes, images, state, lazy lists,
|
||||
dialogs, canvas, graphics layers, animation, and gestures.
|
||||
|
||||
```bash
|
||||
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
||||
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
||||
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
|
||||
./gradlew :demo:run # JVM Compose Desktop (reference)
|
||||
```
|
||||
|
||||
**`:apidemo`** is a Postman-style REST client built entirely on the library:
|
||||
request collections, a session inheritance ladder, syntax-highlighted body
|
||||
editors, a response viewer with timing and TLS-chain inspection, and mTLS
|
||||
client certificates.
|
||||
|
||||
<img src="screenshots/apidemo.png" width="100%" alt="Compose Desktop Native API Manager" />
|
||||
|
||||
```bash
|
||||
./gradlew :apidemo:runDebugExecutableMacosArm64
|
||||
./gradlew :apidemo:run # JVM Compose Desktop (reference)
|
||||
```
|
||||
|
||||
Pass `-Prenderer=sdl3` on macOS or Linux to drop Skiko and use the pure SDL3
|
||||
renderer everywhere.
|
||||
|
||||
## Modules
|
||||
|
||||
One Gradle module per upstream Compose artifact, mirroring the upstream
|
||||
`compose/` tree. The renderer and cinterops live in `:ui`; `:window` is the SDL
|
||||
integration layer. `:material-symbols`, `:components-resources`, and
|
||||
`:navigation3-ui` are project or vendored modules that the official Maven
|
||||
artifacts do not cover for Kotlin/Native desktop.
|
||||
|
||||
Most of the androidx architecture stack (lifecycle, viewmodel, navigation3,
|
||||
savedstate, navigationevent) ships real Kotlin/Native desktop klibs and runs on
|
||||
the port unmodified. The full module map, dependency graph, and the list of
|
||||
compatible artifacts are in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Building
|
||||
|
||||
All four native dependencies — **SDL3**, **SDL3_ttf** (the in-house
|
||||
variable-font-axes fork), **SDL3_image** (+ vendored PNG/WEBP codecs) and
|
||||
**FreeType** — are built from source as **static** libraries by one script,
|
||||
on every OS, and linked straight into the executable. No `brew install`, no
|
||||
`apt install libsdl3-dev`, no runtime `.dll` / `.so` / `.dylib` next to the
|
||||
binary — a distributable is just `<app>` + `data.kres`.
|
||||
Build the native libraries once per machine, then build any app target:
|
||||
|
||||
```bash
|
||||
python3 scripts/build-sdl/build-all.py # freetype → sdl3 → sdl3-image → sdl3-ttf
|
||||
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild a subset
|
||||
python3 scripts/build-sdl/build-all.py # SDL3, SDL3_ttf, SDL3_image, FreeType (static)
|
||||
./gradlew :demo:runDebugExecutableMacosArm64
|
||||
```
|
||||
|
||||
Library versions / URLs are pinned in `scripts/build-sdl/build-sdl.properties`;
|
||||
output lands in the gitignored in-repo `libs/`. Required on every host: `git`,
|
||||
`cmake`, Python 3 (`ninja` is fetched automatically when absent). Per host:
|
||||
Every host needs `git`, `cmake`, and Python 3. Per host: macOS needs the Xcode
|
||||
command line tools; Linux needs gcc/g++ and the X11 / Wayland / audio dev
|
||||
headers; Windows needs a mingw-w64 g++ on PATH. See [TOOLING.md](TOOLING.md) for
|
||||
the full build and verification workflow.
|
||||
|
||||
- **macOS** — Xcode command line tools. Skia is the default renderer; Skiko
|
||||
klibs come from Maven.
|
||||
- **Linux** — gcc/g++ plus the X11 / Wayland / audio dev headers SDL3's
|
||||
configure detects (the exact apt list is in
|
||||
`.github/workflows/publish.yml`).
|
||||
- **Windows** — a mingw-w64 g++ on PATH (the C side compiles with
|
||||
Kotlin/Native's bundled mingw).
|
||||
## Documentation
|
||||
|
||||
Then build any app target — see the demo / apidemo commands above.
|
||||
|
||||
## Vendoring
|
||||
|
||||
The bulk of the `androidx.compose.*` code is vendored byte-for-byte from
|
||||
`JetBrains/compose-multiplatform-core` and lives under
|
||||
`<module>/src/vendor/` (gitignored — you re-sync on demand). Each module
|
||||
carries a `compose-fork.txt` manifest listing which upstream files it pulls
|
||||
in.
|
||||
|
||||
```bash
|
||||
scripts/compose-fork/sync.sh # sync every module
|
||||
scripts/compose-fork/sync.sh compose/ui/compose-fork.txt # one module
|
||||
```
|
||||
|
||||
Upstream ref pinned in `scripts/compose-fork/compose.properties`; manifests can
|
||||
pin additional upstream repos inline (`SET_REPO=<url>@<ref>` — the resources
|
||||
runtime vendors from the compose-multiplatform umbrella repo this way).
|
||||
|
||||
How much of upstream's public API the port actually covers is measured, not
|
||||
guessed — `scripts/compose-coverage.py` diffs this repo's klib ABI dumps
|
||||
against upstream's, per vendored module:
|
||||
|
||||
```bash
|
||||
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
|
||||
python3 scripts/compose-coverage.py --missing ui-text # list the exact uncovered decls
|
||||
```
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for the full architecture, source-set hierarchy,
|
||||
vendoring rules, density flow (physical-pixel Option B), and per-area file
|
||||
map.
|
||||
|
||||
## Known Compatible — official artifacts that just work
|
||||
|
||||
A surprising amount of the androidx architecture stack ships real
|
||||
Kotlin/Native desktop klibs (mingwX64, linuxX64/arm64, macosArm64) and runs
|
||||
on this port **unmodified** — no reimplementation, no vendoring. Sometimes
|
||||
the Google coordinates (`androidx.*`) carry the K/N variant, sometimes the
|
||||
JetBrains ones (`org.jetbrains.*`) — check both.
|
||||
|
||||
Verified working in-tree:
|
||||
|
||||
| Artifact | Notes |
|
||||
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `org.jetbrains.compose.runtime:runtime`, `runtime-saveable` | the Compose runtime itself — never reimplemented |
|
||||
| `androidx.compose.runtime:runtime-retain` | google coordinates |
|
||||
| `androidx.lifecycle:lifecycle-runtime-compose` | `LocalLifecycleOwner`, `rememberLifecycleOwner`, `repeatOnLifecycle` |
|
||||
| `androidx.lifecycle:lifecycle-viewmodel(-compose, -savedstate)` | full ViewModel + SavedStateHandle stack |
|
||||
| `androidx.lifecycle:lifecycle-viewmodel-navigation3` | per-entry ViewModel scoping for Navigation 3 |
|
||||
| `androidx.savedstate:savedstate`, `savedstate-compose` | |
|
||||
| `androidx.navigation3:navigation3-runtime` | backstack / NavEntry / decorators (only the UI layer needed vendoring → `:navigation3-ui`) |
|
||||
| `androidx.navigationevent:navigationevent-compose` | predictive back / BackHandler plumbing |
|
||||
| `androidx.collection:collection` | |
|
||||
|
||||
The window layer supplies what these expect from a host: per-window
|
||||
`LifecycleOwner` (driven by real SDL focus/visibility events — focused →
|
||||
RESUMED, unfocused → STARTED, minimized → CREATED), `ViewModelStoreOwner`
|
||||
(the `activityViewModels()` analog, `SavedStateHandle`-enabled) and
|
||||
`SavedStateRegistryOwner`, plus a `Dispatchers.Main` with a true `immediate`.
|
||||
ViewModels scope per nav entry, per window, or anywhere in between — same
|
||||
semantics as Android.
|
||||
|
||||
## Tooling
|
||||
|
||||
Helper scripts live under `scripts/`; each has its own README with detail.
|
||||
|
||||
| Tool | Use it to | More |
|
||||
|------|-----------|------|
|
||||
| `scripts/build-sdl/build-all.py` | build the static SDL3 / TTF / image / FreeType libs | see **Building** above |
|
||||
| `scripts/compose-fork/sync.sh` | re-sync the vendored upstream Compose sources | [README](scripts/compose-fork/README.md) |
|
||||
| `scripts/parity/parity.py` | diff every demo screen native-vs-JVM to catch render regressions | [README](scripts/parity/README.md) |
|
||||
| `scripts/probe/probe.py` | drive a native window (click/hover/hold) + screenshot it | [README](scripts/probe/README.md) |
|
||||
| `CDN_PROFILE=1 <app>` | print per-phase frame timings to find slow frames | — |
|
||||
| `python scripts/compose-coverage.py` | measure API coverage vs upstream (after `./gradlew apiDump`) | — |
|
||||
|
||||
Building on the port from another project? Use the bridge plugin —
|
||||
[gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||
Contributor-facing detail (including how to read the parity `%differ`) is in
|
||||
[CLAUDE.md](CLAUDE.md#tooling--whats-available-and-when-to-reach-for-it).
|
||||
- [TOOLING.md](TOOLING.md): building the native libs, vendoring, and verification.
|
||||
- [CLAUDE.md](CLAUDE.md): architecture, source-set hierarchy, vendoring rules,
|
||||
density flow, and a per-area file map.
|
||||
- [gradle-plugin/compose-desktop-native-bridge](gradle-plugin/compose-desktop-native-bridge/README.md):
|
||||
consuming the port from another build.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
# RENDERER.md
|
||||
|
||||
The durable reference for the rendering layer: how it is structured, the
|
||||
convergence work that shaped it, the decisions worth remembering, and what is
|
||||
left. Read this before touching anything under `renderer/`, the graphics
|
||||
actuals, or the layer engine.
|
||||
|
||||
For build and verification commands see [TOOLING.md](TOOLING.md). For module
|
||||
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## 1. The two renderer legs
|
||||
|
||||
Rendering is pluggable behind one `RenderBackend` interface. Exactly one leg is
|
||||
active per target:
|
||||
|
||||
- **Skia leg** (`skikoRendererMain`, macOS + Linux). Draws through upstream
|
||||
Compose Multiplatform's own Skia stack, vendored verbatim: `SkiaBackedCanvas`,
|
||||
`SkiaBackedPaint`, `SkiaShader`, `actual class GraphicsLayer` backed by
|
||||
`org.jetbrains.skiko.node.RenderNode`, `SkiaGraphicsContext`. On this leg the
|
||||
layer and draw engine internals are literally upstream.
|
||||
- **SDL leg** (`sdlRendererMain`, Windows always; macOS/Linux under
|
||||
`-Prenderer=sdl3`). A from-scratch renderer on `SDL_RenderGeometry` +
|
||||
`SDL3_ttf` + FreeType. SDL is a triangle blitter, so this leg carries a
|
||||
permanent bespoke surface (about 3,800 lines): `Sdl3Canvas`,
|
||||
`Sdl3TextRenderer`, `FreeTypeIcons`, and the SDL render nodes.
|
||||
|
||||
Both legs share one project-owned text and image pipeline (`SdlParagraph`,
|
||||
`NativeTextMeasurer`, `IconFont`). Text layout, line-breaking, metrics, and
|
||||
hit-test are the port's engine on both legs; only glyph rasterization differs
|
||||
(Skia vs SDL3_ttf/FreeType). This is a deliberate axiom, not a gap: see the
|
||||
text decision in section 4.
|
||||
|
||||
The seam is kept as narrow and low as possible. Code flows
|
||||
`Common (upstream) -> shared native engine -> Skia actual / SDL actual`. What we
|
||||
minimize is hand-rolled actual surface, not actual-side line count: a fat
|
||||
vendored-upstream actual (the Skia leg carrying upstream's whole GraphicsLayer
|
||||
stack) is preferred over a thin hand-written one.
|
||||
|
||||
## 2. The retained-layer engine (the model we copied)
|
||||
|
||||
Upstream skiko skips work at three levels. The port has all three.
|
||||
|
||||
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
|
||||
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
|
||||
`SDL_WaitEventTimeout` when idle.
|
||||
- **L2 measure/layout only dirty nodes.** Vendored `MeasureAndLayoutDelegate`
|
||||
used verbatim through `ComposeOwner.measureAndLayout()`.
|
||||
- **L3 draw record-once / replay** (the expensive one). Each isolated
|
||||
`LayoutNode` gets an `OwnedLayer` (`GraphicsLayerOwnerLayer`) owning a
|
||||
`GraphicsLayer`. The display list re-records only when content is dirty;
|
||||
otherwise it replays.
|
||||
|
||||
The critical property: **transform, alpha, and clip changes do NOT re-record.**
|
||||
Moving, scaling, rotating, or fading a layer replays cached content under a new
|
||||
transform. Only a genuine content change (a state read inside the draw block, or
|
||||
a resize) re-records. Upstream replays the whole scene every frame with no
|
||||
dirty-region present; the entire win is not re-recording clean layers, not
|
||||
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
|
||||
|
||||
The compositing-strategy contract (`requiresLayer()`, from
|
||||
`SkiaGraphicsLayer.skiko.kt`) must match on both legs. It decides when a layer
|
||||
needs an offscreen:
|
||||
|
||||
| Condition | Auto | Offscreen | ModulateAlpha |
|
||||
|---|---|---|---|
|
||||
| `alpha < 1` | offscreen | offscreen | per-op alpha multiply (no offscreen) |
|
||||
| `colorFilter != null` | offscreen | offscreen | offscreen |
|
||||
| `blendMode != SrcOver` | offscreen | offscreen | offscreen |
|
||||
| `renderEffect != null` | offscreen | offscreen | offscreen |
|
||||
| none of the above | replay in place | always offscreen | replay in place |
|
||||
|
||||
## 3. Current state
|
||||
|
||||
- **Skia leg runs upstream's engine.** Canvas (`SkiaBackedCanvas`) and
|
||||
GraphicsLayer/GraphicsContext (`org.jetbrains.skiko.node.RenderNode`) are
|
||||
vendored verbatim. The Skia leg gets real display-list caching and correct
|
||||
clip/shadow/renderEffect for free from upstream. After this landed, Skia
|
||||
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
|
||||
- **SDL leg ships the geo node as the Windows default.** `SdlDisplayListRenderNode`
|
||||
records a leaf's layer-local tessellated geometry, text runs, and icon glyphs
|
||||
by params (not texture pointers), then re-emits through the layer transform.
|
||||
Crisp under any transform, bit-exact, deterministic (no render-target state),
|
||||
and eviction-safe (replay re-looks-up via the run LRU / FreeType glyph cache).
|
||||
0.000% across a 57-screen self-consistency sweep bar a sub-0.06% rotated-edge
|
||||
AA fringe. LazyColumn steady-state `draw` fell 57% versus the un-cached
|
||||
baseline. Fallbacks: `CDN_LAYERCACHE=off|texture`.
|
||||
- **Parity is a golden-master on the Skia leg** (about 2% median, dominated by
|
||||
the shared text engine's small metric delta) and a bounded, ranked signal on
|
||||
the SDL leg. See TOOLING.md for how to read it.
|
||||
- **Memory is stable.** The historical composition leak is fixed (see section 6)
|
||||
and guarded by the `--soaktest` gate in the verify runbook.
|
||||
|
||||
## 4. Decisions to remember
|
||||
|
||||
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
|
||||
reconciliation cost when following upstream, not Windows pixel-parity or
|
||||
feature completeness. The JVM Compose Desktop target (`:demo:run`) is the
|
||||
documented Windows fidelity/feature tier: real Skia, full fidelity, on any
|
||||
host. That makes SDL-leg fidelity work (B3) and real-Skia-on-Windows (Track A)
|
||||
optional polish, not obligations.
|
||||
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
|
||||
Edit-to-compile becomes a manual vendor with a `// VENDOR-BASE:` header so the
|
||||
drift tripwire can track it. The litmus test for any divergence: "Is this what
|
||||
upstream does? If not, what real platform constraint forces the difference?"
|
||||
Valid answers name a constraint (no Skiko on Windows K/N; SDL is a triangle
|
||||
blitter). "It was easier" is not valid.
|
||||
- **B2 convergence is complete.** The Skia leg was migrated off the port's
|
||||
hand-rolled GraphicsLayer onto upstream's own. This was a source-set migration,
|
||||
not a file-flip: the SDL node cluster relocated `nativeMain -> sdlRendererMain`,
|
||||
`GraphicsContext` forked per-leg behind a `createGraphicsContext()` seam, and
|
||||
the upstream skiko files were un-refused in `compose-fork.txt`. Measured
|
||||
sync-tax reduction on the skiko path: about 8.5x (roughly 1240 hand-rolled
|
||||
lines down to about 145 lines of drift-tracked edits; roughly 1006 lines now
|
||||
auto-sync verbatim on a ref bump). The beta02 -> beta03 bump proved it: zero
|
||||
reconciliation, all manual-vendor bases unchanged.
|
||||
- **Text stays the port's engine (B6.3 skipped).** Making Skia-leg text truly
|
||||
upstream (`SkiaParagraph`) is a font-subsystem replacement, not a canvas swap:
|
||||
two coexisting font-identity models (the port's name-to-bytes `IconFont` +
|
||||
no-op resolver vs upstream `PlatformFont`/`FontCache`/`FontCollection`), about
|
||||
13 skiko files to re-vendor, about 10 shared actuals to split, a
|
||||
`data.kres`-to-`FontCache` bridge, no green intermediate, and it discards the
|
||||
P3.1 metrics work. ROI is low: the Skia leg already measures text with skiko
|
||||
`Font` metrics (via `currentTextMeasurer` -> `SkiaTextRenderer`), which is how
|
||||
parity reached about 2%. Revisit only if complex-script/bidi fidelity on the
|
||||
native Skia leg becomes a hard requirement.
|
||||
- **`GraphicsLayerOwnerLayer` stays shared.** The fork point is `GraphicsLayer` /
|
||||
`GraphicsContext`, not the owner layer. The shared owner layer compiles
|
||||
against both `GraphicsLayer` actuals (upstream skiko + port SDL) only if both
|
||||
satisfy the commonMain `expect class GraphicsLayer` API. That API-parity
|
||||
invariant is enforced by `:ui:compileCommonMainKotlinMetadata` on every build.
|
||||
- **Lifetime model.** The SDL leg uses GC / release-queue for layer lifetime and
|
||||
does NOT vendor `ChildLayerDependenciesTracker`. The Skia leg uses upstream's
|
||||
`SkiaGraphicsLayer`, which inherently uses the tracker (already vendored in
|
||||
commonMain). A per-leg lifetime divergence is the class of bug that caused an
|
||||
early navigation crash, so any change here goes to both legs plus a soak gate.
|
||||
- **Module split (`:ui-graphics` / `:ui-text`) is shelved as infeasible.** Not
|
||||
cosmetic churn as originally scoped: the `sdl3` cinterop is a shared substrate
|
||||
used by 11 graphics-side files AND 12 platform/windowing/node/resources files
|
||||
that stay in `:ui`. Kotlin/Native cannot cleanly share a cinterop klib across a
|
||||
module boundary, which is exactly why they were merged. A real split needs a
|
||||
new non-upstream `:ui-cinterop` base module plus cross-module cinterop
|
||||
api-exposure, which is bigger and more divergent than the "match upstream
|
||||
artifacts" goal it was meant to serve. The one keeper from the attempt:
|
||||
`RenderBackend.drawRoot` now takes `(Canvas) -> Unit`, decoupling the backends
|
||||
from `ComposeRootHost` (a genuine improvement, retained).
|
||||
- **Track A (real Skia on Windows K/N) is shelved.** Kotlin/Native mingwX64 is
|
||||
GNU-ABI; skia-pack Windows is MSVC-ABI, which cannot be statically fused into a
|
||||
GNU-ABI binary. Building current C++20 Skia to a GNU/mingw static archive is an
|
||||
open-ended fork (only abandoned Mozilla-era precedent). The only working route
|
||||
is a runtime DLL, which breaks the no-DLL invariant and adds tens of MB on the
|
||||
one platform it targets. The SDL leg is the permanent Windows renderer; JVM is
|
||||
the fidelity escape hatch.
|
||||
|
||||
## 5. Convergence status
|
||||
|
||||
| Item | Status |
|
||||
|------|--------|
|
||||
| Guardrails (parity gate, verify-mac runbook, drift + vendor-clean checks) | Done |
|
||||
| B2: Skia leg on upstream GraphicsLayer/GraphicsContext | Done |
|
||||
| B6.1: Skia leg on upstream `SkiaBackedCanvas` + paint/shader | Done |
|
||||
| B6.2: upstream `GraphicsLayer` + delete transient port cluster | Done |
|
||||
| B6.3: upstream text (`SkiaParagraph`) on the Skia leg | Skipped (decision) |
|
||||
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
|
||||
| P2.2: composition memory leak | Fixed + soak-gated |
|
||||
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
|
||||
| P3.1: SDL text metrics parity (17% median -> 2%) | Done |
|
||||
| B3: further SDL fidelity | Capped (parity-ranked wins only) |
|
||||
| Module split | Shelved (infeasible as specified) |
|
||||
| Track A: real Skia on Windows | Shelved |
|
||||
|
||||
## 6. Remaining and future work
|
||||
|
||||
Nothing here blocks day-to-day work. These are the open threads worth
|
||||
remembering.
|
||||
|
||||
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook covers both renderers
|
||||
but cannot cover the shipped mingwX64 binary, the Windows-only `PrintWindow`
|
||||
probe, or the common-metadata publish job. Run these on a Windows host before
|
||||
any release. This is the only outstanding verification.
|
||||
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
|
||||
`v1.12.0-beta03+dev4483` (no clean beta03 tag exists yet, and it is not on
|
||||
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
|
||||
published) by a documented skew. When 1.12.0 stable ships: re-pin both refs to
|
||||
the clean tag, bump the `vComposeJvmVersion` forcing in demo/apidemo/
|
||||
material-symbols to close the skew, then cut the release. The concrete
|
||||
step-by-step (the version map, the ref-bump flow, and the release flow) lives
|
||||
in [TOOLING.md](TOOLING.md#versioning-and-releasing).
|
||||
- **Deferred engine deltas (D2-D6, all low ROI under G1).**
|
||||
- D2: split `GraphicsLayerOwnerLayer` per-leg to restore upstream's exact
|
||||
`setLightingInfo` shadow lighting. Feasible (the old RootNodeOwner-coupling
|
||||
blocker was outdated) but high blast radius for a marginal gain; elevation
|
||||
shadows already match JVM.
|
||||
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
|
||||
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
|
||||
both-legs matrix fn).
|
||||
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
|
||||
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
|
||||
accessibility or complex-script roadmap.
|
||||
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
|
||||
vendoring them saves nothing.
|
||||
- **SDL performance (opportunistic).** Shared glyph atlas to replace per-run
|
||||
textures; `drawImageRect` capture in the geo node (journal rates it low-ROI).
|
||||
- **SDL_GPU backend (long-term).** Real stencil clipping, pipelined batching,
|
||||
shader gradients. The `NativeRenderNode` seam makes a GPU node a clean
|
||||
drop-in.
|
||||
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
|
||||
`SdlImageBitmap.close()` fully into cache eviction and the renderer
|
||||
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
|
||||
|
||||
## 7. Hard-won learnings (do not relearn)
|
||||
|
||||
- **Minimize-divergence is load-bearing.** A hand-written `notifyLayerIsDirty`
|
||||
that diverged from upstream `OwnedLayerManagerImpl` removed a layer from
|
||||
`dirtyLayers` mid-loop and crashed on any navigation. Screenshots missed it;
|
||||
the interaction probe caught it. Match upstream verbatim; do not hand-roll
|
||||
engine plumbing from a summary.
|
||||
- **The composition memory leak (root cause).** The port never called
|
||||
`OwnerSnapshotObserver.clearInvalidObservations()`, which upstream
|
||||
`RootNodeOwner` runs after every measure. Snapshot read-observations for
|
||||
scopes invalidated on dispose lingered forever, each pinning its observed
|
||||
object graph via a Kotlin/Native `ExternalRCRef` (a K/N-heap leak, vmmap
|
||||
Memory Tag 246). Most visible on ripple/indication draws. Fix:
|
||||
`ComposeRootHost.measureAndLayout()` sweeps `clearInvalidObservations()` after
|
||||
layout, plus `ComposeOwner.onDetach` clears the detached node's observations.
|
||||
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
|
||||
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
|
||||
budget heap tooling for this class.
|
||||
- **Offscreen-texture caching is timing-nondeterministic on complex screens.**
|
||||
This is why the geo (no-render-target) node is the robust default, not the
|
||||
texture node.
|
||||
- **Rounded/path layer clips must be applied or deferred on the fast path.** The
|
||||
geo path submits raw geometry, which clips only to a rect; rounded clips are a
|
||||
lazy offscreen mask the fast replay bypasses. Rect clip on the fast path;
|
||||
rounded/generic clips fall back to block-replay.
|
||||
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
|
||||
animated/settling screens give false signals. Use render-to-quiescence +
|
||||
virtual frame time for parity, the probe for interaction/crash coverage.
|
||||
- **The profiler `present` phase is vsync-capped by the display refresh.**
|
||||
Profile on the target monitor before concluding a frame-rate gap.
|
||||
|
||||
## 8. Explicit non-goals
|
||||
|
||||
- **Dirty-region / partial present.** Upstream replays the whole scene; the win
|
||||
is not re-recording, not redrawing less.
|
||||
- **A custom `cacheKey` API.** Superseded by the real per-node display list.
|
||||
- **Vendoring `RootNodeOwner` / the `ComposeScene` stack.** Coupled to skiko's
|
||||
`SkiaLayer` and windowing; it fights the `:window` SDL loop. The port borrows
|
||||
only the layer engine.
|
||||
|
||||
## 9. Key files
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
|
||||
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
|
||||
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3RenderBackend.kt`.
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/FreeTypeIcons.kt`:
|
||||
variable-font axis rasterization.
|
||||
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
|
||||
hit-test, event dispatch, snapshot observer sweep.
|
||||
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
|
||||
`Owner` + `GraphicsLayerOwnerLayer` bridge.
|
||||
- `compose/ui/ui/src/nativeMain/.../ui/text/SdlParagraph.native.kt`: the bridged
|
||||
`Paragraph` (measurement, hit-test, line metrics, span painting).
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
# Renderer roadmap — fixes, performance, memory
|
||||
|
||||
Grounded in the bubble-wrap findings (2026-07): lazy clips took the stress
|
||||
sheet from 23 → 39 FPS, but 75 Hz needs structural work; issue #2 showed that
|
||||
Cleaner-managed native memory starves when the Kotlin heap is quiet.
|
||||
|
||||
## 1. Deterministic native-resource lifecycle
|
||||
|
||||
Ownership closes resources the moment they're finished; GC stays as the
|
||||
safeguard, not the mechanism.
|
||||
|
||||
- [x] **`NativeReleaseQueue`** — SDL calls are main-thread-only, so nothing may
|
||||
destroy directly from a Cleaner or worker: release actions ENQUEUE, the main
|
||||
loop DRAINS between frames. Cleaners become safe from any thread because
|
||||
they only enqueue. Verified end-to-end (direct enqueue + a real
|
||||
Cleaner→enqueue→drain cycle).
|
||||
- [x] **`SdlImageBitmap` textures** — previously never destroyed (manual SDL
|
||||
memory, invisible to the GC nudge). A holder + Cleaner now enqueues
|
||||
`SDL_DestroyTexture` / `SDL_DestroySurface` for the texture, render target
|
||||
and never-realized decoded surface; explicit `close()` frees promptly.
|
||||
- [ ] **Wire `close()` into cache eviction** — the Cleaner is the current
|
||||
release path for decoded/vector bitmaps (correct but GC-timed). Call
|
||||
`SdlImageBitmap.close()` on image-cache eviction / `removeMemoryResource`
|
||||
for prompt release (needs a cross-module hook — the caches hold the
|
||||
`ImageBitmap` interface, not the concrete type).
|
||||
- [ ] **Cache-eviction closes (rest)** — every image/text cache closes what it
|
||||
evicts (Sdl3ImageCache already does; sweep the rest, both renderers).
|
||||
- [ ] **Renderer `destroy()` chain** — window close synchronously frees every
|
||||
pool (clip targets already do; text caches, image caches, shadow cache,
|
||||
typefaces to follow).
|
||||
- [ ] **`SkiaImageBitmap.close()`** (surface + snapshot) + close-on-eviction in
|
||||
the Skia caches. *Skia source sets build only on macOS/Linux — needs a mac
|
||||
or CI leg to verify.*
|
||||
- [ ] **Demote the GC nudge** — once ownership does the work, stretch the 10s
|
||||
interval / gate it on churn; it remains the backstop.
|
||||
|
||||
Verification: `demo --leaktest`-style probe (cycle screens N times, print
|
||||
RSS; expect a plateau without GC sawtooth).
|
||||
|
||||
## 2. SDL renderer performance (the 39 → 75 Hz path)
|
||||
|
||||
- [x] **Frame profiler** — `CDN_PROFILE=1` env flag prints per-phase timings
|
||||
(events / app pump / window pump / render / present) every ~2 s. Measure
|
||||
first, optimize second.
|
||||
- [ ] **Dirty-region rendering** — NEXT, promoted by profiling: the app
|
||||
re-tessellates the whole window on any invalidation. Accumulate damage from
|
||||
invalidated layers and scissor the redraw. Biggest lever for heavy scenes.
|
||||
Evidence (CDN_PROFILE): the full demo is DRAW-bound — draw ~34ms cold /
|
||||
~14ms steady, present only ~1.3ms — because it re-tessellates the whole
|
||||
always-present sidebar (30+ text rows + icons) every rendered frame. That
|
||||
~14ms caps it near 70 fps; apidemo's lighter chrome fits the 6.9ms budget
|
||||
and reaches the 144 Hz vsync ceiling. The "70 ≈ 144/2" look is coincidence,
|
||||
NOT a vsync-half artifact — both use vsync(1). Retained layers / dirty
|
||||
regions would stop re-tessellating the static sidebar.
|
||||
- [ ] **Retained layer textures** — cache a layer's rendered output keyed by
|
||||
its draw content (RenderNode-style); static subtrees stop re-tessellating.
|
||||
- [ ] **Right-size clip scratch targets** (LOW — profiling showed masks aren't
|
||||
a hot path after lazy clips): size scratch textures to the clip bbox.
|
||||
Memory, not frame time.
|
||||
- [ ] **Glyph atlas** — text draws currently break geometry batches (z-order
|
||||
flush per run); an atlas texture lets glyphs ride the vertex batches.
|
||||
|
||||
## 3. Skia renderer
|
||||
|
||||
- [ ] Bounded `SkiaImageCache` (LRU + eviction close) — unbounded HashMap today.
|
||||
- [ ] Eager-close audit of the text pipeline (mirror of the SDL sweep).
|
||||
- [ ] `saveLayer` huge-bounds clamp (GPU offscreen memory spikes — see
|
||||
CLAUDE.md pitfall).
|
||||
|
||||
## 4. Correctness / parity gaps (future bug reports waiting)
|
||||
|
||||
Ordered by likelihood of a user hitting them. NOTE: this list predates several
|
||||
fixes — verify a claim against the parity heatmaps before acting (gradients and
|
||||
rotation both turned out already-working). In a parity `_diff.png`: a solid
|
||||
bright block = real gap; ghosted text = font drift.
|
||||
|
||||
- [x] **Gradient brushes on SDL** — samplers were already implemented; the real
|
||||
bug was UNDER-SAMPLING: fills sampled only corners, so radial/sweep on a
|
||||
rect/round-rect rendered FLAT (a square's corners are equidistant from centre
|
||||
→ identical t). Gradient fills now grid-mesh the interior (emitRectMesh);
|
||||
radial/sweep match upstream. (Solid fills unchanged — one quad.)
|
||||
- [x] **Layer rotation** — verified WORKING (demo GraphicsLayer "Rotation
|
||||
(live)" + "Combined" rotate correctly); stale claim removed.
|
||||
- [ ] **Real `saveLayer` alpha on SDL** — overlapping content composites at
|
||||
paint level; needs an offscreen (the clip-target pool can serve it).
|
||||
- [ ] **`clipPath` generic shapes** — bbox fallback clips square.
|
||||
|
||||
## 5. Permanent tooling
|
||||
|
||||
- [x] **Parity harness** — scripts/parity/: renders every :demo screen native +
|
||||
JVM from the same commonMain composables, pixel-diffs, ranks by %differ,
|
||||
emits per-screen diff heatmaps + side-by-side compares (pct in filename).
|
||||
build/parity/ (gitignored). See scripts/parity/README.md.
|
||||
- [x] Promote the press/hover automation rig into `scripts/probe/`:
|
||||
window-CLIENT-relative input (click/hover/hold, fractional coords, process-
|
||||
addressed) + PrintWindow capture (works occluded). See scripts/probe/README.md.
|
||||
|
||||
## 6. Long-term
|
||||
|
||||
- [ ] **SDL_GPU render backend** — real stencil clipping (no masks at all),
|
||||
pipelined batching, shader gradients. Weeks of work across three
|
||||
platforms; items 2–4 stay useful beneath it.
|
||||
|
||||
Suggested order: profiler → lifecycle queue + leak closes → clip-target
|
||||
right-sizing → gradients → dirty regions → the rest by demand.
|
||||
@@ -0,0 +1,160 @@
|
||||
# TODO.md
|
||||
|
||||
Everything the port currently leaves as a no-op, stub, hardcode, or partial
|
||||
implementation, plus the gaps worth closing before a stable 1.12 release. This
|
||||
is an audit of PROJECT code only (the `*.native.kt` / `*.sdl.kt` /
|
||||
`com.compose.sdl.*` actuals); vendored upstream under `src/vendor/` is out of
|
||||
scope.
|
||||
|
||||
Severity is a rough guide, not a mandate:
|
||||
|
||||
- **Blocker** for stable: a capability a typical desktop app expects that is
|
||||
currently absent or broken.
|
||||
- **Nice-to-have**: real gap, but the app works without it.
|
||||
- **Cosmetic**: minor fidelity or edge-case behavior.
|
||||
|
||||
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
|
||||
(see [RENDERER.md](RENDERER.md)). The **JVM Compose Desktop target is the
|
||||
documented Windows fidelity/feature tier**, so SDL-leg visual gaps (section J)
|
||||
are lower priority than the cross-cutting platform gaps (input, locale, a11y,
|
||||
cursor) that affect every backend including Skia on macOS/Linux.
|
||||
|
||||
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
|
||||
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
|
||||
here.
|
||||
|
||||
---
|
||||
|
||||
## Release-blocking shortlist for stable 1.12
|
||||
|
||||
The cross-cutting items to weigh first (all detailed below):
|
||||
|
||||
1. **Locale is hardcoded to `en-US`** (section E). One fix unlocks the 40+
|
||||
vendored Material 3 translations. Cheapest high-value win in this file.
|
||||
2. **No IME / text composition** (section B). Only committed Latin text works;
|
||||
CJK, dead keys, and accented input do not.
|
||||
3. **No cursor changes** (section D). The OS cursor never becomes an I-beam over
|
||||
text or a hand over clickables.
|
||||
4. **No copy/paste context menu** (section C). Keyboard copy works; there is no
|
||||
right-click or selection toolbar.
|
||||
5. **Accessibility is entirely absent** (section A). Decide whether stable
|
||||
requires any screen-reader support at all.
|
||||
6. **Standard `Font(...)` / resource-font loading is a no-op** (section F). Only
|
||||
pre-registered named fonts render. A blocker only if apps use the standard
|
||||
font APIs.
|
||||
|
||||
---
|
||||
|
||||
## A. Accessibility (entirely absent)
|
||||
|
||||
No accessibility bridge exists. A real `SemanticsOwner` is built but never
|
||||
surfaced to any OS accessibility API (no NSAccessibility / UIA / AT-SPI, no SDL
|
||||
a11y). Screen readers get nothing.
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:340` · `semanticsOwner` is instantiated but never read; no traversal into a platform a11y tree. A real a11y controller would walk the semantics tree and map roles/labels/actions to a native AT bridge. **Blocker for a11y.**
|
||||
- `compose/ui/ui/src/nativeMain/.../platform/CompositionLocals.native.kt:31` · `LocalPlatformScreenReader` default throws (`error(...)`); never provided by any window. Screen-reader-active queries are unavailable. **Blocker for a11y.**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:297` · `accessibilityManager` implements only `calculateRecommendedTimeoutMillis` (passthrough); no announce/focus/event surface. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../semantics/SemanticsRegion.native.kt:14` · `StubSemanticsRegion.intersect()`/`difference()` hardcode `false`; only `set`/`bounds` work. Region math for semantics culling is inert. **Nice-to-have.**
|
||||
|
||||
## B. Text input / IME
|
||||
|
||||
Committed text, arrow/backspace/enter editing, and mouse-drag selection work.
|
||||
Composition (preedit) does not.
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:407` · `textInputSession` awaits cancellation forever (`awaitCancellation()` at :414); no real `PlatformTextInputSession`. The modern `PlatformTextInputModifierNode` pipeline (BasicTextField2) has no IME start/stop, composition, or cursor-rect reporting. **Blocker.**
|
||||
- `compose/ui/ui/src/nativeMain/.../SDL3EventMapper.kt:126` · maps `SDL_EVENT_TEXT_INPUT` (committed) only; `SDL_EVENT_TEXT_EDITING` (preedit/composition) is never mapped, so there is no underlined composing region for dead keys / CJK. **Blocker for CJK and dead-key input.**
|
||||
- `compose/ui/ui/src/nativeMain/.../SDL3Backend.kt:111` · `SDL_StartTextInput` is called once at window init and never scoped to the focused field; `SDL_SetTextInputArea` is never called, so the IME candidate window renders at the wrong location. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/commonMain/.../text/input/NoOpPlatformTextInputService.kt:24,32` · legacy `PlatformTextInputService` `startInput`/`stopInput`/`updateState` + software-keyboard toggles are all NOP. Superseded by the modern path above. **Nice-to-have / cosmetic (desktop).**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:328` · `softwareKeyboardController.show/hide` NOP. **Cosmetic (desktop).**
|
||||
|
||||
## C. Text toolbar and context menus (copy / paste / select-all)
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:311` · `textToolbar` is a stub: `showMenu`/`hide` NOP, `status` always `Hidden`. No selection or right-click copy/paste popup anywhere. Keyboard copy still works. **Blocker for text UX.**
|
||||
- `compose/foundation/.../text/selection/SelectionActuals.native.kt:42` · `addSelectionContainerTextContextMenuComponents` NOP (upstream TODO CMP-7819). No copy/select-all menu for `SelectionContainer`. **Nice-to-have.**
|
||||
- `compose/foundation/.../text/selection/TextFieldSelectionManager.native.kt:24` and `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:36` · `addBasicTextFieldTextContextMenuComponents` NOP for both the legacy and state-based `BasicTextField`. **Nice-to-have.**
|
||||
|
||||
## D. Pointer / cursor icons
|
||||
|
||||
The OS cursor never changes. No SDL cursor API is called anywhere in the port.
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:333` · `pointerIconService.getIcon()` always returns `Default`; `setIcon()` body is empty. This is the hook `Modifier.pointerHoverIcon` drives, so hover cursor changes are swallowed. Should store the requested icon and call `SDL_SetCursor`. **Blocker for UX.**
|
||||
- `compose/ui/ui/src/nativeMain/.../input/pointer/PointerIcon.native.kt:18` · `pointerIconDefault|Crosshair|Text|Hand` are inert marker objects carrying only a name; nothing maps them to `SDL_SYSTEM_CURSOR_*`. Needs `SDL_CreateSystemCursor` + `SDL_SetCursor` (and cursor caching in the `:window` loop). **Blocker (paired with above).**
|
||||
|
||||
## E. Locale and internationalization
|
||||
|
||||
System-locale plumbing already exists in exactly one place:
|
||||
`components/resources/.../ResourceEnvironment.native.kt:38` reads
|
||||
`SDL_GetPreferredLocales()` for resource-qualifier resolution. Every other i18n
|
||||
consumer ignores it and hardcodes English. Wiring `Locale.current` to that same
|
||||
call is the flagship cheap win.
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt:57` · `DefaultLocale` is a fixed `Locale("en-US")`, so `Locale.current` can never change. This one value keys Material 3 string translation selection, stranding the 40+ vendored `material3/internal/l10n/*.kt` tables (they are present and reachable by tag, but the key is always en-US). Fix by querying `SDL_GetPreferredLocales()` (already bound in the `sdl3` cinterop). **Blocker; fixing it transitively localizes all of Material 3.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/intl/PlatformLocale.native.kt:18` · `createPlatformLocaleDelegate().current` hardcodes `LocaleList(listOf(Locale("en-US")))`; should reflect the full ordered preferred-locale list SDL returns (the resources module currently reads only `[0]`). **Blocker.**
|
||||
- `compose/material3/.../CalendarLocale.native.kt:20` · locale-agnostic stub: `toString()` fixed `"en"`, single shared instance, `equals` treats all instances as equal. A separate type from `ui.text.intl.Locale`, so it needs its own wiring so `CalendarModel`/`PlatformDateFormat` can vary by locale. **Blocker (DatePicker/TimePicker).**
|
||||
- `compose/material3/.../internal/PlatformDateFormat.native.kt` · multiple hardcodes:
|
||||
- `:27` `weekdayNames` hardcoded English full/short names (DatePicker shows English day initials regardless of locale). **Blocker.**
|
||||
- `:32` `formatWithPattern`/`formatWithSkeleton` ignore the pattern/skeleton and always emit ISO `yyyy-MM-dd`. **Blocker.**
|
||||
- `:25` `firstDayOfWeek` hardcoded `1` (Sunday); locale-dependent. **Nice-to-have.**
|
||||
- `:44` `parse()` only accepts ISO `yyyy-MM-dd`, ignoring pattern/locale. **Nice-to-have.**
|
||||
- `:62` `getDateInputFormat()` hardcoded `yyyy-MM-dd` / `'-'`. **Nice-to-have.**
|
||||
- `:65` `is24HourFormat()` hardcoded `true`. **Cosmetic.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/platform/NativeStringDelegate.native.kt:17` · `toUpperCase`/`toLowerCase` use locale-independent stdlib casing; the `locale` param is ignored (wrong for Turkish dotted/dotless i, etc.). **Cosmetic.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/intl/Locale.native.kt:17` · `Locale.isRtl()` not implemented; the pipeline is LTR-only (`SdlParagraph.native.kt:333` `getBidiRunDirection` always returns `Ltr`; owners/DrawScopes default `LayoutDirection.Ltr`). No RTL shaping. **Nice-to-have.**
|
||||
- No `NumberFormat` / currency / decimal-grouping actual exists; numbers render via `toString()` with `.` decimal and no grouping regardless of locale. **Cosmetic.**
|
||||
|
||||
**Beyond a real `getLocale`:** SDL reports language + country only (no script subtag, no calendar/clock/number preferences), and Kotlin/Native bundles no ICU. Localized month/weekday name tables, date/number/currency patterns, first-day-of-week, locale-aware casing, and RTL all still require a bundled CLDR subset or a third-party K/N i18n library. The SDL wiring is the cheap 80%; these are the expensive tail.
|
||||
|
||||
## F. Font resolution
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:32` · `SdlPlatformFontLoader.loadBlocking`/`awaitLoad` are NOP (return `Unit`); font bytes for `ResourceFont` / `Font(bytes)` / `LoadedFontFamily` are never loaded. Only pre-registered `NamedFont` names reach the renderer. **Blocker if the app uses standard `Font(...)` / resource-font APIs; otherwise nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:38` · `PlatformFontFamilyTypefaceAdapter.resolve` returns `TypefaceResult.Immutable(Unit)`; any code reading the resolved typeface gets `Unit` (the renderer bypasses this via name lookup, so usually harmless). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/commonMain/.../text/NamedFont.kt:62` · `FontFamily.projectFontName` resolves only a `FontListFontFamily` whose first font is a `NamedFont`; returns `null` for `Serif`/`SansSerif`/`Monospace`/`Cursive`/`Default`, `LoadedFontFamily`, and resource-backed families, so all of those collapse to the single default font (no serif/mono/cursive distinction). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../text/font/FontFamilyResolver.native.kt:69` · deprecated `createFontFamilyResolver(fontResourceLoader)` ignores the supplied loader. **Cosmetic.**
|
||||
|
||||
## G. Text rendering details
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../text/SdlParagraph.native.kt:376` · `drawNativeText` accepts and ignores text `Shadow`, stroke `DrawStyle`, and non-`SrcOver` blend, so those never render on text. **Cosmetic.**
|
||||
- `compose/foundation/.../text/StringHelpers.native.kt:31` and `compose/ui/ui/src/nativeMain/.../text/CharHelpers.native.kt:14` · `findPrecedingBreak`/`findFollowingBreak` (and `offsetByCodePoints`) do a codepoint/surrogate walk only, with no ICU grapheme clusters, so caret movement and backspace split combining marks and emoji ZWJ sequences. **Nice-to-have.**
|
||||
|
||||
## H. Drag and drop
|
||||
|
||||
Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:38` · `requestDragAndDropTransfer` is a NOP and `isRequestDragAndDropTransferRequired` returns `false`; `Modifier.dragAndDropSource` compiles but no OS-level drag leaves the window. Needs per-OS native work (NSDraggingSession / DoDragDrop / XDND); SDL3 has no portable start-drag. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../draganddrop/Sdl3DragAndDropOwner.kt:119` · incoming transfer carries only file paths + a text blob; no image/URL/custom-MIME payloads. **Cosmetic.**
|
||||
- `compose/foundation/.../draganddrop/DragAndDropSource.native.kt:36` · no drag-shadow / drag-image feedback for internal DnD (upstream skiko renders one). **Cosmetic.**
|
||||
|
||||
## I. Window and platform info
|
||||
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:354` · `windowInfo.containerSize`/`containerDpSize` return `IntSize.Zero`/`DpSize.Zero`; `LocalWindowInfo.containerSize` readers (popup sizing, window-size-aware layout) get 0. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:353` · `windowInfo.isWindowFocused` hardcoded `true`; not fed by SDL focus events (which the window already tracks). Focus-reactive UI will not react. **Nice-to-have.**
|
||||
- Multi-monitor: no display enumeration or per-monitor placement anywhere (`SDL_GetDisplays`/`SDL_GetDisplayBounds`/`SDL_GetDisplayForWindow` unused). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../platform/PrefetchLocals.native.kt:18` · `LocalPlatformPrefetchScheduler` default is a NOP scheduler; lazy lists skip ahead-of-time item composition (correctness fine, possible scroll-in jank). **Nice-to-have (perf).**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:324` · `autofill`/`autofillManager` are null; `requestAutofill` NOP. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:279` · `hapticFeedBack.performHapticFeedback` NOP (no desktop pointer haptics API). **Cosmetic.**
|
||||
- `compose/ui/ui/src/nativeMain/.../node/impl/ComposeOwner.kt:287` · deprecated `clipboardManager` `setText`/`getText` NOP, and `clipboard.getClipEntry` null. Harmless: the real `LocalClipboard` (text + PNG image via SDL3) works. **Cosmetic.**
|
||||
- `compose/foundation/.../text/input/internal/selection/TextFieldSelectionState.native.kt:44` · `ClipboardPasteState.hasClip` aliased to `hasText`, so image-only clipboard is not detected for the paste affordance. **Cosmetic.**
|
||||
|
||||
## J. SDL renderer graphics (Windows / `-Prenderer=sdl3` only)
|
||||
|
||||
These affect only the SDL leg. On macOS/Linux the Skia leg implements them
|
||||
correctly, and JVM is the documented Windows fidelity tier, so these are lower
|
||||
priority under G1. Listed because SDL is the shipped Windows renderer.
|
||||
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../graphics/PathEffect.sdl.kt:15` · `dash`/`corner`/`chain`/`stamped` path effects are all NOP; dashed/dotted strokes render solid. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../graphics/RenderEffect.sdl.kt:16` · `RenderEffect.isSupported()` is `false`; `Modifier.blur()` and `graphicsLayer{renderEffect=...}` do nothing. `compose/ui/ui/src/sdlRendererMain/.../graphics/shadow/Blur.native.kt:33` · `Paint.setBlurFilter` NOP. **Nice-to-have (Windows visual parity).**
|
||||
- Gradients render real ramps, but `TileMode` is dropped: `t` is clamped, so `Repeated`/`Mirror`/`Decal` all behave as `Clamp` (`Sdl3DrawScope.kt:1070` samplers; `CanvasPaintActuals.native.kt:66`). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:106` · `ActualImageShader`/`ActualCompositeShader` are stubs; image/composite `ShaderBrush` degrades to solid fill (white/black). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../graphics/ColorFilter.sdl.kt:13` · tint/color-matrix/lighting color filters are empty stubs; only `BlendModeColorFilter` tint on image blits is honored (`Sdl3Canvas.kt:1251`). ColorMatrix (grayscale/saturation) and all filters on shapes are inert. **Nice-to-have.**
|
||||
- BlendMode: only `SrcOver` (plus a `Clear` special case) composites; `Multiply`/`Screen`/`Plus`/etc. are ignored (`Sdl3DrawScope.kt:206`, `Sdl3Canvas.kt:926`). Note `BlendMode.isSupported()` misleadingly returns `true`. **Nice-to-have.**
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:436` · `saveLayer` has no offscreen buffer; layer alpha is multiplied into each primitive, so overlapping content double-composites (wrong group opacity) and the layer paint's colorFilter/blendMode/renderEffect are dropped. **Nice-to-have (blocker for correct group-alpha over overlapping content).**
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:509` · `clipPath` degrades an arbitrary path to its bounding box; rotated/sheared `clipRect` collapses to an AABB. Non-rect clips leak. (Rounded/difference clips are real, with feathered AA.) **Nice-to-have.**
|
||||
- Stroke joins (`StrokeJoin`) are never applied, so polyline corners notch; `StrokeCap.Square` falls back to butt (`Sdl3DrawScope.kt:380,342`). **Nice-to-have / cosmetic.**
|
||||
- `PathFillType` (NonZero vs EvenOdd) is mostly ignored beyond a 2-contour border case; interior holes and self-intersections are not cut out, and concave fills self-overlap (`Sdl3DrawScope.kt:366,717`). **Nice-to-have.**
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../renderer/sdl/Sdl3Canvas.kt:1276` · `drawPoints`/`drawRawPoints`/`drawVertices` are NOP; point-mode drawing and custom vertex meshes render nothing. **Nice-to-have.**
|
||||
- Images under a rotated layer are not rotated (`SDL_RenderTexture` is axis-aligned, `Sdl3Canvas.kt:1264`); stroked non-square ovals stroke as a circular ring (`Sdl3DrawScope.kt:527`); `SdlImageBitmap.readPixels` is a no-op (`Sdl3Offscreen.kt:117`). **Cosmetic / nice-to-have.**
|
||||
- Drop shadow is approximated (9-slice / stacked rings, not a gaussian blur; ambient vs spot largely collapsed) at `Sdl3Canvas.kt:785`; AA is a ~1px geometry fringe rather than analytic coverage. **Cosmetic.**
|
||||
|
||||
## K. GraphicsLayer / image
|
||||
|
||||
- `compose/ui/ui/src/sdlRendererMain/.../graphics/CanvasPaintActuals.native.kt:151` · `createImageBitmap(bytes)` throws `UnsupportedOperationException`. (`GraphicsLayer.toImageBitmap` and `snapshotBgra` do work on both renderers.) **Nice-to-have.**
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
# Tooling
|
||||
|
||||
Helper scripts and workflows used to build, vendor, and verify Compose Desktop
|
||||
Native. Each has a focused job; the deeper ones link to their own README.
|
||||
|
||||
For architecture, source-set layout, and vendoring rules, see
|
||||
[CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Native libraries
|
||||
|
||||
SDL3, SDL3_ttf, SDL3_image, and FreeType are built from source as static
|
||||
libraries and linked straight into the executable. One script does it on every
|
||||
OS. Output lands in the gitignored `libs/`; versions are pinned in
|
||||
`scripts/build-sdl/build-sdl.properties`.
|
||||
|
||||
```bash
|
||||
python3 scripts/build-sdl/build-all.py # freetype, sdl3, sdl3-image, sdl3-ttf
|
||||
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild one step
|
||||
```
|
||||
|
||||
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
|
||||
absent). Run it once per machine, or after bumping the pinned versions.
|
||||
|
||||
## Vendoring upstream Compose
|
||||
|
||||
Most `androidx.compose.*` code is copied byte for byte from
|
||||
`JetBrains/compose-multiplatform-core` into each module's gitignored
|
||||
`src/vendor/`. A `compose-fork.txt` manifest per module lists which upstream
|
||||
files it pulls; the pinned ref lives in `scripts/compose-fork/compose.properties`.
|
||||
|
||||
```bash
|
||||
scripts/compose-fork/sync.sh # re-sync every module
|
||||
scripts/compose-fork/sync.sh compose/ui/ui/compose-fork.txt # one module
|
||||
```
|
||||
|
||||
Reach for this after a fresh checkout or a ref bump. Details:
|
||||
[scripts/compose-fork/README.md](scripts/compose-fork/README.md).
|
||||
|
||||
Guardrails that keep the vendor tree honest:
|
||||
|
||||
```bash
|
||||
python3 scripts/compose-fork/check-vendor-clean.py # src/vendor matches the pinned refs
|
||||
python3 scripts/compose-fork/check-vendor-drift.py # hand-edited "manual vendors" match their pin
|
||||
```
|
||||
|
||||
`check-vendor-clean` fails if a hand-edit ever leaks into `src/vendor/`.
|
||||
`check-vendor-drift` reads the `// VENDOR-BASE:` header on every edited copy and,
|
||||
using the local upstream clone, reports whether the base actually changed since
|
||||
the pin (needs reconciling) or is merely stale (safe to re-stamp). Run both on
|
||||
every ref bump.
|
||||
|
||||
### Coverage against upstream
|
||||
|
||||
How much of upstream's public API the port covers is measured, not guessed.
|
||||
|
||||
```bash
|
||||
./gradlew apiDump && python3 scripts/compose-coverage.py # per-module coverage tables
|
||||
python3 scripts/compose-coverage.py --missing ui-text # list uncovered declarations
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### One-command gate
|
||||
|
||||
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
|
||||
macOS or Linux it exercises BOTH renderer legs (Skia by default, then
|
||||
`-Prenderer=sdl3`) and exits non-zero on any failure:
|
||||
|
||||
```bash
|
||||
scripts/verify-mac.sh
|
||||
```
|
||||
|
||||
It runs, per leg: the vendor drift and clean checks, a build of `:demo` and
|
||||
`:apidemo`, the interaction probes, the parity sweep, a memory soak, and a
|
||||
frame-time spot check. The Windows target is verified separately (see below).
|
||||
|
||||
### Parity: native vs JVM
|
||||
|
||||
`:demo` renders the same shared screens on two stacks: this port, and stock JVM
|
||||
Compose Desktop. `parity.py` screenshots every screen on both and pixel-diffs
|
||||
them, so a screen that diverges is a porting bug. It gates against per-screen
|
||||
golden baselines.
|
||||
|
||||
```bash
|
||||
python3 scripts/parity/parity.py # all screens
|
||||
python3 scripts/parity/parity.py Buttons Shapes # a subset
|
||||
python3 scripts/parity/parity.py --no-build # reuse the last renders
|
||||
```
|
||||
|
||||
How to read the diff and the percentage: [scripts/parity/README.md](scripts/parity/README.md).
|
||||
|
||||
### Interaction probe
|
||||
|
||||
`probe.py` launches a native window, sends window-relative input (click, hover,
|
||||
hold), and captures the client area. It packages the rigs used to reproduce
|
||||
interaction bugs. Windows-only. Details:
|
||||
[scripts/probe/README.md](scripts/probe/README.md).
|
||||
|
||||
### Frame profiler
|
||||
|
||||
Set `CDN_PROFILE=1` (or `CDN_PROFILE=<path>`) and run any app. Every couple of
|
||||
seconds it writes per-phase main-loop timings (events, app pump, render, with
|
||||
draw sub-phases and per-frame draw counters) to a file. `CDN_FORCERENDER=1`
|
||||
renders every frame so steady-state timings read on an otherwise idle screen.
|
||||
|
||||
```bash
|
||||
CDN_PROFILE=1 CDN_FORCERENDER=1 ./demo/build/bin/macosArm64/debugExecutable/demo.kexe
|
||||
```
|
||||
|
||||
Note: `present` is vsync-blocking, so profile on the target refresh rate before
|
||||
drawing conclusions about a frame-rate gap.
|
||||
|
||||
### Demo probes
|
||||
|
||||
`:demo` ships headless regression probes driven by CLI flags, used by the
|
||||
verify runbook and in isolation:
|
||||
|
||||
```bash
|
||||
demo.kexe --nav3test # Navigation 3 + ViewModel + lifecycle
|
||||
demo.kexe --backtest # predictive-back / BackHandler
|
||||
demo.kexe --clicktest # pointer -> upstream clickable
|
||||
demo.kexe --scrolltest # wheel -> scrollable
|
||||
demo.kexe --multiwintest # multi-window lifecycle
|
||||
demo.kexe --soaktest # memory soak (CDN_SOAK_SCREEN, CDN_SOAK_STATIC, CDN_SOAK_CYCLES)
|
||||
```
|
||||
|
||||
### Windows smoke
|
||||
|
||||
The Mac runbook cannot cover the Windows target. Before shipping, on a Windows
|
||||
host: build the mingwX64 executables, run `scripts/probe/`, and run a publish
|
||||
dry run (only Windows compiles the full common-metadata variant table).
|
||||
|
||||
```bat
|
||||
gradlew.bat :demo:runDebugExecutableMingwX64
|
||||
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||
```
|
||||
|
||||
## Consuming the port
|
||||
|
||||
To build a third-party app against the published klibs, apply the bridge Gradle
|
||||
plugin and declare official Compose Multiplatform coordinates; the plugin swaps
|
||||
in the port's klibs on native desktop targets. Setup:
|
||||
[gradle-plugin/compose-desktop-native-bridge/README.md](gradle-plugin/compose-desktop-native-bridge/README.md).
|
||||
|
||||
## Versioning and releasing
|
||||
|
||||
### Version map
|
||||
|
||||
Versions live in a few places, some of which move in lockstep. There is no
|
||||
single file to edit; know which axis you are changing.
|
||||
|
||||
| Version | Where | Notes |
|
||||
|---------|-------|-------|
|
||||
| Project release version | The git tag `vX.Y.Z`. `PUBLISH_VERSION` (from the tag) feeds `vPublishVersion` in `build.gradle.kts`, which strips the leading `v`. Group is `com.bitsycore.compose.sdl`. | Set by the tag, not edited by hand. A non-publish build is `0.0.0-SNAPSHOT`. |
|
||||
| Vendored Compose (native side) | `COMPOSE_CORE_REF` and `COMPOSE_REF` in `scripts/compose-fork/compose.properties`, plus `compose` in `gradle/libs.versions.toml`. | Pin to a durable tag (not a `+dev` commit upstream may GC). Re-sync after changing. |
|
||||
| JVM parity forcing | `vComposeJvmVersion` in `demo`, `apidemo`, and `material-symbols` `build.gradle.kts`. | Must be a version PUBLISHED to Maven Central. It may lag the vendored native ref (a documented skew) until the matching version is published. |
|
||||
| Skiko | `skiko` in `gradle/libs.versions.toml`. | Must expose the `org.jetbrains.skiko.node` `RenderNode` / `GraphicsContext` API the vendored compose-core uses. Verify with a throwaway `skikoRendererMain` compile if unsure. |
|
||||
| SDL3 / SDL3_ttf / SDL3_image / FreeType | `scripts/build-sdl/build-sdl.properties`. | Rebuild `libs/` with `build-all.py` after any change. |
|
||||
| Bridge substituted version | Defaults to the bridge plugin's own published version; consumers override with the `composeDesktopNative.version` Gradle property. | The plugin publishes with the release tag, so a consumer on the matching plugin version resolves the right klibs automatically. |
|
||||
|
||||
### Bump the upstream Compose ref
|
||||
|
||||
Run this on each upstream bump; it is the flow that keeps the sync tax low.
|
||||
|
||||
1. Edit `COMPOSE_CORE_REF` / `COMPOSE_REF` in `compose.properties` (and `compose`
|
||||
in `libs.versions.toml` if the coordinate version moved).
|
||||
2. `scripts/compose-fork/sync.sh`
|
||||
3. `python3 scripts/compose-fork/check-vendor-drift.py`. For any manual vendor
|
||||
whose upstream base actually changed, reconcile by hand; otherwise re-stamp
|
||||
its `// VENDOR-BASE:` header to the new ref.
|
||||
4. Build both legs and fix any fallout.
|
||||
5. `scripts/verify-mac.sh` (both legs green, including the soak and parity gates).
|
||||
6. If a matching Compose version is now published to Maven, bump
|
||||
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
|
||||
7. Run WIN-SMOKE on a Windows host.
|
||||
8. Re-seed parity baselines only if metrics legitimately moved
|
||||
(`parity.py --update-baselines`), and record why.
|
||||
|
||||
### Cut a release
|
||||
|
||||
1. Complete the ref-bump flow above and confirm it is green. For a STABLE
|
||||
release, `vComposeJvmVersion` should match the vendored ref (no skew).
|
||||
2. On a Windows host, run `gradlew :window:compileCommonMainKotlinMetadata`.
|
||||
Only Windows compiles the full common-metadata variant table, and the root
|
||||
KotlinMultiplatform publications live there; a macOS-only publish leaves the
|
||||
roots without mingwX64 variants (this bit v0.1.15).
|
||||
3. `git tag vX.Y.Z && git push origin vX.Y.Z`. This triggers `.github/workflows/publish.yml`.
|
||||
|
||||
What the publish job does, so you can read a failure:
|
||||
|
||||
- Each host publishes only its own target's publications to GitHub Packages
|
||||
under `com.bitsycore.compose.sdl:*`. Windows additionally publishes the root
|
||||
KotlinMultiplatform metadata, the `jvm` publication, and the bridge plugin
|
||||
(only Windows declares every target).
|
||||
- The `MODULES` list in the workflow must stay in sync with the modules in
|
||||
`settings.gradle.kts`.
|
||||
- A partial publish (for example a dropped connection mid-upload) deletes this
|
||||
host's half-published versions for the tag and retries, so a rerun is safe.
|
||||
- The demo and apidemo release binaries are zipped and attached to the GitHub
|
||||
Release for the tag.
|
||||
|
||||
4. Verify a consumer build against the new version resolves through the bridge.
|
||||
@@ -20,8 +20,8 @@ val vHostSupportsMingw: Boolean by rootProject.extra
|
||||
// COMPOSE_CORE_REF (see scripts/compose-fork/compose.properties); mirrors
|
||||
// :demo's forcing (Gradle orders "+dev" BELOW the plain version, so the
|
||||
// plugin's beta01 would win conflict resolution without it).
|
||||
val vComposeJvmVersion = "1.12.0-beta01+dev4324"
|
||||
val vComposeM3JvmVersion = "1.12.0-alpha03+dev4324"
|
||||
val vComposeJvmVersion = libs.versions.compose.get()
|
||||
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
|
||||
val vComposeJvmForced = mapOf(
|
||||
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
|
||||
"org.jetbrains.compose.ui" to vComposeJvmVersion,
|
||||
@@ -106,7 +106,7 @@ kotlin {
|
||||
// invisible to the granular-metadata visibility check. The
|
||||
// runtime is the official klib everywhere, never substituted
|
||||
// (jvm configs force the +dev build above).
|
||||
implementation("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
|
||||
|
||||
+2
@@ -16,6 +16,8 @@ package org.jetbrains.compose.resources.vector.xmldom
|
||||
CDATA skipping, and the five predefined entities plus numeric character
|
||||
references. No DTD expansion, no processing-instruction handling beyond
|
||||
skipping — malformed input throws MalformedXMLException like upstream. */
|
||||
// VENDOR-BASE(COMPOSE_REF): components/resources/library/src/nativeMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/DomXmlParser.kt @ v1.12.0-beta03+dev4483
|
||||
// (fresh REIMPL, not a copy-edit — the base ref marks the upstream API it tracks)
|
||||
|
||||
internal fun parse(xml: String): Element {
|
||||
val vParser = XmlDomParser(xml)
|
||||
|
||||
+1
@@ -18,6 +18,7 @@
|
||||
|
||||
// MANUAL VENDOR of upstream compose/foundation/foundation/src/commonMain/kotlin/
|
||||
// androidx/compose/foundation/text/selection/SelectionLayout.kt.
|
||||
// VENDOR-BASE: compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/selection/SelectionLayout.kt @ v1.12.0-beta03+dev4483
|
||||
//
|
||||
// Change vs upstream: MultiSelectionLayout.crossStatus and slotToIndex clamp their
|
||||
// infoList index to the valid range. Upstream assumes that if the drag position never
|
||||
|
||||
@@ -38,6 +38,7 @@ import sdl3.SDL_Delay
|
||||
import sdl3.SDL_GetPerformanceCounter
|
||||
import sdl3.SDL_GetPerformanceFrequency
|
||||
import sdl3.SDL_GetTicks
|
||||
import sdl3.SDL_GetTicksNS
|
||||
import sdl3.SDL_Quit
|
||||
import sdl3.SDL_SetWindowTitle
|
||||
import sdl3.SDL_WaitEventTimeout
|
||||
@@ -144,7 +145,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
|
||||
appComposition.setContent { appScope.content() }
|
||||
// Compose the initial Window() declarations before entering the loop.
|
||||
appClock.sendFrame()
|
||||
appClock.sendFrame(frameClockNanos())
|
||||
yield()
|
||||
|
||||
// ============
|
||||
@@ -153,6 +154,9 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
var vRenderedSinceGc = false
|
||||
while (!runtime.exitRequested) {
|
||||
FrameProfiler.mark()
|
||||
// One virtual 16.6ms tick per loop iteration (no-op unless useVirtualFrameTime) —
|
||||
// all windows' clocks share the same timestamp this iteration.
|
||||
advanceVirtualFrame()
|
||||
Snapshot.sendApplyNotifications()
|
||||
|
||||
// ============
|
||||
@@ -185,7 +189,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
// ============
|
||||
// App composition pump — Window()s may appear / disappear here.
|
||||
Snapshot.sendApplyNotifications()
|
||||
appClock.sendFrame()
|
||||
appClock.sendFrame(frameClockNanos())
|
||||
yield()
|
||||
runtime.reapDestroyed()
|
||||
|
||||
@@ -209,10 +213,10 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
val vAppPending = appRecomposer.hasPendingWork
|
||||
for (vW in runtime.windows.toList()) {
|
||||
vW.installGlobals()
|
||||
vW.host.sendAnimationFrame(SDL_GetTicks().toLong() * 1_000_000L)
|
||||
vW.host.sendAnimationFrame(animationClockNanos())
|
||||
mainDispatcher.drainPending()
|
||||
Snapshot.sendApplyNotifications()
|
||||
vW.frameClock.sendFrame()
|
||||
vW.frameClock.sendFrame(frameClockNanos())
|
||||
yield()
|
||||
FrameProfiler.phase("pump")
|
||||
if (vW.shouldRender()) {
|
||||
@@ -352,6 +356,57 @@ internal object FrameProfiler {
|
||||
their native memory (see the main loop's native-memory nudge). */
|
||||
private val kForceRender: Boolean = platform.posix.getenv("CDN_FORCERENDER") != null
|
||||
|
||||
// ==================
|
||||
// MARK: Probe / screenshot support (render-to-quiescence)
|
||||
// ==================
|
||||
|
||||
/* Set BEFORE nativeComposeApp/nativeComposeWindow: every window's composition then runs
|
||||
under an InfiniteAnimationPolicy that CANCELS infinite animations, so
|
||||
rememberInfiniteTransition & co. freeze at their initial value — the same mechanism
|
||||
upstream's test rules use. Screenshot/parity runs enable it so looping screens can
|
||||
reach quiescence and capture deterministically. */
|
||||
var disableInfiniteAnimations: Boolean = false
|
||||
|
||||
/* The cancelling policy: a coroutine that ends in CancellationException counts as
|
||||
cancelled (not failed), so only the animation coroutine stops — nothing propagates. */
|
||||
private object CancelInfiniteAnimationsPolicy : androidx.compose.ui.platform.InfiniteAnimationPolicy {
|
||||
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
|
||||
throw CancellationException("infinite animations are disabled (disableInfiniteAnimations)")
|
||||
}
|
||||
|
||||
/* Set BEFORE nativeComposeApp/nativeComposeWindow: the composition + animation frame
|
||||
clocks advance a VIRTUAL 16.6ms per main-loop iteration instead of reading SDL's
|
||||
real-time ticks — the native mirror of the JVM parity leg's render(nanos) stepping.
|
||||
Animations then progress by exact per-frame deltas, so anything time-raced (e.g. a
|
||||
bring-into-view scroll interrupted mid-flight) resolves identically on every run and
|
||||
screenshots become deterministic. Input timestamps and FPS stay on real time. */
|
||||
var useVirtualFrameTime: Boolean = false
|
||||
|
||||
private var virtualFrameNanos = 0L
|
||||
|
||||
private fun advanceVirtualFrame() {
|
||||
if (useVirtualFrameTime) virtualFrameNanos += 16_666_667L
|
||||
}
|
||||
|
||||
// Timestamp for the composition frame clocks (recomposer + withFrameNanos animations).
|
||||
private fun frameClockNanos(): Long =
|
||||
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicksNS().toLong()
|
||||
|
||||
// Timestamp for the owner's node-animation clock — real path keeps the pre-existing
|
||||
// ms-resolution SDL_GetTicks base so non-screenshot behaviour is bit-for-bit unchanged.
|
||||
private fun animationClockNanos(): Long =
|
||||
if (useVirtualFrameTime) virtualFrameNanos else SDL_GetTicks().toLong() * 1_000_000L
|
||||
|
||||
// The window currently inside renderFrame — the loop is single-threaded, so a plain
|
||||
// var is enough for onFrame probes to address "the window I'm being called for".
|
||||
private var renderingWindow: WindowInstance? = null
|
||||
|
||||
/* From inside an onFrame callback: true while the just-rendered window still has pending
|
||||
work (recomposition, layout/draw invalidation, or an animation awaiting the next frame).
|
||||
Screenshot probes capture once this stays false for a few consecutive frames instead of
|
||||
at a fixed frame count. Outside onFrame it answers false. */
|
||||
fun windowHasInvalidations(): Boolean = renderingWindow?.hasInvalidations() ?: false
|
||||
|
||||
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class)
|
||||
private fun collectNativeGarbage() = kotlin.native.runtime.GC.collect()
|
||||
|
||||
@@ -490,8 +545,9 @@ internal class WindowInstance(
|
||||
private var hasMousePos = false
|
||||
|
||||
private var frameIndex = 0
|
||||
private var fpsFrames = 0
|
||||
private var fpsLastMs = SDL_GetTicks()
|
||||
private var fpsEma = 0.0
|
||||
private var fpsLastFrameMs = SDL_GetTicks()
|
||||
private var fpsLastTitleMs = 0uL
|
||||
|
||||
// Escape → back (upstream desktop's BackNavigationEventInput): unconsumed
|
||||
// Escape completes a back navigation on THIS window's dispatcher.
|
||||
@@ -545,6 +601,10 @@ internal class WindowInstance(
|
||||
|
||||
host = ComposeRootHost(inDensity = backend.pixelDensity)
|
||||
host.attach()
|
||||
// A layer whose content changed (OwnedLayer.invalidate) schedules a frame
|
||||
// even when nothing else (recompose / relayout) is pending — retained layers
|
||||
// need this so a draw-only state change still repaints.
|
||||
host.setInvalidationCallback { needsFrame = true }
|
||||
facade = ComposeNativeWindow(backend, gpuMode, initialTitle)
|
||||
navigationEventOwner.navigationEventDispatcher.addInput(backNavigationInput)
|
||||
|
||||
@@ -553,7 +613,11 @@ internal class WindowInstance(
|
||||
// setContent below).
|
||||
installGlobals()
|
||||
|
||||
val vRecomposer = Recomposer(inScope.coroutineContext + frameClock)
|
||||
// Effect context for this window's composition — the screenshot flag injects the
|
||||
// infinite-animation-cancelling policy (see disableInfiniteAnimations above).
|
||||
var vEffectContext = inScope.coroutineContext + frameClock
|
||||
if (disableInfiniteAnimations) vEffectContext += CancelInfiniteAnimationsPolicy
|
||||
val vRecomposer = Recomposer(vEffectContext)
|
||||
recomposer = vRecomposer
|
||||
recomposeJob = inScope.launch(frameClock) { vRecomposer.runRecomposeAndApplyChanges() }
|
||||
val vComposition = Composition(host.applier, vRecomposer)
|
||||
@@ -754,6 +818,14 @@ internal class WindowInstance(
|
||||
fun shouldRender(): Boolean =
|
||||
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null || kForceRender
|
||||
|
||||
/* True while this window still has pending work: a state/layout/draw invalidation
|
||||
(needsFrame), recomposer work, or a composition/node animation awaiting the next
|
||||
frame. Sampled by windowHasInvalidations() from onFrame probes — the quiescence
|
||||
signal for render-to-settle screenshot capture. */
|
||||
fun hasInvalidations(): Boolean =
|
||||
needsFrame || recomposer?.hasPendingWork == true ||
|
||||
frameClock.hasAwaiters || host.hasAnimationAwaiters()
|
||||
|
||||
// TEMP measurement: CDN_FORCERENDER=1 forces every frame to render so
|
||||
// sustained steady-state timings can be measured on otherwise-idle screens.
|
||||
|
||||
@@ -774,32 +846,48 @@ internal class WindowInstance(
|
||||
}
|
||||
|
||||
vRender.beginFrame(1f)
|
||||
vRender.drawRoot(host)
|
||||
if (onFrame != null && !onFrame.invoke(vRender, frameIndex)) {
|
||||
vRender.drawRoot { canvas -> host.drawRoot(canvas) }
|
||||
if (onFrame != null) {
|
||||
renderingWindow = this
|
||||
val vContinue = onFrame.invoke(vRender, frameIndex)
|
||||
renderingWindow = null
|
||||
// Probe consumers end the app when their scenario completes.
|
||||
facade.close()
|
||||
if (!vContinue) facade.close()
|
||||
}
|
||||
FrameProfiler.phase(" draw")
|
||||
vRender.endFrame()
|
||||
FrameProfiler.phase(" present")
|
||||
frameIndex++
|
||||
|
||||
// FPS — refreshed ~once a second, per window.
|
||||
fpsFrames++
|
||||
// FPS — instantaneous inter-frame rate, EMA-smoothed, title refreshed
|
||||
// ~4x/sec. This shows within ~2 rendered frames of ANY activity rather
|
||||
// than waiting for a full second of unbroken rendering to accumulate
|
||||
// (the old fixed-window counter never got its first update: bursty
|
||||
// interaction kept idling before 1s elapsed, and the idle-skip reset the
|
||||
// window each time — so FPS only appeared during a long enough page
|
||||
// transition). A dt outside 1..100ms is the first frame or a resume from
|
||||
// idle (the gap isn't a real frame interval), so it's not sampled — the
|
||||
// title just holds the last active rate while idle.
|
||||
val vNowMs = SDL_GetTicks()
|
||||
val vElapsed = (vNowMs - fpsLastMs).toInt()
|
||||
if (vElapsed >= 1000) {
|
||||
val vFps = fpsFrames * 1000 / vElapsed
|
||||
facade.updateFps(vFps)
|
||||
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
|
||||
fpsFrames = 0
|
||||
fpsLastMs = vNowMs
|
||||
val vDt = (vNowMs - fpsLastFrameMs).toInt()
|
||||
fpsLastFrameMs = vNowMs
|
||||
if (vDt in 1..100) {
|
||||
val vInst = 1000.0 / vDt
|
||||
fpsEma = if (fpsEma <= 0.0) vInst else fpsEma * 0.9 + vInst * 0.1
|
||||
if ((vNowMs - fpsLastTitleMs).toInt() >= 250) {
|
||||
val vFps = (fpsEma + 0.5).toInt()
|
||||
facade.updateFps(vFps)
|
||||
SDL_SetWindowTitle(backend.window?.reinterpret(), "${facade.title} · $vFps FPS")
|
||||
fpsLastTitleMs = vNowMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idle-skip calls this: drop the frame timer so the resume-from-idle frame's
|
||||
// dt (the whole idle gap) isn't sampled as a real interval. The EMA is left
|
||||
// intact so the title keeps showing the last active rate while idle.
|
||||
fun resetFpsWindow() {
|
||||
fpsFrames = 0
|
||||
fpsLastMs = SDL_GetTicks()
|
||||
fpsLastFrameMs = SDL_GetTicks()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
|
||||
@@ -26,7 +26,12 @@ internal class SDL3FrameClock : MonotonicFrameClock {
|
||||
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R =
|
||||
broadcast.withFrameNanos(onFrame)
|
||||
|
||||
fun sendFrame() {
|
||||
broadcast.sendFrame(SDL_GetTicksNS().toLong())
|
||||
fun sendFrame(inNanos: Long = SDL_GetTicksNS().toLong()) {
|
||||
broadcast.sendFrame(inNanos)
|
||||
}
|
||||
|
||||
// Coroutines (recomposer / composition animations) currently suspended in withFrameNanos
|
||||
// awaiting the next sendFrame — the "an animation is still running" half of the window's
|
||||
// quiescence signal (see WindowInstance.hasInvalidations).
|
||||
val hasAwaiters: Boolean get() = broadcast.hasAwaiters
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
api("androidx.navigationevent:navigationevent-compose:1.1.2")
|
||||
api(project(":ui-util"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
@@ -13,7 +13,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
api(project(":ui-util"))
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
|
||||
@@ -20,7 +20,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
api(project(":ui-geometry"))
|
||||
api(project(":ui-util"))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
api("androidx.collection:collection:1.5.0")
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
|
||||
@@ -156,9 +156,9 @@ kotlin {
|
||||
api(project(":ui-geometry"))
|
||||
api(project(":ui-unit"))
|
||||
api(project(":ui-backhandler"))
|
||||
api("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime-saveable:1.11.1")
|
||||
api("androidx.compose.runtime:runtime-retain:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
api("org.jetbrains.compose.runtime:runtime-saveable:${libs.versions.composeRuntime.get()}")
|
||||
api("androidx.compose.runtime:runtime-retain:${libs.versions.composeRuntime.get()}")
|
||||
api("androidx.navigationevent:navigationevent-compose:1.1.2")
|
||||
api("androidx.savedstate:savedstate:1.5.0")
|
||||
api("androidx.savedstate:savedstate-compose:1.5.0")
|
||||
|
||||
@@ -514,14 +514,14 @@ skikoMain/kotlin/ -> src/vendor/skikoRenderer/kotlin/
|
||||
# | skikoMain/kotlin/androidx/compose/ui/graphics/SkiaVertexMode.skiko.kt -> src/vendor/skikoRenderer/kotlin/androidx/compose/ui/graphics/SkiaVertexMode.skiko.kt
|
||||
# | skikoMain/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt -> src/vendor/skikoRenderer/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt
|
||||
# | skikoMain/kotlin/androidx/compose/ui/graphics/shadow/Blur.skiko.kt -> src/vendor/skikoRenderer/kotlin/androidx/compose/ui/graphics/shadow/Blur.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/ImageBitmap.skiko.kt
|
||||
# B6.1 (2026-07-16): un-refused — skiko leg adopts upstream graphics actuals.
|
||||
# SkiaBackedCanvas is then MANUAL-VENDORED (moved to src/skikoRendererMain, re-refused
|
||||
# below) to also implement the port Native* draw contracts. See RENDERER_TASKS.md B6.1.
|
||||
# B6.2: un-refused — skiko leg adopts upstream GraphicsLayer/GraphicsContext (real
|
||||
# skiko RenderNode) now the canvas is SkiaBackedCanvas. See RENDERER_TASKS.md B6.2.
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaBackedCanvas.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaBackedPaint.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaGraphicsContext.skiko.kt
|
||||
# B6.1: MANUAL-VENDORED (flattened toBitmap/putBytesInto expect-actuals — see the file).
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaImageAsset.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/SkiaShader.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt
|
||||
!skikoMain/kotlin/androidx/compose/ui/graphics/shadow/Blur.skiko.kt
|
||||
|
||||
SET_FOLDER=compose/ui/ui-text/src
|
||||
|
||||
@@ -757,11 +757,13 @@ skikoMain/kotlin/androidx/compose/ui/text/style/TextMotion.skiko.kt -> src/vendo
|
||||
# | androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt -> src/vendor/android/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
|
||||
# | androidMain/kotlin/androidx/compose/ui/window/AndroidPopup.android.kt -> src/vendor/android/kotlin/androidx/compose/ui/window/AndroidPopup.android.kt
|
||||
# | androidMain/kotlin/androidx/compose/ui/window/SecureFlagPolicy.android.kt -> src/vendor/android/kotlin/androidx/compose/ui/window/SecureFlagPolicy.android.kt
|
||||
# desktopMain (173)
|
||||
# desktopMain (175)
|
||||
# | desktopMain/kotlin/androidx/compose/ui/Actuals.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/Actuals.desktop.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/ComposeDesktopEntryPoint.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ComposeDesktopEntryPoint.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/ComposeFeatureFlags.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ComposeFeatureFlags.desktop.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/ConfigureSwingGlobalsForCompose.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ConfigureSwingGlobalsForCompose.desktop.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/DesktopComposeUiFlags.skiko.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/DesktopComposeUiFlags.skiko.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/ProvideSystemTheme.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/ProvideSystemTheme.desktop.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/awt/AwtDimension.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/awt/AwtDimension.desktop.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/awt/AwtEventFilter.desktop.kt
|
||||
# | desktopMain/kotlin/androidx/compose/ui/awt/AwtEvents.desktop.kt -> src/vendor/desktop/kotlin/androidx/compose/ui/awt/AwtEvents.desktop.kt
|
||||
@@ -1092,7 +1094,7 @@ skikoMain/kotlin/androidx/compose/ui/text/style/TextMotion.skiko.kt -> src/vendo
|
||||
# | wasmJsMain/kotlin/androidx/compose/ui/Actuals.wasm.kt -> src/vendor/wasmJs/kotlin/androidx/compose/ui/Actuals.wasm.kt
|
||||
# | wasmJsMain/kotlin/androidx/compose/ui/internal/System.wasm.kt -> src/vendor/wasmJs/kotlin/androidx/compose/ui/internal/System.wasm.kt
|
||||
# | wasmJsMain/kotlin/androidx/compose/ui/platform/PlatformClipboard.wasm.kt -> src/vendor/wasmJs/kotlin/androidx/compose/ui/platform/PlatformClipboard.wasm.kt
|
||||
# webMain (39)
|
||||
# webMain (40)
|
||||
# | webMain/kotlin/androidx/compose/ui/dom/Events.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/dom/Events.web.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/draganddrop/DragAndDrop.web.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/draganddrop/DragAndDropTransferData.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/draganddrop/DragAndDropTransferData.web.kt
|
||||
@@ -1129,6 +1131,7 @@ skikoMain/kotlin/androidx/compose/ui/text/style/TextMotion.skiko.kt -> src/vendo
|
||||
# | webMain/kotlin/androidx/compose/ui/window/ComposeViewportConfiguration.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/ComposeViewportConfiguration.web.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/window/RootScrollObserver.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/RootScrollObserver.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/SystemThemeObserver.web.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/window/WebCompositionLocals.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/WebCompositionLocals.kt
|
||||
# | webMain/kotlin/androidx/compose/ui/window/WebTextInputSession.kt -> src/vendor/web/kotlin/androidx/compose/ui/window/WebTextInputSession.kt
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* Line-for-line kept in sync; the entry in compose/ui/compose-fork.txt is
|
||||
* commented out so this file survives `scripts/compose-fork/sync.sh`.
|
||||
*/
|
||||
// VENDOR-BASE: compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/Synchronization.kt @ v1.12.0-beta03+dev4483
|
||||
|
||||
package androidx.compose.ui.platform
|
||||
|
||||
|
||||
+1
@@ -6,6 +6,7 @@
|
||||
* compose/ui/compose-fork.txt is commented out so this file survives
|
||||
* `scripts/compose-fork/sync.sh`.
|
||||
*/
|
||||
// VENDOR-BASE: compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/platform/Synchronization.kt @ v1.12.0-beta03+dev4483
|
||||
|
||||
package androidx.compose.ui.text.platform
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
|
||||
// ==================
|
||||
// MARK: GraphicsLayer factory (project bridge)
|
||||
// ==================
|
||||
|
||||
/* The vendored `expect class GraphicsLayer` exposes neither a constructor nor
|
||||
the isReleased setter to common code — upstream owners create layers from
|
||||
platform source sets. ComposeOwner lives in commonMain, so these two hops
|
||||
let its GraphicsContext create and release the project record/replay
|
||||
actual (GraphicsLayer.native.kt). */
|
||||
|
||||
internal expect fun createProjectGraphicsLayer(): GraphicsLayer
|
||||
|
||||
internal expect fun releaseProjectGraphicsLayer(inLayer: GraphicsLayer)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
// ==================
|
||||
// MARK: NativeFinishableCanvas
|
||||
// ==================
|
||||
|
||||
/*
|
||||
A renderer canvas that must be flushed once drawing into it is done — SDL batches
|
||||
geometry and only submits on flush; an offscreen SDL canvas must flush before its
|
||||
backing texture is read. Implemented by Sdl3Canvas (real flush) and SkiaCanvas
|
||||
(no-op — Skia save/restore is balanced per call). Lets renderer-agnostic code
|
||||
(e.g. GraphicsLayer.toImageBitmap) commit an offscreen render without knowing the
|
||||
concrete canvas type.
|
||||
*/
|
||||
interface NativeFinishableCanvas {
|
||||
fun finish()
|
||||
}
|
||||
@@ -44,6 +44,14 @@ interface NativeTextCanvas {
|
||||
// (underline / line-through). Per-run overrides ride in via inSpans.
|
||||
inBaseItalic: Boolean = false,
|
||||
inTextDecoration: TextDecoration? = null,
|
||||
// The paragraph's per-line band height in px (TextStyle.lineHeight when the
|
||||
// style specifies one — SdlParagraph passes it so glyph rows stack exactly
|
||||
// where layout put the line boxes). <= 0 → derive from font metrics (legacy
|
||||
// behaviour; icon callers and TextDrawNode don't pass it).
|
||||
inLineHeightPx: Float = 0f,
|
||||
// Compat-trim (raw styles): the FIRST line keeps the tight font cell. False
|
||||
// for M3's LineHeightStyle(Trim.None) — every line is the full band.
|
||||
inTrimFirstLine: Boolean = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
package androidx.compose.ui.graphics.layer
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.RenderEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
// ==================
|
||||
// MARK: GraphicsLayer — native no-op actual
|
||||
// ==================
|
||||
//
|
||||
// Upstream's skiko actual (SkiaGraphicsLayer.skiko.kt, 513L) uses Skia's
|
||||
// Picture/Canvas API for record + replay. We don't have skiko on the
|
||||
// SDL3 path and the Skia path doesn't route through GraphicsLayer yet
|
||||
// — the renderer's per-node draw does its own composition. So this
|
||||
// actual is a plain field bag with no rendering behavior: setters store
|
||||
// their values, getters return them, record/draw/toImageBitmap are
|
||||
// no-ops. Nothing in our pipeline constructs a GraphicsLayer yet.
|
||||
|
||||
@Suppress("PropertyName", "unused")
|
||||
actual class GraphicsLayer {
|
||||
|
||||
actual var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
|
||||
actual var topLeft: IntOffset = IntOffset.Zero
|
||||
actual var size: IntSize = IntSize.Zero
|
||||
actual var pivotOffset: Offset = Offset.Unspecified
|
||||
actual var alpha: Float = 1f
|
||||
actual var scaleX: Float = 1f
|
||||
actual var scaleY: Float = 1f
|
||||
actual var translationX: Float = 0f
|
||||
actual var translationY: Float = 0f
|
||||
actual var shadowElevation: Float = 0f
|
||||
actual var ambientShadowColor: Color = Color.Black
|
||||
actual var spotShadowColor: Color = Color.Black
|
||||
actual var blendMode: BlendMode = BlendMode.SrcOver
|
||||
actual var colorFilter: ColorFilter? = null
|
||||
|
||||
private var outlineField: Outline = Outline.Rectangle(androidx.compose.ui.geometry.Rect.Zero)
|
||||
actual val outline: Outline get() = outlineField
|
||||
|
||||
actual fun setOutsets(left: Int, top: Int, right: Int, bottom: Int) { /* no-op */ }
|
||||
actual fun setPathOutline(path: Path) { outlineField = Outline.Generic(path) }
|
||||
actual fun setRoundRectOutline(topLeft: Offset, size: Size, cornerRadius: Float) {
|
||||
outlineField = Outline.Rounded(
|
||||
androidx.compose.ui.geometry.RoundRect(
|
||||
left = topLeft.x, top = topLeft.y,
|
||||
right = topLeft.x + size.width, bottom = topLeft.y + size.height,
|
||||
cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius),
|
||||
)
|
||||
)
|
||||
}
|
||||
actual fun setRectOutline(topLeft: Offset, size: Size) {
|
||||
val effTopLeft = if (topLeft == Offset.Unspecified) Offset.Zero else topLeft
|
||||
val effSize = if (size == Size.Unspecified) this.size.let { Size(it.width.toFloat(), it.height.toFloat()) } else size
|
||||
outlineField = Outline.Rectangle(
|
||||
androidx.compose.ui.geometry.Rect(
|
||||
effTopLeft.x, effTopLeft.y,
|
||||
effTopLeft.x + effSize.width, effTopLeft.y + effSize.height,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
actual var rotationX: Float = 0f
|
||||
actual var rotationY: Float = 0f
|
||||
actual var rotationZ: Float = 0f
|
||||
actual var cameraDistance: Float = DefaultCameraDistance
|
||||
actual var clip: Boolean = false
|
||||
actual var renderEffect: RenderEffect? = null
|
||||
actual var isReleased: Boolean = false
|
||||
internal set
|
||||
|
||||
// ============
|
||||
// Record / draw — lambda replay
|
||||
//
|
||||
// Upstream's skiko actual records into a Skia Picture. This renderer is
|
||||
// immediate-mode, so record() stores the draw lambda and draw() REPLAYS it
|
||||
// with the layer's transform / alpha / clip applied. Same visual result as
|
||||
// a display list as long as callers re-record on invalidation (they do —
|
||||
// that's the GraphicsLayer contract). This is what makes GraphicsContext
|
||||
// consumers render: SharedTransitionLayout's overlay, rememberGraphicsLayer.
|
||||
|
||||
private var recordedBlock: (DrawScope.() -> Unit)? = null
|
||||
private var recordedDensity: Density = Density(1f)
|
||||
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
|
||||
private val drawScope = androidx.compose.ui.graphics.drawscope.CanvasDrawScope()
|
||||
|
||||
actual fun record(
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
size: IntSize,
|
||||
block: DrawScope.() -> Unit,
|
||||
) {
|
||||
recordedDensity = density
|
||||
recordedLayoutDirection = layoutDirection
|
||||
this.size = size
|
||||
recordedBlock = block
|
||||
}
|
||||
|
||||
actual suspend fun toImageBitmap(): ImageBitmap =
|
||||
throw NotImplementedError("GraphicsLayer.toImageBitmap not implemented on desktop")
|
||||
|
||||
internal actual fun draw(canvas: Canvas, parentLayer: GraphicsLayer?) {
|
||||
if (isReleased) return
|
||||
val vBlock = recordedBlock ?: return
|
||||
canvas.save()
|
||||
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
|
||||
// Transform about the pivot (defaults to the layer centre).
|
||||
if (scaleX != 1f || scaleY != 1f || rotationZ != 0f || translationX != 0f || translationY != 0f) {
|
||||
val vPivot =
|
||||
if (pivotOffset == Offset.Unspecified) Offset(size.width / 2f, size.height / 2f)
|
||||
else pivotOffset
|
||||
canvas.translate(translationX + vPivot.x, translationY + vPivot.y)
|
||||
if (scaleX != 1f || scaleY != 1f) canvas.scale(scaleX, scaleY)
|
||||
if (rotationZ != 0f) canvas.rotate(rotationZ)
|
||||
canvas.translate(-vPivot.x, -vPivot.y)
|
||||
}
|
||||
if (clip) {
|
||||
// Rect/rounded outlines clip to their bounds (SDL clips are rects;
|
||||
// rounded-corner exactness would need the offscreen mask path);
|
||||
// generic outlines go through clipPath (bbox fallback on SDL).
|
||||
when (val vOutline = outline) {
|
||||
is Outline.Rectangle -> canvas.clipRect(vOutline.rect)
|
||||
is Outline.Rounded -> canvas.clipRect(
|
||||
androidx.compose.ui.geometry.Rect(
|
||||
vOutline.roundRect.left, vOutline.roundRect.top,
|
||||
vOutline.roundRect.right, vOutline.roundRect.bottom,
|
||||
),
|
||||
)
|
||||
is Outline.Generic -> canvas.clipPath(vOutline.path)
|
||||
}
|
||||
}
|
||||
val vNeedsAlphaLayer = alpha < 1f
|
||||
if (vNeedsAlphaLayer) {
|
||||
canvas.saveLayer(
|
||||
androidx.compose.ui.geometry.Rect(0f, 0f, size.width.toFloat(), size.height.toFloat()),
|
||||
androidx.compose.ui.graphics.Paint().apply { this.alpha = this@GraphicsLayer.alpha },
|
||||
)
|
||||
}
|
||||
drawScope.draw(
|
||||
recordedDensity,
|
||||
recordedLayoutDirection,
|
||||
canvas,
|
||||
Size(size.width.toFloat(), size.height.toFloat()),
|
||||
vBlock,
|
||||
)
|
||||
if (vNeedsAlphaLayer) canvas.restore()
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* Copyright 2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// MANUALLY VENDORED (NON-IDEMPOTENT) from compose-multiplatform-core:
|
||||
// compose/ui/ui/src/skikoMain/.../node/GraphicsLayerOwnerLayer.skiko.kt
|
||||
// VENDOR-BASE: compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/GraphicsLayerOwnerLayer.skiko.kt @ v1.12.0-beta03+dev4483
|
||||
// Change vs upstream: dropped the trailing `SkiaGraphicsContext.setLightingInfo`
|
||||
// extension + LIGHT_* constants (skiko-only shadow lighting; our shadows go via
|
||||
// NativeShadowCanvas). The class body is unchanged — it is the real OwnedLayer
|
||||
// (dirty-gated record/replay over a per-renderer GraphicsLayer), replacing the
|
||||
// immediate-mode ProjectOwnedLayer. `!`-refused in compose-fork.txt; reconcile
|
||||
// upstream changes by hand (run scripts/compose-fork/check-vendor-drift.py at each
|
||||
// COMPOSE_CORE_REF bump). See RENDERER.md Appendix A.
|
||||
package androidx.compose.ui.node
|
||||
|
||||
import androidx.compose.ui.FrameRateCategory
|
||||
import androidx.compose.ui.geometry.MutableRect
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.isUnspecified
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.Fields
|
||||
import androidx.compose.ui.graphics.GraphicsContext
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.ReusableGraphicsLayerScope
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.isIdentity
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.layer.setOutline
|
||||
import com.compose.sdl.graphics.prepareLayerTransformationMatrix
|
||||
import androidx.compose.ui.internal.checkPreconditionNotNull
|
||||
import androidx.compose.ui.internal.requirePrecondition
|
||||
import androidx.compose.ui.platform.invertTo
|
||||
import androidx.compose.ui.platform.isInOutline
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
/**
|
||||
* A class representing a layer that owns and manages a `GraphicsLayer` for composable rendering.
|
||||
*
|
||||
* This layer is responsible for managing graphical properties, transformations, and rendering
|
||||
* tasks associated with a `GraphicsLayer`. It provides mechanisms for updating layer properties,
|
||||
* triggering invalidations, resizing, and mapping offsets or bounds using transformation matrices.
|
||||
*
|
||||
* @constructor
|
||||
* @param graphicsLayer The initial `GraphicsLayer` for this layer.
|
||||
* @param context The graphics context responsible for managing this layer, or `null` if externally
|
||||
* managed. When context is not null, it means the object is created internally, and we need to
|
||||
* release it during disposal.
|
||||
* @param layerManager The manager responsible for handling the ownership and lifecycle of this layer.
|
||||
* @param drawBlock The lambda function invoked to perform custom drawing on the canvas.
|
||||
* @param invalidateParentLayer Callback to invalidate the parent layer when necessary.
|
||||
*/
|
||||
internal class GraphicsLayerOwnerLayer(
|
||||
graphicsLayer: GraphicsLayer,
|
||||
private val context: GraphicsContext?,
|
||||
private val layerManager: OwnedLayerManager,
|
||||
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
|
||||
invalidateParentLayer: () -> Unit,
|
||||
) : OwnedLayer {
|
||||
internal var graphicsLayer: GraphicsLayer = graphicsLayer
|
||||
private set
|
||||
private var drawBlock: ((canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit)? = drawBlock
|
||||
private var invalidateParentLayer: (() -> Unit)? = invalidateParentLayer
|
||||
|
||||
private var size: IntSize = IntSize(Int.MAX_VALUE, Int.MAX_VALUE)
|
||||
private var density = Density(1f)
|
||||
private var layoutDirection = LayoutDirection.Ltr
|
||||
private val scope = CanvasDrawScope()
|
||||
private var mutatedFields: Int = 0
|
||||
private var transformOrigin: TransformOrigin = TransformOrigin.Center
|
||||
private var outline: Outline? = null
|
||||
|
||||
private val matrixCache = Matrix()
|
||||
private var inverseMatrixCache: Matrix? = null
|
||||
|
||||
private var isDestroyed = false
|
||||
private var isDirty = true
|
||||
set(value) {
|
||||
if (value != field) {
|
||||
field = value
|
||||
layerManager.notifyLayerIsDirty(this, value)
|
||||
}
|
||||
}
|
||||
private var isMatrixDirty = false
|
||||
private var isInverseMatrixDirty = false
|
||||
private var isIdentity = true
|
||||
override var frameRate: Float = 0f
|
||||
override var isFrameRateFromParent = false
|
||||
|
||||
override fun updateLayerProperties(scope: ReusableGraphicsLayerScope) {
|
||||
val maybeChangedFields = scope.mutatedFields or mutatedFields
|
||||
this.layoutDirection = scope.layoutDirection
|
||||
this.density = scope.graphicsDensity
|
||||
if (maybeChangedFields and Fields.Outsets != 0) {
|
||||
with(density) {
|
||||
graphicsLayer.setOutsets(
|
||||
left = scope.outsets.left.roundToPx(),
|
||||
top = scope.outsets.top.roundToPx(),
|
||||
right = scope.outsets.right.roundToPx(),
|
||||
bottom = scope.outsets.bottom.roundToPx(),
|
||||
)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
if (maybeChangedFields and Fields.TransformOrigin != 0) {
|
||||
this.transformOrigin = scope.transformOrigin
|
||||
}
|
||||
if (maybeChangedFields and Fields.ScaleX != 0) {
|
||||
graphicsLayer.scaleX = scope.scaleX
|
||||
}
|
||||
if (maybeChangedFields and Fields.ScaleY != 0) {
|
||||
graphicsLayer.scaleY = scope.scaleY
|
||||
}
|
||||
if (maybeChangedFields and Fields.Alpha != 0) {
|
||||
graphicsLayer.alpha = scope.alpha
|
||||
}
|
||||
if (maybeChangedFields and Fields.TranslationX != 0) {
|
||||
graphicsLayer.translationX = scope.translationX
|
||||
}
|
||||
if (maybeChangedFields and Fields.TranslationY != 0) {
|
||||
graphicsLayer.translationY = scope.translationY
|
||||
}
|
||||
if (maybeChangedFields and Fields.ShadowElevation != 0) {
|
||||
graphicsLayer.shadowElevation = scope.shadowElevation
|
||||
}
|
||||
if (maybeChangedFields and Fields.AmbientShadowColor != 0) {
|
||||
graphicsLayer.ambientShadowColor = scope.ambientShadowColor
|
||||
}
|
||||
if (maybeChangedFields and Fields.SpotShadowColor != 0) {
|
||||
graphicsLayer.spotShadowColor = scope.spotShadowColor
|
||||
}
|
||||
if (maybeChangedFields and Fields.RotationZ != 0) {
|
||||
graphicsLayer.rotationZ = scope.rotationZ
|
||||
}
|
||||
if (maybeChangedFields and Fields.RotationX != 0) {
|
||||
graphicsLayer.rotationX = scope.rotationX
|
||||
}
|
||||
if (maybeChangedFields and Fields.RotationY != 0) {
|
||||
graphicsLayer.rotationY = scope.rotationY
|
||||
}
|
||||
if (maybeChangedFields and Fields.CameraDistance != 0) {
|
||||
graphicsLayer.cameraDistance = scope.cameraDistance
|
||||
}
|
||||
if (maybeChangedFields and Fields.TransformOrigin != 0) {
|
||||
if (transformOrigin == TransformOrigin.Center) {
|
||||
graphicsLayer.pivotOffset = Offset.Unspecified
|
||||
} else {
|
||||
graphicsLayer.pivotOffset =
|
||||
Offset(
|
||||
transformOrigin.pivotFractionX * size.width,
|
||||
transformOrigin.pivotFractionY * size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
if (maybeChangedFields and Fields.Clip != 0) {
|
||||
graphicsLayer.clip = scope.clip
|
||||
}
|
||||
if (maybeChangedFields and Fields.RenderEffect != 0) {
|
||||
graphicsLayer.renderEffect = scope.renderEffect
|
||||
}
|
||||
if (maybeChangedFields and Fields.ColorFilter != 0) {
|
||||
graphicsLayer.colorFilter = scope.colorFilter
|
||||
}
|
||||
if (maybeChangedFields and Fields.BlendMode != 0) {
|
||||
graphicsLayer.blendMode = scope.blendMode
|
||||
}
|
||||
if (maybeChangedFields and Fields.CompositingStrategy != 0) {
|
||||
graphicsLayer.compositingStrategy =
|
||||
when (scope.compositingStrategy) {
|
||||
CompositingStrategy.Auto -> androidx.compose.ui.graphics.layer.CompositingStrategy.Auto
|
||||
CompositingStrategy.Offscreen -> androidx.compose.ui.graphics.layer.CompositingStrategy.Offscreen
|
||||
CompositingStrategy.ModulateAlpha -> androidx.compose.ui.graphics.layer.CompositingStrategy.ModulateAlpha
|
||||
else -> throw IllegalStateException("Not supported composition strategy")
|
||||
}
|
||||
}
|
||||
if (maybeChangedFields and Fields.MatrixAffectingFields != 0) {
|
||||
isMatrixDirty = true
|
||||
isInverseMatrixDirty = true
|
||||
}
|
||||
|
||||
var outlineChanged = false
|
||||
|
||||
if (outline != scope.outline) {
|
||||
outlineChanged = true
|
||||
outline = scope.outline
|
||||
updateOutline()
|
||||
}
|
||||
|
||||
mutatedFields = scope.mutatedFields
|
||||
if (maybeChangedFields != 0 || outlineChanged) {
|
||||
triggerRepaint()
|
||||
layerManager.voteFrameRate(frameRate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers redrawing of Compose content during the next frame.
|
||||
*/
|
||||
private fun triggerRepaint() {
|
||||
layerManager.invalidate()
|
||||
}
|
||||
|
||||
private fun updateOutline() {
|
||||
val outline = outline ?: return
|
||||
graphicsLayer.setOutline(outline)
|
||||
}
|
||||
|
||||
override fun isInLayer(position: Offset): Boolean {
|
||||
val x = position.x
|
||||
val y = position.y
|
||||
|
||||
if (graphicsLayer.clip) {
|
||||
return isInOutline(graphicsLayer.outline, x, y)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun move(position: IntOffset) {
|
||||
layerManager.voteFrameRate(FrameRateCategory.High.value)
|
||||
graphicsLayer.topLeft = position
|
||||
triggerRepaint()
|
||||
}
|
||||
|
||||
override fun resize(size: IntSize) {
|
||||
if (size != this.size) {
|
||||
layerManager.voteFrameRate(FrameRateCategory.High.value)
|
||||
this.size = size
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun drawLayer(canvas: Canvas, parentLayer: GraphicsLayer?) {
|
||||
updateDisplayList()
|
||||
scope.drawContext.also {
|
||||
it.canvas = canvas
|
||||
it.graphicsLayer = parentLayer
|
||||
}
|
||||
scope.drawLayer(graphicsLayer)
|
||||
}
|
||||
|
||||
override fun updateDisplayList() {
|
||||
layerManager.voteFrameRate(frameRate)
|
||||
if (isDirty) {
|
||||
if (transformOrigin != TransformOrigin.Center && graphicsLayer.size != size) {
|
||||
graphicsLayer.pivotOffset =
|
||||
Offset(
|
||||
transformOrigin.pivotFractionX * size.width,
|
||||
transformOrigin.pivotFractionY * size.height
|
||||
)
|
||||
}
|
||||
graphicsLayer.record(density, layoutDirection, size, recordLambda)
|
||||
isDirty = false
|
||||
}
|
||||
}
|
||||
|
||||
private val recordLambda: DrawScope.() -> Unit = {
|
||||
drawIntoCanvas { canvas ->
|
||||
this@GraphicsLayerOwnerLayer.drawBlock?.let { it(canvas, drawContext.graphicsLayer) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks content as dirty and triggers redrawing.
|
||||
*/
|
||||
override fun invalidate() {
|
||||
if (isDestroyed) return
|
||||
isDirty = true
|
||||
layerManager.invalidate()
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
frameRate = 0f
|
||||
isFrameRateFromParent = false
|
||||
drawBlock = null
|
||||
invalidateParentLayer = null
|
||||
isDestroyed = true
|
||||
isDirty = false
|
||||
if (context != null) {
|
||||
context.releaseGraphicsLayer(graphicsLayer)
|
||||
|
||||
// Recycle only in case of non-null context (meaning only for not external layers).
|
||||
layerManager.recycle(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mapOffset(point: Offset, inverse: Boolean): Offset {
|
||||
val matrix =
|
||||
if (inverse) {
|
||||
getInverseMatrix() ?: return Offset.Infinite
|
||||
} else {
|
||||
getMatrix()
|
||||
}
|
||||
return if (isIdentity) {
|
||||
point
|
||||
} else {
|
||||
matrix.map(point)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mapBounds(rect: MutableRect, inverse: Boolean) {
|
||||
val matrix = if (inverse) getInverseMatrix() else getMatrix()
|
||||
if (!isIdentity) {
|
||||
if (matrix == null) {
|
||||
rect.set(0f, 0f, 0f, 0f)
|
||||
} else {
|
||||
matrix.map(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun reuseLayer(
|
||||
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
|
||||
invalidateParentLayer: () -> Unit
|
||||
) {
|
||||
val context =
|
||||
checkPreconditionNotNull(context) {
|
||||
"currently reuse is only supported when we manage the layer lifecycle"
|
||||
}
|
||||
requirePrecondition(graphicsLayer.isReleased) {
|
||||
"layer should have been released before reuse"
|
||||
}
|
||||
|
||||
// recreate a layer
|
||||
graphicsLayer = context.createGraphicsLayer()
|
||||
isDestroyed = false
|
||||
|
||||
// apply new params
|
||||
this.drawBlock = drawBlock
|
||||
this.invalidateParentLayer = invalidateParentLayer
|
||||
|
||||
// reset mutable variables to their initial values
|
||||
isMatrixDirty = false
|
||||
isInverseMatrixDirty = false
|
||||
isIdentity = true
|
||||
matrixCache.reset()
|
||||
inverseMatrixCache?.reset()
|
||||
transformOrigin = TransformOrigin.Center
|
||||
size = IntSize(Int.MAX_VALUE, Int.MAX_VALUE)
|
||||
outline = null
|
||||
mutatedFields = 0
|
||||
}
|
||||
|
||||
override fun transform(matrix: Matrix) {
|
||||
matrix.timesAssign(getMatrix())
|
||||
}
|
||||
|
||||
override val underlyingMatrix: Matrix
|
||||
get() = getMatrix()
|
||||
|
||||
override fun inverseTransform(matrix: Matrix) {
|
||||
val inverse = getInverseMatrix()
|
||||
if (inverse != null) {
|
||||
matrix.timesAssign(inverse)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMatrix(): Matrix {
|
||||
updateMatrix()
|
||||
return matrixCache
|
||||
}
|
||||
|
||||
private fun getInverseMatrix(): Matrix? {
|
||||
val matrix = getMatrix()
|
||||
if (isIdentity) {
|
||||
return matrix
|
||||
}
|
||||
|
||||
val inverseMatrix = inverseMatrixCache ?: Matrix().also { inverseMatrixCache = it }
|
||||
if (!isInverseMatrixDirty) {
|
||||
return if (inverseMatrix[0, 0].isNaN()) {
|
||||
null
|
||||
} else {
|
||||
inverseMatrix
|
||||
}
|
||||
}
|
||||
|
||||
isInverseMatrixDirty = false
|
||||
return if (matrix.invertTo(inverseMatrix)) {
|
||||
inverseMatrix
|
||||
} else {
|
||||
inverseMatrix[0, 0] = Float.NaN
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMatrix() {
|
||||
if (!isMatrixDirty) return
|
||||
with(graphicsLayer) {
|
||||
val pivotX: Float
|
||||
val pivotY: Float
|
||||
if (pivotOffset.isUnspecified) {
|
||||
pivotX = this@GraphicsLayerOwnerLayer.size.width / 2f
|
||||
pivotY = this@GraphicsLayerOwnerLayer.size.height / 2f
|
||||
} else {
|
||||
pivotX = pivotOffset.x
|
||||
pivotY = pivotOffset.y
|
||||
}
|
||||
prepareLayerTransformationMatrix(
|
||||
matrix = matrixCache,
|
||||
pivotX = pivotX,
|
||||
pivotY = pivotY,
|
||||
translationX = translationX,
|
||||
translationY = translationY,
|
||||
rotationX = rotationX,
|
||||
rotationY = rotationY,
|
||||
rotationZ = rotationZ,
|
||||
scaleX = scaleX,
|
||||
scaleY = scaleY,
|
||||
cameraDistance = cameraDistance
|
||||
)
|
||||
}
|
||||
isMatrixDirty = false
|
||||
isIdentity = matrixCache.isIdentity()
|
||||
}
|
||||
}
|
||||
+41
-6
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.isUnspecified
|
||||
import com.compose.sdl.text.NativeTextCanvas
|
||||
import com.compose.sdl.text.currentTextMeasurer
|
||||
@@ -73,7 +74,32 @@ internal class SdlParagraph(
|
||||
private val wrapped = currentTextMeasurer.wrap(text, fontPx, maxWidthPx, family, variations)
|
||||
private val allLines: List<String> = wrapped.lines
|
||||
private val lineStarts: IntArray = wrapped.lineStarts
|
||||
private val lh: Float = currentTextMeasurer.lineHeight(fontPx, family, variations).coerceAtLeast(1f)
|
||||
|
||||
// TextStyle.lineHeight in px — upstream Skia paragraphs honour it (every M3
|
||||
// Typography style sets one, e.g. bodyMedium 14sp/20sp), so ignoring it made every
|
||||
// text block's height differ from upstream by ~1px per WRAPPED line, an error that
|
||||
// ACCUMULATED down the page (the dominant term in every parity diff). sp scales by
|
||||
// density like fontPx; em multiplies the font size.
|
||||
private val styleLineHeightPx: Float? = when {
|
||||
style.lineHeight.isUnspecified -> null
|
||||
style.lineHeight.type == TextUnitType.Sp -> style.lineHeight.value * density
|
||||
style.lineHeight.type == TextUnitType.Em -> style.lineHeight.value * fontPx
|
||||
else -> null
|
||||
// Upstream ignores a lineHeight NOT STRICTLY GREATER than the font size (skia
|
||||
// only applies height multipliers > 1em) — probe: 48sp/24lh -> the 65px cell,
|
||||
// and 24sp/24lh (h = 1.0 exactly) -> the 33px cell, while 24sp/25lh -> 25.
|
||||
}?.takeIf { it > fontPx }
|
||||
// How the band applies depends on TextStyle.lineHeightStyle (probe-verified vs JVM):
|
||||
// - unspecified (raw BasicText): COMPAT TRIM — the first line keeps the tight font
|
||||
// cell, lineHeight is the advance between baselines. n lines = cell + (n-1)*lh.
|
||||
// - Trim.None (every M3 Typography style: Center/None/Fixed): UNIFORM bands — every
|
||||
// line is exactly lineHeight, single-line included (even below the cell).
|
||||
private val uniformBands: Boolean =
|
||||
style.lineHeightStyle?.trim == androidx.compose.ui.text.style.LineHeightStyle.Trim.None
|
||||
private val fontCellH: Float =
|
||||
currentTextMeasurer.lineHeight(fontPx, family, variations).coerceAtLeast(1f)
|
||||
private val lh: Float = styleLineHeightPx ?: fontCellH
|
||||
private val firstLineH: Float = if (uniformBands && styleLineHeightPx != null) lh else fontCellH
|
||||
|
||||
// True when a span changes glyph METRICS (fontSize / fontWeight) — gates
|
||||
// the styled measurement paths so plain/colour-only text keeps the cheap
|
||||
@@ -103,10 +129,14 @@ internal class SdlParagraph(
|
||||
// Per-line box heights: base line height unless a size span makes a line's
|
||||
// tallest run cell bigger. lineTops[i] = cumulative top; last entry = height.
|
||||
private val lineHeights: FloatArray by lazy {
|
||||
if (!metricSpans) FloatArray(lineCount) { lh }
|
||||
if (!metricSpans) FloatArray(lineCount) { if (it == 0) firstLineH else lh }
|
||||
else FloatArray(lineCount) {
|
||||
styledLineCellHeight(allLines[it], lineStarts[it], spanStyles, fontPx, density, currentTextMeasurer, family, variations)
|
||||
.coerceAtLeast(1f)
|
||||
// A size span can push a line's cell above the style's line height, but
|
||||
// never below it (first line keeps its tight cell under compat trim).
|
||||
max(
|
||||
styledLineCellHeight(allLines[it], lineStarts[it], spanStyles, fontPx, density, currentTextMeasurer, family, variations),
|
||||
if (it == 0 && !uniformBands) 0f else (styleLineHeightPx ?: 0f),
|
||||
).coerceAtLeast(1f)
|
||||
}
|
||||
}
|
||||
private val lineTops: FloatArray by lazy {
|
||||
@@ -118,7 +148,9 @@ internal class SdlParagraph(
|
||||
override val width: Float by lazy {
|
||||
if (maxWidthPx == Int.MAX_VALUE) (lineWidths.maxOrNull() ?: 0f) else widthConstraint
|
||||
}
|
||||
override val height: Float by lazy { if (!metricSpans) lineCount * lh else lineTops[lineCount] }
|
||||
override val height: Float by lazy {
|
||||
if (!metricSpans) firstLineH + (lineCount - 1) * lh else lineTops[lineCount]
|
||||
}
|
||||
// Widest single HARD-BREAK line — NOT the concatenated all-on-one-line width.
|
||||
// Compose's Text(softWrap = false) reads this to decide the paragraph width
|
||||
// (LayoutUtils.finalMaxWidth returns Constraints.Infinity when softWrap=false,
|
||||
@@ -193,8 +225,9 @@ internal class SdlParagraph(
|
||||
}
|
||||
|
||||
override fun getLineForVerticalPosition(vertical: Float): Int {
|
||||
if (!metricSpans) return (vertical / lh).toInt().coerceIn(0, lineCount - 1)
|
||||
// Cumulative tops are monotonic — walk to the line containing `vertical`.
|
||||
// (No uniform-divide fast path anymore: the first line's box is the font
|
||||
// cell while later lines use the style lineHeight.)
|
||||
for (i in 0 until lineCount) if (vertical < lineTops[i + 1]) return i
|
||||
return lineCount - 1
|
||||
}
|
||||
@@ -368,6 +401,8 @@ internal class SdlParagraph(
|
||||
inFontVariations = variations,
|
||||
inBaseItalic = style.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
inTextDecoration = inDecoration ?: style.textDecoration,
|
||||
inLineHeightPx = lh,
|
||||
inTrimFirstLine = !uniformBands || styleLineHeightPx == null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,13 @@ interface RenderBackend {
|
||||
so it maps 1:1 onto the pixel back buffer. */
|
||||
fun beginFrame(inDpr: Float)
|
||||
|
||||
/* Draw the upstream LayoutNode tree owned by [inHost] through the vendored
|
||||
coordinator / DrawModifierNode pipeline (LayoutNode.draw → CanvasDrawScope
|
||||
→ the platform Canvas backend). Default no-op — the Skia backend will
|
||||
override once SkiaCanvas lands (see NODE_ENGINE_PORT.md Phase 9). */
|
||||
fun drawRoot(inHost: com.compose.sdl.node.ComposeRootHost) {}
|
||||
/* Build this frame's platform Canvas and hand it to [inDraw], which walks the
|
||||
composition (host.drawRoot(canvas)) through the vendored coordinator /
|
||||
DrawModifierNode pipeline. Taking a (Canvas)->Unit instead of the host keeps
|
||||
RenderBackend + its implementations independent of the node/host layer — the
|
||||
decoupling that lets the renderers live in :ui-graphics (no ui-graphics→ui
|
||||
cycle). Default no-op. */
|
||||
fun drawRoot(inDraw: (canvas: androidx.compose.ui.graphics.Canvas) -> Unit) {}
|
||||
|
||||
/* Flush + present whatever was just drawn. */
|
||||
fun endFrame()
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.GraphicsContext
|
||||
|
||||
// ==================
|
||||
// MARK: GraphicsContext factory seam (B2 / P1.3)
|
||||
// ==================
|
||||
|
||||
/* ComposeOwner.graphicsContext is created behind a per-renderer factory seam so each
|
||||
leg supplies its own GraphicsContext: exactly one actual is attached per target
|
||||
(the createRenderBackend trick). SDL → the project ProjectGraphicsContext (record/
|
||||
replay via the SDL NativeRenderNode); Skia → upstream's SkiaGraphicsContext (owns a
|
||||
skiko RenderNodeContext). See RENDERER.md §4 (B2). */
|
||||
internal expect fun createGraphicsContext(): GraphicsContext
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
|
||||
// ==================
|
||||
// MARK: GraphicsLayer factory — native actuals
|
||||
// ==================
|
||||
|
||||
internal actual fun createProjectGraphicsLayer(): GraphicsLayer = GraphicsLayer()
|
||||
|
||||
internal actual fun releaseProjectGraphicsLayer(inLayer: GraphicsLayer) {
|
||||
inLayer.isReleased = true
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import kotlin.math.abs
|
||||
|
||||
// Layer transform matrix builder for GraphicsLayerOwnerLayer (hit-testing /
|
||||
// coordinate mapping). Body vendored from compose-multiplatform-core
|
||||
// Matrices.skiko.kt `prepareTransformationMatrix` — pure Compose `Matrix` math,
|
||||
// no Skia. It lives HERE in nativeMain (shared by both renderer legs) under a
|
||||
// distinct name so it is visible to GraphicsLayerOwnerLayer (also nativeMain);
|
||||
// the upstream `prepareTransformationMatrix` stays skikoRenderer-only in the
|
||||
// vendored Matrices.skiko.kt and would not be visible up here. Distinct name
|
||||
// avoids clashing with that copy on the Skia leg. (RENDERER.md P1.5 will
|
||||
// reverse this rename once B2 vendors Matrices.skiko onto the Skia leg.)
|
||||
// VENDOR-BASE: compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/graphics/Matrices.skiko.kt @ v1.12.0-beta03+dev4483
|
||||
|
||||
// Builds the layer transform (translation / rotation / scale / camera about a
|
||||
// pivot) into [matrix]. Matches the skiko copy so hit-testing agrees across legs.
|
||||
internal fun prepareLayerTransformationMatrix(
|
||||
matrix: Matrix,
|
||||
pivotX: Float,
|
||||
pivotY: Float,
|
||||
translationX: Float,
|
||||
translationY: Float,
|
||||
rotationX: Float,
|
||||
rotationY: Float,
|
||||
rotationZ: Float,
|
||||
scaleX: Float,
|
||||
scaleY: Float,
|
||||
cameraDistance: Float,
|
||||
) {
|
||||
matrix.reset()
|
||||
matrix.translate(x = -pivotX, y = -pivotY)
|
||||
matrix *= Matrix().apply {
|
||||
rotateZ(rotationZ)
|
||||
rotateY(rotationY)
|
||||
rotateX(rotationX)
|
||||
scale(scaleX, scaleY)
|
||||
}
|
||||
// Perspective transform should be applied only in case of rotations to avoid
|
||||
// multiply application in hierarchies.
|
||||
// See Android's frameworks/base/libs/hwui/RenderProperties.cpp for reference
|
||||
if (!rotationX.isZero() || !rotationY.isZero()) {
|
||||
matrix *= Matrix().apply {
|
||||
// The camera location is passed in inches, set in pt
|
||||
val depth = cameraDistance * 72f
|
||||
this[2, 3] = -1f / depth
|
||||
}
|
||||
}
|
||||
matrix *= Matrix().apply {
|
||||
translate(x = pivotX + translationX, y = pivotY + translationY)
|
||||
}
|
||||
|
||||
// Third column and row are irrelevant for 2D space.
|
||||
// Zeroing required to get correct inverse transformation matrix.
|
||||
matrix[2, 0] = 0f
|
||||
matrix[2, 1] = 0f
|
||||
matrix[2, 3] = 0f
|
||||
matrix[0, 2] = 0f
|
||||
matrix[1, 2] = 0f
|
||||
matrix[3, 2] = 0f
|
||||
}
|
||||
|
||||
private const val NON_ZERO_EPSILON = 0.001f
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
private inline fun Float.isZero(): Boolean = abs(this) <= NON_ZERO_EPSILON
|
||||
+29
-26
@@ -65,12 +65,36 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
fOwner.animationFrameClock.sendFrame(inNanos)
|
||||
}
|
||||
|
||||
// True while a node-level animation (scroll fling, node Animatable) awaits the next
|
||||
// animation frame — the node half of the window's quiescence signal (render-to-quiescence
|
||||
// screenshot capture asks the window whether ANY work is still pending).
|
||||
fun hasAnimationAwaiters(): Boolean = fOwner.animationFrameClock.hasAwaiters
|
||||
|
||||
fun measureAndLayout() {
|
||||
// Flush deferred end-of-apply work (focus invalidation etc.) before measuring, so
|
||||
// requestFocus() from composition OR from a pointer event this frame takes effect —
|
||||
// e.g. focus-on-click, which schedules a focus invalidation during event dispatch.
|
||||
fOwner.onEndApplyChanges()
|
||||
fOwner.measureAndLayout()
|
||||
// Drop snapshot observations whose scope is no longer valid (detached nodes,
|
||||
// destroyed layers, disposed draw scopes). Upstream RootNodeOwner does this after
|
||||
// the measure pass; without it, every disposed subtree's measure/layout/DRAW
|
||||
// observation scopes linger in the OwnerSnapshotObserver forever — each pins its
|
||||
// observed object graph (a leak the P2.2 soak caught on ripple/indication draws).
|
||||
fOwner.snapshotObserver.clearInvalidObservations()
|
||||
}
|
||||
|
||||
// Draw the composed tree into [canvas] — re-records dirty layers, then walks
|
||||
// the root so clean layers replay their cached content. Backends call this
|
||||
// instead of rootNode.draw so retained-layer bookkeeping runs each frame.
|
||||
fun drawRoot(canvas: androidx.compose.ui.graphics.Canvas) {
|
||||
fOwner.renderRoot(canvas)
|
||||
}
|
||||
|
||||
// The window wires this to set needsFrame — a layer whose content changed
|
||||
// (OwnedLayer.invalidate) schedules a frame even with nothing else pending.
|
||||
fun setInvalidationCallback(callback: () -> Unit) {
|
||||
fOwner.onInvalidate = callback
|
||||
}
|
||||
|
||||
// The owner's FocusOwner IS a FocusManager — the window provides it as LocalFocusManager.
|
||||
@@ -138,29 +162,8 @@ class ComposeRootHost(inDensity: Float = 1f) {
|
||||
fun onDropComplete() = fOwner.dragAndDropManager.dropComplete()
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: pointer-event bridge (nativeMain builds the internal PointerInputEvent)
|
||||
// ==================
|
||||
|
||||
/* The internal `expect PointerInputEvent` exposes no commonMain constructor, so the
|
||||
build+dispatch is a nativeMain actual. Constructs a single-pointer event and feeds it
|
||||
to the owner's PointerInputEventProcessor. inType: 0=Move 1=Press 2=Release. */
|
||||
internal expect fun feedPointerToProcessor(
|
||||
inOwner: com.compose.sdl.node.impl.ComposeOwner,
|
||||
inType: Int,
|
||||
inButton: Int,
|
||||
inUptime: Long,
|
||||
inX: Float,
|
||||
inY: Float,
|
||||
)
|
||||
|
||||
/* Builds a Scroll-type PointerInputEvent (scrollDelta) and drives the processor, so the vendored
|
||||
Modifier.scrollable's MouseWheelScrollingLogic handles the wheel. nativeMain-only (ctor is native). */
|
||||
internal expect fun feedScrollToProcessor(
|
||||
inOwner: com.compose.sdl.node.impl.ComposeOwner,
|
||||
inX: Float,
|
||||
inY: Float,
|
||||
inDeltaX: Float,
|
||||
inDeltaY: Float,
|
||||
inUptime: Long,
|
||||
)
|
||||
// NOTE: feedPointerToProcessor / feedScrollToProcessor are plain nativeMain
|
||||
// functions in PointerEventBridge.native.kt. This facade moved from commonMain to
|
||||
// nativeMain (it now constructs the native retained-layer OwnedLayer via
|
||||
// ComposeOwner), so the former expect/actual split collapsed to a single native
|
||||
// definition — see RENDERER.md §5.
|
||||
+2
-2
@@ -25,7 +25,7 @@ private var fTertiaryDown = false
|
||||
/* Builds the internal PointerInputEvent (its constructor is native-only) from a single mouse
|
||||
pointer and drives the owner's PointerInputEventProcessor. inType: 0=Move 1=Press 2=Release;
|
||||
inButton: 0=primary 1=secondary 2=tertiary. */
|
||||
internal actual fun feedPointerToProcessor(
|
||||
internal fun feedPointerToProcessor(
|
||||
inOwner: ComposeOwner,
|
||||
inType: Int,
|
||||
inButton: Int,
|
||||
@@ -82,7 +82,7 @@ internal actual fun feedPointerToProcessor(
|
||||
vendored Modifier.scrollable (MouseWheelScrollingLogic) consumes it. SDL wheel delta maps
|
||||
directly to scrollDelta (wheel-down = SDL deltaY<0 → content scrolls down); magnitude is scaled
|
||||
in Sdl3ScrollConfig (dp per notch). */
|
||||
internal actual fun feedScrollToProcessor(
|
||||
internal fun feedScrollToProcessor(
|
||||
inOwner: ComposeOwner,
|
||||
inX: Float,
|
||||
inY: Float,
|
||||
|
||||
+94
-13
@@ -9,6 +9,8 @@ import androidx.compose.ui.node.LayoutNodeDrawScope
|
||||
import androidx.compose.ui.node.MeasureAndLayoutDelegate
|
||||
import androidx.compose.ui.node.Owner
|
||||
import androidx.compose.ui.node.OwnedLayer
|
||||
import androidx.compose.ui.node.OwnedLayerManager
|
||||
import androidx.compose.ui.node.GraphicsLayerOwnerLayer
|
||||
import androidx.compose.ui.node.OwnerScope
|
||||
import androidx.compose.ui.node.OwnerSnapshotObserver
|
||||
import androidx.compose.ui.node.RootForTest
|
||||
@@ -79,7 +81,7 @@ internal class ComposeOwner(
|
||||
override val root: LayoutNode,
|
||||
override val density: Density = Density(1f, 1f),
|
||||
override val layoutDirection: LayoutDirection = LayoutDirection.Ltr,
|
||||
) : Owner {
|
||||
) : Owner, OwnedLayerManager {
|
||||
|
||||
// The vendored measure/layout state machine, rooted at [root].
|
||||
private val fDelegate = MeasureAndLayoutDelegate(root)
|
||||
@@ -151,6 +153,11 @@ internal class ComposeOwner(
|
||||
|
||||
override fun onDetach(node: LayoutNode) {
|
||||
fDelegate.onNodeDetached(node)
|
||||
// Drop the detached node's read-observation scopes, exactly as upstream
|
||||
// RootNodeOwner.onDetach does. Without this, every disposed node's measure/
|
||||
// layout/draw observation scopes accumulate in the snapshot observer forever
|
||||
// — the baseline composition-machinery leak the P2.2 soak caught.
|
||||
snapshotObserver.clear(node)
|
||||
}
|
||||
|
||||
override fun registerOnLayoutCompletedListener(listener: Owner.OnLayoutCompletedListener) {
|
||||
@@ -176,7 +183,86 @@ internal class ComposeOwner(
|
||||
drawBlock: (canvas: Canvas, parentLayer: GraphicsLayer?) -> Unit,
|
||||
invalidateParentLayer: () -> Unit,
|
||||
explicitLayer: GraphicsLayer?,
|
||||
): OwnedLayer = ProjectOwnedLayer(drawBlock)
|
||||
): OwnedLayer = GraphicsLayerOwnerLayer(
|
||||
graphicsLayer = explicitLayer ?: graphicsContext.createGraphicsLayer(),
|
||||
context = if (explicitLayer != null) null else graphicsContext,
|
||||
layerManager = this,
|
||||
drawBlock = drawBlock,
|
||||
invalidateParentLayer = invalidateParentLayer,
|
||||
)
|
||||
|
||||
// ============
|
||||
// OwnedLayerManager — retained-layer bookkeeping (mirrors upstream
|
||||
// OwnedLayerManagerImpl). dirtyLayers are re-recorded once per frame in
|
||||
// [renderRoot] before the tree is drawn; clean layers replay their cached
|
||||
// display list. invalidate() asks the window to schedule a frame.
|
||||
|
||||
private val dirtyLayers = mutableListOf<OwnedLayer>()
|
||||
private var postponedDirtyLayers: MutableList<OwnedLayer>? = null
|
||||
private var isDrawingContent = false
|
||||
|
||||
// Set by the window (via ComposeRootHost) to flag needsFrame; a layer whose
|
||||
// content changed (OwnedLayer.invalidate) schedules a frame even when nothing
|
||||
// else (recompose / relayout) is pending.
|
||||
internal var onInvalidate: (() -> Unit)? = null
|
||||
|
||||
override fun invalidate() {
|
||||
onInvalidate?.invoke()
|
||||
}
|
||||
|
||||
override fun notifyLayerIsDirty(layer: OwnedLayer, isDirty: Boolean) {
|
||||
// Mirrors upstream OwnedLayerManagerImpl exactly. The CRITICAL detail: when a
|
||||
// layer clears its dirty flag DURING drawing (updateDisplayList sets isDirty
|
||||
// = false in the renderRoot loop), we must NOT remove it from dirtyLayers here
|
||||
// — that would shrink the list mid-iteration (IndexOutOfBounds). renderRoot's
|
||||
// dirtyLayers.clear() after the loop handles it instead.
|
||||
if (!isDirty) {
|
||||
if (!isDrawingContent) {
|
||||
dirtyLayers.remove(layer)
|
||||
postponedDirtyLayers?.remove(layer)
|
||||
}
|
||||
} else if (!isDrawingContent) {
|
||||
dirtyLayers += layer
|
||||
} else {
|
||||
val postponed = postponedDirtyLayers
|
||||
?: mutableListOf<OwnedLayer>().also { postponedDirtyLayers = it }
|
||||
postponed += layer
|
||||
}
|
||||
}
|
||||
|
||||
override fun recycle(layer: OwnedLayer): Boolean {
|
||||
dirtyLayers.remove(layer)
|
||||
postponedDirtyLayers?.remove(layer)
|
||||
return false
|
||||
}
|
||||
|
||||
// Both Owner and OwnedLayerManager declare voteFrameRate with a default, so an
|
||||
// explicit override is required. We render at a fixed vsync cadence — no voting.
|
||||
override fun voteFrameRate(frameRate: Float) {}
|
||||
|
||||
// Draw the tree: re-record dirty layers' display lists, then walk the root so
|
||||
// clean layers replay. Called once per frame by ComposeRootHost.drawRoot.
|
||||
internal fun renderRoot(canvas: Canvas) {
|
||||
isDrawingContent = true
|
||||
// Re-record dirty layers BEFORE drawing (unlike Android, our draw forms the
|
||||
// actual render-command sequence, so display lists must be current first).
|
||||
// notifyLayerIsDirty(false) is a no-op while drawing, so the list can't shrink
|
||||
// here; clear() below drops them all at once.
|
||||
if (dirtyLayers.isNotEmpty()) {
|
||||
for (i in 0 until dirtyLayers.size) {
|
||||
dirtyLayers[i].updateDisplayList()
|
||||
}
|
||||
}
|
||||
dirtyLayers.clear()
|
||||
root.draw(canvas, null)
|
||||
// Layers that invalidated themselves during draw were parked in postponed;
|
||||
// roll them into next frame.
|
||||
postponedDirtyLayers?.let {
|
||||
dirtyLayers.addAll(it)
|
||||
it.clear()
|
||||
}
|
||||
isDrawingContent = false
|
||||
}
|
||||
|
||||
// ============
|
||||
// No-op subsystems (focus / semantics / input / clipboard / text / …).
|
||||
@@ -216,17 +302,12 @@ internal class ComposeOwner(
|
||||
containsControls: Boolean,
|
||||
): Long = originalTimeoutMillis
|
||||
}
|
||||
// GraphicsLayer here is the project record/replay actual (see
|
||||
// GraphicsLayer.native.kt) — SharedTransitionLayout overlays and
|
||||
// rememberGraphicsLayer() create layers through this context. The
|
||||
// expect class hides its constructor/release from commonMain, hence
|
||||
// the project factory hops.
|
||||
override val graphicsContext: GraphicsContext = object : GraphicsContext {
|
||||
override fun createGraphicsLayer(): GraphicsLayer =
|
||||
com.compose.sdl.graphics.createProjectGraphicsLayer()
|
||||
override fun releaseGraphicsLayer(layer: GraphicsLayer) =
|
||||
com.compose.sdl.graphics.releaseProjectGraphicsLayer(layer)
|
||||
}
|
||||
// Created behind a per-renderer factory seam (B2 / P1.3): SDL uses the project
|
||||
// record/replay GraphicsContext; the Skia leg swaps in upstream's SkiaGraphicsContext
|
||||
// at P1.6. SharedTransitionLayout overlays and rememberGraphicsLayer() create layers
|
||||
// through this context. See com/compose/sdl/graphics/GraphicsContextFactory.kt.
|
||||
override val graphicsContext: GraphicsContext =
|
||||
com.compose.sdl.graphics.createGraphicsContext()
|
||||
override val textToolbar: TextToolbar = object : TextToolbar {
|
||||
override val status: androidx.compose.ui.platform.TextToolbarStatus
|
||||
get() = androidx.compose.ui.platform.TextToolbarStatus.Hidden
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package androidx.compose.ui.graphics.layer
|
||||
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.isUnspecified
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RenderEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import com.compose.sdl.graphics.NativeRenderNode
|
||||
|
||||
// ==================
|
||||
// MARK: GraphicsLayer — façade over NativeRenderNode
|
||||
// ==================
|
||||
//
|
||||
// DERIVED (copy + edit) from compose-multiplatform-core
|
||||
// ui-graphics/src/skikoMain/.../graphics/layer/SkiaGraphicsLayer.skiko.kt.
|
||||
// VENDOR-BASE: compose/ui/ui-graphics/src/skikoMain/kotlin/androidx/compose/ui/graphics/layer/SkiaGraphicsLayer.skiko.kt @ v1.12.0-beta03+dev4483
|
||||
// The ONLY structural change vs upstream: the backing display-list node is our
|
||||
// renderer-agnostic `NativeRenderNode` instead of `org.jetbrains.skiko.node.RenderNode`,
|
||||
// so both renderers share this façade (Skia's node wraps the real skiko RenderNode;
|
||||
// SDL's node is SdlRenderNode). Every visual property is mirrored onto the node and
|
||||
// applied at replay, exactly like upstream. Trimmed vs upstream: outsets (blur
|
||||
// expansion) and ChildLayerDependenciesTracker (prompt child release — our children
|
||||
// release via NativeReleaseQueue/GC instead). See RENDERER.md §4 (B2).
|
||||
|
||||
@Suppress("PropertyName")
|
||||
actual class GraphicsLayer internal constructor(
|
||||
private val renderNode: NativeRenderNode,
|
||||
) {
|
||||
|
||||
private var outlineDirty = true
|
||||
private var roundRectOutlineTopLeft: Offset = Offset.Zero
|
||||
private var roundRectOutlineSize: Size = Size.Unspecified
|
||||
private var roundRectCornerRadius: Float = 0f
|
||||
private var internalOutline: Outline? = null
|
||||
private var outlinePath: Path? = null
|
||||
|
||||
actual var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
renderNode.compositingStrategy = value
|
||||
}
|
||||
}
|
||||
|
||||
actual var topLeft: IntOffset = IntOffset.Zero
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
renderNode.topLeft = value
|
||||
}
|
||||
}
|
||||
|
||||
actual var size: IntSize = IntSize.Zero
|
||||
private set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
renderNode.size = value
|
||||
if (roundRectOutlineSize.isUnspecified) {
|
||||
outlineDirty = true
|
||||
configureOutlineAndClip()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual var pivotOffset: Offset = Offset.Unspecified
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
renderNode.pivot = value
|
||||
}
|
||||
}
|
||||
|
||||
actual var alpha: Float = 1f
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
renderNode.alpha = value
|
||||
}
|
||||
}
|
||||
|
||||
actual var scaleX: Float = 1f
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.scaleX = value }
|
||||
}
|
||||
|
||||
actual var scaleY: Float = 1f
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.scaleY = value }
|
||||
}
|
||||
|
||||
actual var translationX: Float = 0f
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.translationX = value }
|
||||
}
|
||||
|
||||
actual var translationY: Float = 0f
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.translationY = value }
|
||||
}
|
||||
|
||||
actual var shadowElevation: Float = 0f
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
renderNode.shadowElevation = value
|
||||
outlineDirty = true
|
||||
configureOutlineAndClip()
|
||||
}
|
||||
}
|
||||
|
||||
actual var ambientShadowColor: Color = Color.Black
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.ambientShadowColor = value }
|
||||
}
|
||||
|
||||
actual var spotShadowColor: Color = Color.Black
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.spotShadowColor = value }
|
||||
}
|
||||
|
||||
actual var blendMode: BlendMode = BlendMode.SrcOver
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.blendMode = value }
|
||||
}
|
||||
|
||||
actual var colorFilter: ColorFilter? = null
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.colorFilter = value }
|
||||
}
|
||||
|
||||
actual val outline: Outline
|
||||
get() {
|
||||
val tmpOutline = internalOutline
|
||||
val tmpPath = outlinePath
|
||||
return if (tmpOutline != null) {
|
||||
tmpOutline
|
||||
} else if (tmpPath != null) {
|
||||
Outline.Generic(tmpPath).also { internalOutline = it }
|
||||
} else {
|
||||
resolveOutlinePosition { outlineTopLeft, outlineSize ->
|
||||
val left = outlineTopLeft.x
|
||||
val top = outlineTopLeft.y
|
||||
val right = left + outlineSize.width
|
||||
val bottom = top + outlineSize.height
|
||||
val cornerRadius = this.roundRectCornerRadius
|
||||
if (cornerRadius > 0f) {
|
||||
Outline.Rounded(RoundRect(left, top, right, bottom, CornerRadius(cornerRadius)))
|
||||
} else {
|
||||
Outline.Rectangle(Rect(left, top, right, bottom))
|
||||
}
|
||||
}.also { internalOutline = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetOutlineParams() {
|
||||
internalOutline = null
|
||||
outlinePath = null
|
||||
roundRectOutlineSize = Size.Unspecified
|
||||
roundRectOutlineTopLeft = Offset.Zero
|
||||
roundRectCornerRadius = 0f
|
||||
outlineDirty = true
|
||||
}
|
||||
|
||||
actual fun setPathOutline(path: Path) {
|
||||
resetOutlineParams()
|
||||
this.outlinePath = path
|
||||
configureOutlineAndClip()
|
||||
}
|
||||
|
||||
actual fun setRoundRectOutline(topLeft: Offset, size: Size, cornerRadius: Float) {
|
||||
if (this.roundRectOutlineTopLeft != topLeft ||
|
||||
this.roundRectOutlineSize != size ||
|
||||
this.roundRectCornerRadius != cornerRadius ||
|
||||
this.outlinePath != null
|
||||
) {
|
||||
resetOutlineParams()
|
||||
this.roundRectOutlineTopLeft = topLeft
|
||||
this.roundRectOutlineSize = size
|
||||
this.roundRectCornerRadius = cornerRadius
|
||||
configureOutlineAndClip()
|
||||
}
|
||||
}
|
||||
|
||||
actual fun setRectOutline(topLeft: Offset, size: Size) {
|
||||
setRoundRectOutline(topLeft, size, 0f)
|
||||
}
|
||||
|
||||
actual var rotationX: Float = 0f
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.rotationX = value }
|
||||
}
|
||||
|
||||
actual var rotationY: Float = 0f
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.rotationY = value }
|
||||
}
|
||||
|
||||
actual var rotationZ: Float = 0f
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.rotationZ = value }
|
||||
}
|
||||
|
||||
actual var cameraDistance: Float = DefaultCameraDistance
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.cameraDistance = value }
|
||||
}
|
||||
|
||||
actual var clip: Boolean = false
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
renderNode.clip = value
|
||||
outlineDirty = true
|
||||
configureOutlineAndClip()
|
||||
}
|
||||
}
|
||||
|
||||
actual var renderEffect: RenderEffect? = null
|
||||
set(value) {
|
||||
if (field != value) { field = value; renderNode.renderEffect = value }
|
||||
}
|
||||
|
||||
actual var isReleased: Boolean = false
|
||||
internal set
|
||||
|
||||
actual fun setOutsets(left: Int, top: Int, right: Int, bottom: Int) {
|
||||
// Outsets (blur/shadow expansion) not modelled on this port — see header.
|
||||
}
|
||||
|
||||
actual fun record(
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
size: IntSize,
|
||||
block: DrawScope.() -> Unit,
|
||||
) {
|
||||
this.size = size
|
||||
renderNode.record(density, layoutDirection, size, block)
|
||||
}
|
||||
|
||||
internal actual fun draw(canvas: Canvas, parentLayer: GraphicsLayer?) {
|
||||
if (isReleased) return
|
||||
configureOutlineAndClip()
|
||||
renderNode.drawInto(canvas)
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.ui.InternalComposeUiApi::class)
|
||||
private fun configureOutlineAndClip() {
|
||||
if (!outlineDirty) return
|
||||
val outlineIsNeeded = clip || shadowElevation > 0f
|
||||
if (!outlineIsNeeded) {
|
||||
renderNode.clip = false
|
||||
renderNode.setClipPath(null)
|
||||
} else {
|
||||
renderNode.clip = clip
|
||||
when (val tmpOutline = outline) {
|
||||
is Outline.Rectangle -> renderNode.setClipRect(
|
||||
tmpOutline.rect.left, tmpOutline.rect.top,
|
||||
tmpOutline.rect.right, tmpOutline.rect.bottom,
|
||||
)
|
||||
is Outline.Rounded -> renderNode.setClipRRect(
|
||||
tmpOutline.roundRect.left, tmpOutline.roundRect.top,
|
||||
tmpOutline.roundRect.right, tmpOutline.roundRect.bottom,
|
||||
floatArrayOf(
|
||||
tmpOutline.roundRect.topLeftCornerRadius.x,
|
||||
tmpOutline.roundRect.topLeftCornerRadius.y,
|
||||
tmpOutline.roundRect.topRightCornerRadius.x,
|
||||
tmpOutline.roundRect.topRightCornerRadius.y,
|
||||
tmpOutline.roundRect.bottomRightCornerRadius.x,
|
||||
tmpOutline.roundRect.bottomRightCornerRadius.y,
|
||||
tmpOutline.roundRect.bottomLeftCornerRadius.x,
|
||||
tmpOutline.roundRect.bottomLeftCornerRadius.y,
|
||||
),
|
||||
)
|
||||
is Outline.Generic -> renderNode.setClipPath(tmpOutline.path)
|
||||
}
|
||||
}
|
||||
outlineDirty = false
|
||||
}
|
||||
|
||||
private inline fun <T> resolveOutlinePosition(block: (Offset, Size) -> T): T {
|
||||
val layerSize = this.size.toSize()
|
||||
val rRectTopLeft = roundRectOutlineTopLeft
|
||||
val rRectSize = roundRectOutlineSize
|
||||
val outlineSize = if (rRectSize.isUnspecified) layerSize else rRectSize
|
||||
return block(rRectTopLeft, outlineSize)
|
||||
}
|
||||
|
||||
internal fun release() {
|
||||
if (!isReleased) {
|
||||
isReleased = true
|
||||
renderNode.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors upstream SkiaGraphicsLayer.toImageBitmap: render the layer into a fresh
|
||||
// bitmap of its own size. The offscreen canvas is flushed via NativeFinishableCanvas
|
||||
// (SDL batches geometry; the backing render-target texture must be committed before
|
||||
// it's read) — Skia's finish() is a no-op.
|
||||
actual suspend fun toImageBitmap(): ImageBitmap {
|
||||
val bitmap = ImageBitmap(size.width, size.height)
|
||||
val canvas = Canvas(bitmap)
|
||||
draw(canvas, null)
|
||||
(canvas as? com.compose.sdl.graphics.NativeFinishableCanvas)?.finish()
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.isUnspecified
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RenderEffect
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.DefaultCameraDistance
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
// ==================
|
||||
// MARK: DeferredRenderNode — replay-the-block NativeRenderNode
|
||||
// ==================
|
||||
|
||||
/*
|
||||
The renderer-agnostic [NativeRenderNode] both renderers use today. It does NOT
|
||||
cache rasterised output: [record] stores the draw block; [drawInto] REPLAYS it
|
||||
against the target canvas under the node's transform / clip / shadow / alpha. This
|
||||
is behaviour-identical to the pre-refactor paths it replaces — the old
|
||||
GraphicsLayer.native lambda-replay AND ProjectOwnedLayer's transform/shadow/clip —
|
||||
now unified in one place behind the RenderNode seam.
|
||||
|
||||
It is the correct-but-unoptimised node. The perf win (stop re-tessellating on
|
||||
replay) comes from swapping the per-renderer createNativeRenderNode actual to a
|
||||
caching node — skiko RenderNode (a real display list) on Skia, an offscreen /
|
||||
cached-geometry display list on SDL — WITHOUT touching GraphicsLayer. See
|
||||
RENDERER.md §4 (Phase 2 = that swap).
|
||||
|
||||
Transform is applied via [prepareLayerTransformationMatrix] + canvas.concat, the
|
||||
SAME matrix GraphicsLayerOwnerLayer uses for hit-testing (mapOffset), so draw and
|
||||
hit-test provably agree. Shadow + rounded/shape clip go through the project canvas
|
||||
interfaces (NativeShadowCanvas / NativeShapeClipCanvas), exactly as the old
|
||||
ProjectOwnedLayer did.
|
||||
*/
|
||||
internal class DeferredRenderNode : NativeRenderNode {
|
||||
|
||||
override var topLeft: IntOffset = IntOffset.Zero
|
||||
override var size: IntSize = IntSize.Zero
|
||||
override var pivot: Offset = Offset.Unspecified
|
||||
override var alpha: Float = 1f
|
||||
override var scaleX: Float = 1f
|
||||
override var scaleY: Float = 1f
|
||||
override var translationX: Float = 0f
|
||||
override var translationY: Float = 0f
|
||||
override var rotationX: Float = 0f
|
||||
override var rotationY: Float = 0f
|
||||
override var rotationZ: Float = 0f
|
||||
override var cameraDistance: Float = DefaultCameraDistance
|
||||
override var shadowElevation: Float = 0f
|
||||
override var ambientShadowColor: Color = Color.Black
|
||||
override var spotShadowColor: Color = Color.Black
|
||||
override var blendMode: BlendMode = BlendMode.SrcOver
|
||||
override var colorFilter: ColorFilter? = null
|
||||
override var renderEffect: RenderEffect? = null
|
||||
override var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
|
||||
override var clip: Boolean = false
|
||||
|
||||
// Outline pushed by GraphicsLayer.configureOutlineAndClip whenever clip OR a
|
||||
// shadow is present — so it drives BOTH the content clip (when clip=true) and
|
||||
// the drop-shadow silhouette (when shadowElevation>0, even with clip=false).
|
||||
private var clipOutline: Outline? = null
|
||||
|
||||
override fun setClipRect(left: Float, top: Float, right: Float, bottom: Float) {
|
||||
clipOutline = Outline.Rectangle(Rect(left, top, right, bottom))
|
||||
}
|
||||
|
||||
override fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray) {
|
||||
clipOutline = Outline.Rounded(
|
||||
RoundRect(
|
||||
left = left, top = top, right = right, bottom = bottom,
|
||||
topLeftCornerRadius = CornerRadius(radii[0], radii[1]),
|
||||
topRightCornerRadius = CornerRadius(radii[2], radii[3]),
|
||||
bottomRightCornerRadius = CornerRadius(radii[4], radii[5]),
|
||||
bottomLeftCornerRadius = CornerRadius(radii[6], radii[7]),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun setClipPath(path: Path?) {
|
||||
clipOutline = if (path == null) null else Outline.Generic(path)
|
||||
}
|
||||
|
||||
// ============
|
||||
// Record — store the block (deferred; replayed in drawInto).
|
||||
|
||||
private var recordedBlock: (DrawScope.() -> Unit)? = null
|
||||
private var recordedDensity: Density = Density(1f)
|
||||
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
|
||||
private val drawScope = CanvasDrawScope()
|
||||
|
||||
override fun record(
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
size: IntSize,
|
||||
block: DrawScope.() -> Unit,
|
||||
) {
|
||||
recordedDensity = density
|
||||
recordedLayoutDirection = layoutDirection
|
||||
this.size = size
|
||||
recordedBlock = block
|
||||
}
|
||||
|
||||
override fun drawInto(canvas: Canvas) {
|
||||
val block = recordedBlock ?: return
|
||||
val w = size.width.toFloat()
|
||||
val h = size.height.toFloat()
|
||||
|
||||
canvas.save()
|
||||
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
|
||||
|
||||
// Transform about the pivot (default centre) — via the SAME matrix used for
|
||||
// hit-testing, so draw and hit-test agree.
|
||||
val hasTransform = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
|
||||
rotationX != 0f || rotationY != 0f || translationX != 0f || translationY != 0f
|
||||
if (hasTransform && w > 0f && h > 0f) {
|
||||
val pivotX = if (pivot.isUnspecified) w / 2f else pivot.x
|
||||
val pivotY = if (pivot.isUnspecified) h / 2f else pivot.y
|
||||
val matrix = Matrix()
|
||||
prepareLayerTransformationMatrix(
|
||||
matrix = matrix,
|
||||
pivotX = pivotX, pivotY = pivotY,
|
||||
translationX = translationX, translationY = translationY,
|
||||
rotationX = rotationX, rotationY = rotationY, rotationZ = rotationZ,
|
||||
scaleX = scaleX, scaleY = scaleY, cameraDistance = cameraDistance,
|
||||
)
|
||||
canvas.concat(matrix)
|
||||
}
|
||||
|
||||
// Drop shadow — layer-local, BEFORE the clip (shadow lives outside bounds).
|
||||
if (shadowElevation > 0f && w > 0f && h > 0f) {
|
||||
val outline = clipOutline
|
||||
if (outline != null) {
|
||||
(canvas as? NativeShadowCanvas)
|
||||
?.drawDropShadow(outline, shadowElevation, ambientShadowColor, spotShadowColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Content clip.
|
||||
if (clip && w > 0f && h > 0f) applyClip(canvas)
|
||||
|
||||
// Offscreen for alpha (behaviour-preserving: the old paths saveLayer'd on
|
||||
// alpha<1). ModulateAlpha suppresses the offscreen (alpha folds per-op) — but
|
||||
// our DrawScope has no per-op multiplier yet, so it still composites here.
|
||||
val needsAlphaLayer = alpha < 1f && w > 0f && h > 0f
|
||||
if (needsAlphaLayer) {
|
||||
canvas.saveLayer(
|
||||
Rect(0f, 0f, w, h),
|
||||
Paint().apply { alpha = this@DeferredRenderNode.alpha },
|
||||
)
|
||||
}
|
||||
|
||||
drawScope.draw(recordedDensity, recordedLayoutDirection, canvas, Size(w, h), block)
|
||||
|
||||
if (needsAlphaLayer) canvas.restore()
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun applyClip(canvas: Canvas) {
|
||||
when (val outline = clipOutline) {
|
||||
is Outline.Rectangle -> canvas.clipRect(
|
||||
outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom,
|
||||
)
|
||||
is Outline.Rounded -> {
|
||||
// Skia clips rounded natively via clipPath; the SDL canvas has no
|
||||
// path-clip primitive, so it implements NativeShapeClipCanvas and clips
|
||||
// the rounded outline via an offscreen mask (preferred over clipPath,
|
||||
// which falls back to the bounding rect there and left rounded state
|
||||
// layers looking square).
|
||||
val shapeClip = canvas as? NativeShapeClipCanvas
|
||||
if (shapeClip != null) {
|
||||
shapeClip.clipRoundRect(outline.roundRect)
|
||||
} else {
|
||||
val path = Path().apply { addRoundRect(outline.roundRect) }
|
||||
canvas.clipPath(path)
|
||||
}
|
||||
}
|
||||
is Outline.Generic -> canvas.clipPath(outline.path)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
recordedBlock = null
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.GraphicsContext
|
||||
|
||||
// ==================
|
||||
// MARK: createGraphicsContext — SDL3 renderer actual
|
||||
// ==================
|
||||
|
||||
/* The SDL leg uses the project record/replay GraphicsContext (backed by the SDL
|
||||
NativeRenderNode via createProjectGraphicsLayer). This is the permanent SDL home. */
|
||||
internal actual fun createGraphicsContext(): GraphicsContext = ProjectGraphicsContext()
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
|
||||
// ==================
|
||||
// MARK: GraphicsLayer factory — native actuals
|
||||
// ==================
|
||||
|
||||
internal fun createProjectGraphicsLayer(): GraphicsLayer =
|
||||
GraphicsLayer(createNativeRenderNode(NativeRenderNodeContext()))
|
||||
|
||||
internal fun releaseProjectGraphicsLayer(inLayer: GraphicsLayer) {
|
||||
inLayer.release()
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RenderEffect
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
|
||||
// ==================
|
||||
// MARK: NativeRenderNode — the renderer-agnostic retained display-list node
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* The one genuinely renderer-specific piece of the retained-layer engine. This is
|
||||
* the [GraphicsLayer] façade's backing node — the analogue of skiko's
|
||||
* `org.jetbrains.skiko.node.RenderNode`. [GraphicsLayer] (our copy-edit of upstream
|
||||
* `SkiaGraphicsLayer.skiko.kt`) mirrors every visual property onto this node, calls
|
||||
* [record] to capture the layer's drawing once, and [drawInto] to replay it every
|
||||
* frame with the transform / clip / shadow / compositing applied — WITHOUT
|
||||
* re-recording.
|
||||
*
|
||||
* Property contract mirrors skiko `RenderNode` (translation/scale/rotation/pivot/
|
||||
* alpha/shadow/clip/…), so the Skia actual wraps skiko `RenderNode` almost verbatim
|
||||
* and the SDL actual (`SdlRenderNode`) implements the same contract.
|
||||
*
|
||||
* [record]/[drawInto] are higher-level than skiko's `beginRecording()`/`drawInto()`
|
||||
* so BOTH a display-list impl (Skia: record into a skiko RenderNode; SDL later: a
|
||||
* cached-geometry / offscreen display list) AND the current deferred-replay impl
|
||||
* (SDL now: store the block, replay it under the transform) satisfy them.
|
||||
*/
|
||||
internal interface NativeRenderNode {
|
||||
|
||||
// ============
|
||||
// Geometry — where the recorded content sits and its transform origin.
|
||||
|
||||
var topLeft: IntOffset
|
||||
var size: IntSize
|
||||
var pivot: Offset
|
||||
|
||||
// ============
|
||||
// Transform — applied at replay, no re-record (matches skiko RenderNode).
|
||||
|
||||
var alpha: Float
|
||||
var scaleX: Float
|
||||
var scaleY: Float
|
||||
var translationX: Float
|
||||
var translationY: Float
|
||||
var rotationX: Float
|
||||
var rotationY: Float
|
||||
var rotationZ: Float
|
||||
var cameraDistance: Float
|
||||
|
||||
// ============
|
||||
// Elevation shadow.
|
||||
|
||||
var shadowElevation: Float
|
||||
var ambientShadowColor: Color
|
||||
var spotShadowColor: Color
|
||||
|
||||
// ============
|
||||
// Compositing — decides an offscreen (requiresLayer) and how content composites.
|
||||
|
||||
var blendMode: BlendMode
|
||||
var colorFilter: ColorFilter?
|
||||
var renderEffect: RenderEffect?
|
||||
var compositingStrategy: CompositingStrategy
|
||||
|
||||
// ============
|
||||
// Clip — outline pushed onto the node; rect / rounded-rect / generic path.
|
||||
|
||||
var clip: Boolean
|
||||
fun setClipRect(left: Float, top: Float, right: Float, bottom: Float)
|
||||
fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray)
|
||||
fun setClipPath(path: Path?)
|
||||
|
||||
// ============
|
||||
// Record / replay.
|
||||
|
||||
/** Capture the layer content once; re-called only when the layer is dirty. */
|
||||
fun record(density: Density, layoutDirection: LayoutDirection, size: IntSize, block: DrawScope.() -> Unit)
|
||||
|
||||
/** Replay the recorded content onto [canvas], applying this node's transform. */
|
||||
fun drawInto(canvas: Canvas)
|
||||
|
||||
/** Release native resources (textures / picture / display list). */
|
||||
fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-window context shared by the [NativeRenderNode]s of one composition — the
|
||||
* analogue of skiko's `RenderNodeContext`. Minimal today; the SDL caching impl will
|
||||
* grow it to carry the renderer handle it needs to allocate offscreen targets.
|
||||
*/
|
||||
internal class NativeRenderNodeContext(
|
||||
val measureDrawBounds: Boolean = false,
|
||||
)
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import com.compose.sdl.renderer.sdl.SdlDisplayListRenderNode
|
||||
import com.compose.sdl.renderer.sdl.SdlRenderNode
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.toKString
|
||||
|
||||
// ==================
|
||||
// MARK: createNativeRenderNode — SDL3 renderer actual
|
||||
// ==================
|
||||
|
||||
/* CDN_LAYERCACHE selects the retained node. The DEFAULT is now the cached-geometry
|
||||
node (Phase 4): it records each leaf's tessellated geometry + plain text runs ONCE
|
||||
and replays them under the layer transform — crisp/bit-exact at any transform, no
|
||||
render-target state, deterministic. A full 57-screen geo-vs-block-replay sweep is
|
||||
clean (55 at 0.000%; GraphicsLayer/ModShortcuts <0.13% cosmetic rotated-edge AA)
|
||||
and the former Carousel/Pickers diffs are gone, so it graduates from opt-in to
|
||||
default. See RENDERER.md §4b/§13.
|
||||
|
||||
Escape hatches (env override):
|
||||
off / defer / 0 → DeferredRenderNode (replay the block every frame; no caching —
|
||||
the previous default, kept as a fallback)
|
||||
1 / texture → SdlRenderNode (offscreen-texture cache — legacy; fast for
|
||||
static leaves but timing-nondeterministic
|
||||
on complex screens, so not the default)
|
||||
* → SdlDisplayListRenderNode (geo — the default) */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private val layerCacheMode: String by lazy {
|
||||
platform.posix.getenv("CDN_LAYERCACHE")?.toKString() ?: ""
|
||||
}
|
||||
|
||||
internal fun createNativeRenderNode(context: NativeRenderNodeContext): NativeRenderNode =
|
||||
when (layerCacheMode) {
|
||||
"off", "defer", "0" -> DeferredRenderNode()
|
||||
"1", "texture" -> SdlRenderNode()
|
||||
else -> SdlDisplayListRenderNode()
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.GraphicsContext
|
||||
import androidx.compose.ui.graphics.layer.GraphicsLayer
|
||||
|
||||
// ==================
|
||||
// MARK: ProjectGraphicsContext — SDL leg (record/replay GraphicsLayer)
|
||||
// ==================
|
||||
|
||||
/* The SDL leg's GraphicsContext: creates/releases the project record/replay
|
||||
GraphicsLayer (backed by the SDL NativeRenderNode). GraphicsLayer's `expect class`
|
||||
hides its constructor/release from common code, so layer creation/release hop
|
||||
through the [createProjectGraphicsLayer] / [releaseProjectGraphicsLayer] factories
|
||||
(co-located here). `shadowContext` keeps the GraphicsContext interface default. */
|
||||
internal class ProjectGraphicsContext : GraphicsContext {
|
||||
override fun createGraphicsLayer(): GraphicsLayer = createProjectGraphicsLayer()
|
||||
override fun releaseGraphicsLayer(layer: GraphicsLayer) = releaseProjectGraphicsLayer(layer)
|
||||
}
|
||||
+131
-7
@@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.drawscope.DrawStyle
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import com.compose.sdl.graphics.a8
|
||||
import com.compose.sdl.graphics.b8
|
||||
import com.compose.sdl.graphics.g8
|
||||
import com.compose.sdl.graphics.r8
|
||||
@@ -71,17 +72,87 @@ internal class Sdl3Canvas(
|
||||
// Alpha8 mask) so the tint applied on blit colours it correctly.
|
||||
private val fOffscreenTexture: COpaquePointer? = null,
|
||||
private val fForceWhite: Boolean = false,
|
||||
// Phase 4 capture mode: when set, this canvas RECORDS shape geometry into the list
|
||||
// (via the scope's recordingSink) and touches NO GPU. Ops not yet capturable
|
||||
// (text / image / clip / saveLayer / shadow) mark the list `unsupported` so the
|
||||
// SdlDisplayListRenderNode falls back to a crisp block-replay for that leaf — so
|
||||
// no un-captured op ever leaks to the screen. See SdlDisplayList.
|
||||
private val fCaptureList: SdlDisplayList? = null,
|
||||
) : Canvas,
|
||||
com.compose.sdl.text.NativeTextCanvas,
|
||||
com.compose.sdl.graphics.NativePainterCanvas,
|
||||
com.compose.sdl.graphics.NativeShapeClipCanvas,
|
||||
com.compose.sdl.graphics.NativeShadowCanvas {
|
||||
com.compose.sdl.graphics.NativeShadowCanvas,
|
||||
com.compose.sdl.graphics.NativeFinishableCanvas {
|
||||
|
||||
// The tessellating scope. It emits geometry in the canvas's user space; the
|
||||
// canvas hands it the current affine each draw (setMatrix) so scale / rotate /
|
||||
// translate reach the vertices.
|
||||
private val fScope = Sdl3DrawScope(fRenderer, 0f, 0f, fSize)
|
||||
|
||||
init {
|
||||
// Capture mode routes the scope's geometry to the display list instead of the GPU.
|
||||
if (fCaptureList != null) fScope.recordingSink = fCaptureList
|
||||
}
|
||||
|
||||
// Capture-mode gate for ops we can't record yet: flag the list unsupported (the
|
||||
// node will replay the block instead) and skip the GPU work. Returns true if the
|
||||
// caller should return early. No-op (false) outside capture mode.
|
||||
private fun captureUnsupported(): Boolean {
|
||||
val list = fCaptureList ?: return false
|
||||
list.unsupported = true
|
||||
return true
|
||||
}
|
||||
|
||||
// Replay a captured display list into this (real) canvas under its CURRENT
|
||||
// transform, in order (z-order preserved). Geometry batches re-emit their
|
||||
// layer-local vertices through the affine; text runs flush pending geometry then
|
||||
// re-look-up + blit at the transformed origin (glyph size stays logical). Used by
|
||||
// SdlDisplayListRenderNode.drawInto.
|
||||
internal fun replayDisplayList(list: SdlDisplayList) {
|
||||
fScope.flush()
|
||||
fScope.setMatrix(fMa, fMb, fMc, fMd, fMe, fMf)
|
||||
for (i in list.commands.indices) {
|
||||
when (val cmd = list.commands[i]) {
|
||||
is GeometryBatch -> fScope.replayBatch(cmd.vertexData, cmd.vertexCount)
|
||||
is TextRun -> {
|
||||
fScope.flush() // submit geometry before this run (z-order)
|
||||
// Transform the run ORIGIN by the affine; keep glyph size logical
|
||||
// (matches the immediate path: layer scale reaches position, not size).
|
||||
fTextRenderer?.blitRun(
|
||||
inFontFamily = cmd.fontFamily,
|
||||
inText = cmd.text,
|
||||
inFontSize = cmd.fontSizePx,
|
||||
inFontVariations = cmd.variations,
|
||||
inStyle = cmd.style,
|
||||
inDeviceX = mapX(cmd.x, cmd.y),
|
||||
inDeviceY = mapY(cmd.x, cmd.y),
|
||||
inLogW = cmd.w,
|
||||
inLogH = cmd.h,
|
||||
inColor = androidx.compose.ui.graphics.Color(cmd.colorArgb),
|
||||
)
|
||||
}
|
||||
is IconRun -> {
|
||||
fScope.flush() // submit geometry before this glyph (z-order)
|
||||
// Map the box origin through the affine; box size stays logical (the
|
||||
// glyph centres in it — layer scale reaches position, not glyph size).
|
||||
fTextRenderer?.blitIconRun(
|
||||
inFontFamily = cmd.fontFamily,
|
||||
inText = cmd.text,
|
||||
inFontSize = cmd.fontSizePx,
|
||||
inFontVariations = cmd.variations,
|
||||
inDeviceBoxX = mapX(cmd.boxX, cmd.boxY),
|
||||
inDeviceBoxY = mapY(cmd.boxX, cmd.boxY),
|
||||
inBoxW = cmd.boxW,
|
||||
inBoxH = cmd.boxH,
|
||||
inColor = androidx.compose.ui.graphics.Color(cmd.colorArgb),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fScope.flush()
|
||||
}
|
||||
|
||||
// Current user→screen affine (a,b,c,d,e,f): screen = (a*x+c*y+e, b*x+d*y+f).
|
||||
// Accumulates Canvas.translate/scale/rotate/concat; identity + a translate is
|
||||
// the common layout-positioning case.
|
||||
@@ -279,7 +350,7 @@ internal class Sdl3Canvas(
|
||||
|
||||
// Flushes any pending batched geometry to SDL, then frees the scope's
|
||||
// native buffer + clears the SDL clip. Call once per frame after the draw.
|
||||
fun finish() {
|
||||
override fun finish() {
|
||||
fPendingClips.clear()
|
||||
fScope.release()
|
||||
// Safety: an unbalanced save/restore must never leave the renderer pointed
|
||||
@@ -343,6 +414,14 @@ internal class Sdl3Canvas(
|
||||
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
||||
fSavedOffscreenTarget = SDL_GetRenderTarget(vRenderer)
|
||||
SDL_SetRenderTarget(vRenderer, vTex.reinterpret())
|
||||
// Clear to transparent. Render-target textures aren't auto-cleared, and a
|
||||
// retained-layer texture (SdlRenderNode) is REUSED across re-records — without
|
||||
// this, a re-record ghosts the previous content, which showed up as multi-frame
|
||||
// instability on churny screens (Pickers). Fresh textures happen to be zeroed
|
||||
// so static leaves were fine, but reuse needs an explicit clear. RenderClear
|
||||
// fills with the draw colour ignoring blend, so (0,0,0,0) = transparent.
|
||||
SDL_SetRenderDrawColor(vRenderer, 0u, 0u, 0u, 0u)
|
||||
SDL_RenderClear(vRenderer)
|
||||
fClip = null
|
||||
SDL_SetRenderClipRect(vRenderer, null)
|
||||
}
|
||||
@@ -355,6 +434,7 @@ internal class Sdl3Canvas(
|
||||
}
|
||||
|
||||
override fun saveLayer(bounds: Rect, paint: Paint) {
|
||||
if (captureUnsupported()) return
|
||||
// No real offscreen buffer — SDL3 lacks a portable render-target-in-a-batched-scope
|
||||
// primitive here. Instead, multiplicatively propagate the layer's alpha through
|
||||
// every enclosed primitive: each draw() reads `fAlpha` and multiplies against
|
||||
@@ -386,6 +466,7 @@ internal class Sdl3Canvas(
|
||||
}
|
||||
|
||||
override fun clipRect(left: Float, top: Float, right: Float, bottom: Float, clipOp: ClipOp) {
|
||||
if (captureUnsupported()) return
|
||||
if (clipOp == ClipOp.Difference) {
|
||||
clipRectDifference(left, top, right, bottom)
|
||||
return
|
||||
@@ -426,6 +507,7 @@ internal class Sdl3Canvas(
|
||||
}
|
||||
|
||||
override fun clipPath(path: ComposePath, clipOp: ClipOp) {
|
||||
if (captureUnsupported()) return
|
||||
// SDL has only rectangular render clipping; approximate a path clip by
|
||||
// its bounding box (TODO: stencil/mask for non-rect clips).
|
||||
val vB = pathBounds(path) ?: return
|
||||
@@ -466,6 +548,7 @@ internal class Sdl3Canvas(
|
||||
// arbitrary paths keep the clipPath bbox fallback.
|
||||
|
||||
override fun clipRoundRect(inRoundRect: RoundRect) {
|
||||
if (captureUnsupported()) return
|
||||
// The mask is cut in DEVICE space (the bbox below goes through the
|
||||
// affine), but the corner radii arrive in LOCAL user space — scale
|
||||
// them by the affine too, or a graphicsLayer-scaled circle clip cuts
|
||||
@@ -705,6 +788,7 @@ internal class Sdl3Canvas(
|
||||
inAmbientColor: androidx.compose.ui.graphics.Color,
|
||||
inSpotColor: androidx.compose.ui.graphics.Color,
|
||||
) {
|
||||
if (captureUnsupported()) return
|
||||
realizePendingClips()
|
||||
if (inElevationPx <= 0f) return
|
||||
|
||||
@@ -958,7 +1042,26 @@ internal class Sdl3Canvas(
|
||||
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?,
|
||||
inBaseItalic: Boolean,
|
||||
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
|
||||
inLineHeightPx: Float,
|
||||
inTrimFirstLine: Boolean,
|
||||
) {
|
||||
// Capture mode: icon-font glyphs (Material Symbols via FreeType) record as an
|
||||
// IconRun — flush pending geometry first for z-order, then capture the box in
|
||||
// LAYER-LOCAL device coords (replay maps it through the layer affine + re-draws
|
||||
// via FreeTypeIcons' cache). Plain/decorated AND spanned text ARE captured too
|
||||
// (runSink set below; each styled run becomes a TextRun) — only a run with a
|
||||
// SpanStyle.background still bails inside Sdl3TextRenderer.drawText.
|
||||
val vCaptureList = fCaptureList
|
||||
if (vCaptureList != null && inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
|
||||
fScope.flush()
|
||||
val vIconColor = if (fAlpha >= 1f) inColor else inColor.copy(alpha = inColor.alpha * fAlpha)
|
||||
val vArgb = (vIconColor.a8 shl 24) or (vIconColor.r8 shl 16) or (vIconColor.g8 shl 8) or vIconColor.b8
|
||||
vCaptureList.captureIconRun(
|
||||
inFontFamily, inText, inFontSizePx, inFontVariations,
|
||||
mapX(inX, inY), mapY(inX, inY), inBoxWidth, inBoxHeight, vArgb,
|
||||
)
|
||||
return
|
||||
}
|
||||
// Small centred labels (a popped bubble's "pop") usually sit fully
|
||||
// inside a rounded clip — gate on containment like other draws, with
|
||||
// a 2px margin for glyph overhang / AA bleed, instead of paying a
|
||||
@@ -1016,21 +1119,38 @@ internal class Sdl3Canvas(
|
||||
// right edge instead of wrapping when the sidebar was resized narrower.
|
||||
val vWrapWidth = if (inSoftWrap && inBoxWidth > 0f) inBoxWidth.toInt() else Int.MAX_VALUE
|
||||
val vWrapped = vRenderer.wrap(inText, inFontSizePx, vWrapWidth, inFontFamily, inFontVariations)
|
||||
val vBaseLineH = vRenderer.lineHeight(inFontSizePx, inFontFamily, inFontVariations)
|
||||
// Line stacking mirrors SdlParagraph's per-mode model: compat trim (raw styles)
|
||||
// keeps the FIRST line at the tight font cell; M3's Trim.None (inTrimFirstLine =
|
||||
// false) makes every line the full band. Following lines always advance by the
|
||||
// band (inLineHeightPx) when supplied, else the font cell.
|
||||
val vFontCellH = vRenderer.lineHeight(inFontSizePx, inFontFamily, inFontVariations)
|
||||
val vAdvance = if (inLineHeightPx > 0f) inLineHeightPx else vFontCellH
|
||||
val vFirstLineH = if (inTrimFirstLine || inLineHeightPx <= 0f) vFontCellH else vAdvance
|
||||
// Per-run fontSize spans make a line's box the TALLEST run cell on it —
|
||||
// same styledLineCellHeight the paragraph measured with, so paint stacks
|
||||
// lines exactly where layout put them.
|
||||
val vMetricSpans = com.compose.sdl.text.spansAffectMetrics(inSpans)
|
||||
|
||||
// Capture mode: route each plain line's run to the display list (spanned lines
|
||||
// bail inside drawText via markUnsupported). Reset after the loop. No-op when
|
||||
// not capturing (vCaptureList == null).
|
||||
fTextRenderer?.runSink = vCaptureList
|
||||
|
||||
var vLineY = inY
|
||||
for ((vIdx, vLine) in vWrapped.lines.withIndex()) {
|
||||
val vLineH =
|
||||
if (vMetricSpans && inSpans != null) {
|
||||
com.compose.sdl.text.styledLineCellHeight(
|
||||
vLine, vWrapped.lineStarts.getOrElse(vIdx) { 0 }, inSpans,
|
||||
inFontSizePx, vTr.dpr, vRenderer, inFontFamily, inFontVariations,
|
||||
// Size spans can exceed the band, never shrink below it — the same
|
||||
// max() the paragraph's lineHeights used, so paint tracks layout
|
||||
// (first line keeps its tight cell under compat trim).
|
||||
kotlin.math.max(
|
||||
com.compose.sdl.text.styledLineCellHeight(
|
||||
vLine, vWrapped.lineStarts.getOrElse(vIdx) { 0 }, inSpans,
|
||||
inFontSizePx, vTr.dpr, vRenderer, inFontFamily, inFontVariations,
|
||||
),
|
||||
if ((vIdx == 0 && inTrimFirstLine) || inLineHeightPx <= 0f) 0f else inLineHeightPx,
|
||||
)
|
||||
} else vBaseLineH
|
||||
} else if (vIdx == 0) vFirstLineH else vAdvance
|
||||
// Cull lines that would fall entirely below the box (softWrap keeps the
|
||||
// natural line count; the box just clips at draw time).
|
||||
if (vLineY >= inY + inBoxHeight) break
|
||||
@@ -1056,6 +1176,7 @@ internal class Sdl3Canvas(
|
||||
)
|
||||
vLineY += vLineH
|
||||
}
|
||||
fTextRenderer?.runSink = null
|
||||
}
|
||||
|
||||
// ============
|
||||
@@ -1072,6 +1193,7 @@ internal class Sdl3Canvas(
|
||||
inContentScale: androidx.compose.ui.layout.ContentScale,
|
||||
inAlpha: Float,
|
||||
) {
|
||||
if (captureUnsupported()) return
|
||||
admitDraw(inX, inY, inX + inWidth, inY + inHeight)
|
||||
fScope.flush()
|
||||
// Map the origin through the affine and scale the size by the matrix's axis
|
||||
@@ -1090,6 +1212,7 @@ internal class Sdl3Canvas(
|
||||
// Not-yet-wired ops (B5 image leaf / B6 layers). Accept-and-ignore.
|
||||
|
||||
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
|
||||
if (captureUnsupported()) return
|
||||
val vBmp = image as? SdlImageBitmap ?: return
|
||||
admitDraw(topLeftOffset.x, topLeftOffset.y, topLeftOffset.x + vBmp.width, topLeftOffset.y + vBmp.height)
|
||||
drawImageRect(
|
||||
@@ -1114,6 +1237,7 @@ internal class Sdl3Canvas(
|
||||
dstSize: androidx.compose.ui.unit.IntSize,
|
||||
paint: Paint,
|
||||
) {
|
||||
if (captureUnsupported()) return
|
||||
realizePendingClips()
|
||||
val vTex = (image as? SdlImageBitmap)?.texture ?: return
|
||||
com.compose.sdl.graphics.DrawStats.imageBlits++
|
||||
|
||||
+38
-1
@@ -112,6 +112,14 @@ internal class Sdl3DrawScope(
|
||||
internal fun setMatrix(a: Float, b: Float, c: Float, d: Float, e: Float, f: Float) {
|
||||
fMa = a; fMb = b; fMc = c; fMd = d; fMe = e; fMf = f
|
||||
}
|
||||
// Current device affine (a,b,c,d,e,f) — screen = (a*x+c*y+e, b*x+d*y+f). Phase 4
|
||||
// replay reads this to re-transform captured layer-local vertices.
|
||||
internal fun currentDeviceMatrix(): FloatArray = floatArrayOf(fMa, fMb, fMc, fMd, fMe, fMf)
|
||||
|
||||
// Phase 4: when set, flush() CAPTURES the tessellated batch (layer-local, if this
|
||||
// scope's base CTM is identity) instead of submitting to the GPU. null = normal
|
||||
// immediate submit. See SdlDisplayList.
|
||||
internal var recordingSink: GeometrySink? = null
|
||||
|
||||
// ============
|
||||
// Vertex batch — heap-allocated buffer of SDL_Vertex shared across
|
||||
@@ -133,6 +141,13 @@ internal class Sdl3DrawScope(
|
||||
call mid-scope when the buffer fills up (no cross-triangle state). */
|
||||
fun flush() {
|
||||
if (fBatchCount == 0) return
|
||||
val vSink = recordingSink
|
||||
if (vSink != null) {
|
||||
// Phase 4 recording: capture the batch (layer-local) instead of submitting.
|
||||
vSink.captureGeometry(fVertexData, fBatchCount)
|
||||
fBatchCount = 0
|
||||
return
|
||||
}
|
||||
fVertexData.usePinned { vPinned ->
|
||||
platform.posix.memcpy(
|
||||
fBatch,
|
||||
@@ -932,6 +947,28 @@ internal class Sdl3DrawScope(
|
||||
emitFringeQuad(x0, y0, x1, y1, x1 + inNx, y1 + inNy, x0 + inNx, y0 + inNy, inSampler)
|
||||
}
|
||||
|
||||
// Phase 4 replay: re-emit a captured (layer-local) batch through the CURRENT CTM.
|
||||
// The batch was recorded with an identity base CTM, so applying fMa..fMf here
|
||||
// places it at the layer's transform. Colours/UVs pass through unchanged. Runs
|
||||
// in NORMAL (non-recording) mode → the next flush() submits via SDL_RenderGeometry.
|
||||
internal fun replayBatch(vd: FloatArray, count: Int) {
|
||||
for (i in 0 until count) {
|
||||
if (fBatchCount >= kBatchCapacity && fBatchCount % 3 == 0) flush()
|
||||
val s = i * kFloatsPerVertex
|
||||
val x = vd[s]; val y = vd[s + 1]
|
||||
val base = fBatchCount * kFloatsPerVertex
|
||||
fVertexData[base + 0] = fMa * x + fMc * y + fMe
|
||||
fVertexData[base + 1] = fMb * x + fMd * y + fMf
|
||||
fVertexData[base + 2] = vd[s + 2]
|
||||
fVertexData[base + 3] = vd[s + 3]
|
||||
fVertexData[base + 4] = vd[s + 4]
|
||||
fVertexData[base + 5] = vd[s + 5]
|
||||
fVertexData[base + 6] = vd[s + 6]
|
||||
fVertexData[base + 7] = vd[s + 7]
|
||||
fBatchCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Stage one vertex into fVertexData (auto-flush when full). Position goes
|
||||
// through the current affine so scale/rotate reach the GPU; the colour is
|
||||
// sampled at the pre-transform point so a gradient rides its shape.
|
||||
@@ -973,7 +1010,7 @@ private const val kBatchCapacity: Int = 8190
|
||||
|
||||
// Floats per SDL_Vertex: position(x,y) + color(r,g,b,a) + tex_coord(x,y), tightly
|
||||
// packed. Used to stage vertices in a Kotlin FloatArray and memcpy them across.
|
||||
private const val kFloatsPerVertex: Int = 8
|
||||
internal const val kFloatsPerVertex: Int = 8
|
||||
|
||||
// ==================
|
||||
// MARK: Brush → per-vertex colour sampler
|
||||
|
||||
@@ -157,6 +157,12 @@ internal class Sdl3OffscreenRenderer(
|
||||
fForceWhite = vBmp.config == ImageBitmapConfig.Alpha8,
|
||||
)
|
||||
}
|
||||
|
||||
// Phase 4: a capture canvas records shape geometry into [list] and touches NO GPU
|
||||
// (text/image/clip mark the list unsupported). Used by SdlDisplayListRenderNode to
|
||||
// record a leaf's layer-local geometry. No render target — no timing nondeterminism.
|
||||
internal fun createCaptureCanvas(list: SdlDisplayList, size: Size): Sdl3Canvas =
|
||||
Sdl3Canvas(fRenderer, size, fTextRenderer, fCaptureList = list)
|
||||
}
|
||||
|
||||
// ==================
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ internal class Sdl3RenderBackend(private val backend: SDL3Backend) : RenderBacke
|
||||
// Paint the upstream LayoutNode tree through the vendored pipeline.
|
||||
// inHost.rootNode.draw → NodeCoordinator.draw → DrawModifierNode → CanvasDrawScope
|
||||
// → Sdl3Canvas → SDL_RenderGeometry.
|
||||
override fun drawRoot(inHost: com.compose.sdl.node.ComposeRootHost) {
|
||||
override fun drawRoot(inDraw: (androidx.compose.ui.graphics.Canvas) -> Unit) {
|
||||
val vRenderer = backend.renderer ?: return
|
||||
val vClipTargets = fClipTargets ?: Sdl3ClipTargets(vRenderer).also { fClipTargets = it }
|
||||
val vShadowCache = fShadowCache ?: Sdl3ShadowCache(vRenderer).also { fShadowCache = it }
|
||||
@@ -108,7 +108,7 @@ internal class Sdl3RenderBackend(private val backend: SDL3Backend) : RenderBacke
|
||||
// Expose the frame canvas so an offscreen render can flush it before borrowing
|
||||
// the render target (z-order).
|
||||
currentMainCanvas = vCanvas
|
||||
inHost.rootNode.draw(vCanvas, null)
|
||||
inDraw(vCanvas)
|
||||
vCanvas.finish()
|
||||
currentMainCanvas = null
|
||||
}
|
||||
|
||||
+189
-8
@@ -53,6 +53,13 @@ import sdl3_ttf.TTF_StringToTag
|
||||
import sdl3.SDL_IOFromConstMem
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.unit.Density
|
||||
import freetype.FT_Done_Face
|
||||
import freetype.FT_Done_FreeType
|
||||
import freetype.FT_FaceVar
|
||||
import freetype.FT_Init_FreeType
|
||||
import freetype.FT_Library
|
||||
import freetype.FT_LibraryVar
|
||||
import freetype.FT_New_Memory_Face
|
||||
|
||||
// ==================
|
||||
// MARK: Sdl3TextRenderer (mingwX64 fallback)
|
||||
@@ -176,6 +183,11 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
// Cap-and-clear (not LRU): lookups stay a single hash op on the hot measure
|
||||
// path, and re-measuring after a rare full clear is cheap.
|
||||
private data class WidthKey(val family: String?, val text: String, val fontSize: Int, val variations: String, val style: Int)
|
||||
// Phase 4: when set, drawText CAPTURES a plain run (text + params) into the display
|
||||
// list instead of blitting — eviction-safe (replay re-looks-up the cache). Set only
|
||||
// for plain (no-span) text by Sdl3Canvas capture mode; spanned/icon text bails.
|
||||
internal var runSink: TextRunSink? = null
|
||||
|
||||
private val fWidthCache = mutableMapOf<WidthKey, Int>()
|
||||
|
||||
/* Returns false if TTF couldn't init. */
|
||||
@@ -194,6 +206,9 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
for ((vMem, _) in fFontMem.values) nativeHeap.free(vMem)
|
||||
fFontMem.clear()
|
||||
fMissingFamilies.clear()
|
||||
fCellRatios.clear()
|
||||
fMetricsLib?.let { FT_Done_FreeType(it) }
|
||||
fMetricsLib = null
|
||||
fFreeTypeIcons.destroy()
|
||||
TTF_Quit()
|
||||
}
|
||||
@@ -214,12 +229,66 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
|
||||
override fun lineHeight(inFontSize: Int, inFontFamily: String?, inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?): Float {
|
||||
val vFont = getFont(inFontFamily, inFontSize, inFontVariations) ?: return inFontSize * 1.3f
|
||||
// Skia-aligned cell for TEXT fonts (P3.1): Skia scales the face's hhea
|
||||
// metrics linearly and the paragraph rounds to nearest, while
|
||||
// TTF_GetFontHeight reports the grid-fitted CEILED int — +1px at the M3
|
||||
// body sizes (12, 14), accumulating ~1px per stacked label down every
|
||||
// parity page. Icon families keep the TTF value: their glyph boxes were
|
||||
// tuned against it and Skia never renders them.
|
||||
if (inFontFamily == null || !IconFont.isIconFamily(inFontFamily)) {
|
||||
cellRatios(inFontFamily)?.let { vRatios ->
|
||||
val vPhysical = (inFontSize * fDpr).coerceAtLeast(1f)
|
||||
return (kotlin.math.round(vRatios.cell * vPhysical) / fDpr).coerceAtLeast(1f)
|
||||
}
|
||||
}
|
||||
// TTF_GetFontHeight returns physical pixels (we opened the
|
||||
// font at fontSize * DPR). Convert back to logical.
|
||||
return (TTF_GetFontHeight(vFont.reinterpret()).toFloat() / fDpr).coerceAtLeast(1f)
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Unrounded face metrics (P3.1) — read hhea ascender/descender/upem once per
|
||||
// family straight from the font bytes via a metrics-only FreeType library
|
||||
// (SDL3_ttf exposes only grid-fitted ints). Ratios are size-independent.
|
||||
private var fMetricsLib: FT_Library? = null
|
||||
private data class CellRatios(val cell: Float, val ascent: Float)
|
||||
private val fCellRatios = mutableMapOf<String?, CellRatios?>()
|
||||
|
||||
private fun cellRatios(inFamily: String?): CellRatios? {
|
||||
if (fCellRatios.containsKey(inFamily)) return fCellRatios[inFamily]
|
||||
val vComputed = computeCellRatios(inFamily)
|
||||
fCellRatios[inFamily] = vComputed
|
||||
return vComputed
|
||||
}
|
||||
|
||||
private fun computeCellRatios(inFamily: String?): CellRatios? {
|
||||
// getFont() has already staged the family bytes into fFontMem (lineHeight
|
||||
// calls it first); absent bytes = system-font fallback -> no ratios.
|
||||
val vSlot = fFontMem[inFamily] ?: return null
|
||||
if (fMetricsLib == null) {
|
||||
memScoped {
|
||||
val vLibPtr = alloc<FT_LibraryVar>()
|
||||
if (FT_Init_FreeType(vLibPtr.ptr).toInt() == 0) fMetricsLib = vLibPtr.value
|
||||
}
|
||||
}
|
||||
val vLib = fMetricsLib ?: return null
|
||||
memScoped {
|
||||
val vFacePtr = alloc<FT_FaceVar>()
|
||||
val vErr = FT_New_Memory_Face(vLib, vSlot.first.reinterpret(), vSlot.second.convert(), 0, vFacePtr.ptr)
|
||||
if (vErr.toInt() != 0 || vFacePtr.value == null) return null
|
||||
val vFace = vFacePtr.value!!.pointed
|
||||
val vUpem = vFace.units_per_EM.toFloat()
|
||||
val vRatios =
|
||||
if (vUpem > 0f) CellRatios(
|
||||
cell = (vFace.ascender - vFace.descender).toFloat() / vUpem,
|
||||
ascent = vFace.ascender.toFloat() / vUpem,
|
||||
) else null
|
||||
FT_Done_Face(vFacePtr.value)
|
||||
return vRatios
|
||||
}
|
||||
}
|
||||
|
||||
/* Greedy soft-wrap that mirrors the Skia renderer's algorithm so the
|
||||
cross-platform behaviour stays identical. Hard lines on '\n'; long
|
||||
lines split at whitespace, ultra-long words split mid-word. */
|
||||
@@ -422,6 +491,11 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
// blit each as its own texture at its prefix x. Tabs expand for both the
|
||||
// prefix measure and the texture so offsets and glyphs agree.
|
||||
if (inSpans != null) {
|
||||
// Capture mode: record each styled run as its own TextRun command (like the
|
||||
// plain path) — replay re-looks-up the per-run segment texture, eviction-safe.
|
||||
// A run wanting a SpanStyle.background bails to block-replay (the background
|
||||
// fill isn't a captured command yet); rare — highlight/selection spans.
|
||||
val vCapture = runSink
|
||||
// O(spans + line length) style runs (colour + weight + italic +
|
||||
// background + decoration + size), instead of an O(chars × spans)
|
||||
// per-character scan.
|
||||
@@ -441,6 +515,16 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
(if (inRun.italic) kStyleItalic else 0) or
|
||||
(if (inRun.underline) kStyleUnderline else 0) or
|
||||
(if (inRun.lineThrough) kStyleStrikethrough else 0)
|
||||
// Background fills aren't a captured command yet — defer the whole leaf if
|
||||
// any run wants one (rare: highlight/selection spans). Also keeps the
|
||||
// background SDL_RenderFillRect below from firing during a capture pass.
|
||||
if (vCapture != null) {
|
||||
for (vRun in vRuns) {
|
||||
if (vRun.background != ComposeColor.Unspecified && vRun.background.alpha > 0f) {
|
||||
vCapture.markUnsupported(); return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Line width = sum of each run's width at ITS style (for alignment).
|
||||
var vLineW = 0f
|
||||
for (vRun in vRuns) vLineW += measureWidth(runSeg(vRun), runPx(vRun), inFontFamily, runVars(vRun), runStyle(vRun)).toFloat()
|
||||
@@ -499,17 +583,27 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
}
|
||||
val vCachedSeg = getOrCreateTexture(inFontFamily, vSeg, vPx, vVars, vStyle)
|
||||
if (vCachedSeg != null) {
|
||||
applyTint(vCachedSeg.tex, vRun.color)
|
||||
val vLogW = vCachedSeg.w / fDpr
|
||||
val vLogH = vCachedSeg.h / fDpr
|
||||
val vPenY = vBaselineY - fontAscent(vPx, inFontFamily, vVars)
|
||||
memScoped {
|
||||
val vDst = alloc<SDL_FRect>()
|
||||
vDst.x = snap(vRunX)
|
||||
vDst.y = snap(vPenY)
|
||||
vDst.w = vLogW
|
||||
vDst.h = vLogH
|
||||
SDL_RenderTexture(vRenderer.reinterpret(), vCachedSeg.tex.reinterpret(), null, vDst.ptr)
|
||||
if (vCapture != null) {
|
||||
// Record params (not the texture pointer) — eviction-safe replay.
|
||||
val vArgb = (vRun.color.a8 shl 24) or (vRun.color.r8 shl 16) or
|
||||
(vRun.color.g8 shl 8) or vRun.color.b8
|
||||
vCapture.captureTextRun(
|
||||
inFontFamily, vSeg, vPx, vVars, vStyle,
|
||||
snap(vRunX), snap(vPenY), vLogW, vLogH, vArgb,
|
||||
)
|
||||
} else {
|
||||
applyTint(vCachedSeg.tex, vRun.color)
|
||||
memScoped {
|
||||
val vDst = alloc<SDL_FRect>()
|
||||
vDst.x = snap(vRunX)
|
||||
vDst.y = snap(vPenY)
|
||||
vDst.w = vLogW
|
||||
vDst.h = vLogH
|
||||
SDL_RenderTexture(vRenderer.reinterpret(), vCachedSeg.tex.reinterpret(), null, vDst.ptr)
|
||||
}
|
||||
}
|
||||
}
|
||||
vRunX += vAdvance
|
||||
@@ -544,6 +638,18 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
// round(v * dpr) / dpr keeps the blit 1:1, matching Skia's crispness.
|
||||
fun snap(inV: Float): Float = kotlin.math.round(inV * fDpr) / fDpr
|
||||
|
||||
val vSink = runSink
|
||||
if (vSink != null) {
|
||||
// Capture the plain run for eviction-safe retained replay — params, NOT the
|
||||
// texture pointer (the LRU may evict it; replay re-looks-up). Snapped
|
||||
// layer-local dst; replay maps the origin through the layer affine.
|
||||
val vArgb = (inColor.a8 shl 24) or (inColor.r8 shl 16) or (inColor.g8 shl 8) or inColor.b8
|
||||
vSink.captureTextRun(
|
||||
inFontFamily, vText, inFontSize, inFontVariations, vBaseStyle,
|
||||
snap(vPenX), snap(vPenY), vLogW, vLogH, vArgb,
|
||||
)
|
||||
return
|
||||
}
|
||||
memScoped {
|
||||
val vDst = alloc<SDL_FRect>()
|
||||
vDst.x = snap(vPenX)
|
||||
@@ -554,6 +660,81 @@ internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4 replay: blit a captured plain run. Re-looks-up the run texture
|
||||
// (eviction-safe — re-rasterises if the LRU dropped it), applies the tint, and
|
||||
// blits at the given DEVICE origin (already mapped through the layer affine) at
|
||||
// logical size (matching the immediate path: layer scale reaches position, not size).
|
||||
internal fun blitRun(
|
||||
inFontFamily: String?,
|
||||
inText: String,
|
||||
inFontSize: Int,
|
||||
inFontVariations: List<FontVariation.Setting>?,
|
||||
inStyle: Int,
|
||||
inDeviceX: Float,
|
||||
inDeviceY: Float,
|
||||
inLogW: Float,
|
||||
inLogH: Float,
|
||||
inColor: ComposeColor,
|
||||
) {
|
||||
val vRenderer = backend.renderer ?: return
|
||||
val vCached = getOrCreateTexture(inFontFamily, inText, inFontSize, inFontVariations, inStyle) ?: return
|
||||
applyTint(vCached.tex, inColor)
|
||||
memScoped {
|
||||
val vDst = alloc<SDL_FRect>()
|
||||
vDst.x = kotlin.math.round(inDeviceX * fDpr) / fDpr
|
||||
vDst.y = kotlin.math.round(inDeviceY * fDpr) / fDpr
|
||||
vDst.w = inLogW
|
||||
vDst.h = inLogH
|
||||
SDL_RenderTexture(vRenderer.reinterpret(), vCached.tex.reinterpret(), null, vDst.ptr)
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 4 replay: re-draw a captured icon glyph. Mirrors drawNativeText's icon
|
||||
branch — FreeType glyph first (its own cache re-rasterises on eviction), then the
|
||||
SDL_ttf fallback for a codepoint the FreeType path can't draw (rare: a glyph
|
||||
absent from the subset font). Box origin is DEVICE (already mapped through the
|
||||
layer affine); the glyph centres in the logical box. */
|
||||
internal fun blitIconRun(
|
||||
inFontFamily: String,
|
||||
inText: String,
|
||||
inFontSize: Int,
|
||||
inFontVariations: List<FontVariation.Setting>?,
|
||||
inDeviceBoxX: Float,
|
||||
inDeviceBoxY: Float,
|
||||
inBoxW: Float,
|
||||
inBoxH: Float,
|
||||
inColor: ComposeColor,
|
||||
) {
|
||||
val vRenderer = backend.renderer ?: return
|
||||
val vDrew = fFreeTypeIcons.drawGlyph(
|
||||
inSdlRenderer = vRenderer,
|
||||
inFamily = inFontFamily,
|
||||
inCodepoint = inText.codePointAtSafe(0),
|
||||
inPixelSize = inFontSize,
|
||||
inColor = inColor,
|
||||
inVariations = inFontVariations ?: emptyList(),
|
||||
inBoxX = inDeviceBoxX.toInt(),
|
||||
inBoxY = inDeviceBoxY.toInt(),
|
||||
inBoxW = inBoxW.toInt(),
|
||||
inBoxH = inBoxH.toInt(),
|
||||
inDpr = fDpr,
|
||||
)
|
||||
if (vDrew) return
|
||||
// SDL_ttf fallback — render the codepoint as a normal glyph, centred in the box.
|
||||
val vCached = getOrCreateTexture(inFontFamily, inText, inFontSize, inFontVariations, 0) ?: return
|
||||
applyTint(vCached.tex, inColor)
|
||||
val vLogW = vCached.w / fDpr
|
||||
val vLogH = vCached.h / fDpr
|
||||
memScoped {
|
||||
val vDst = alloc<SDL_FRect>()
|
||||
vDst.x = kotlin.math.round((inDeviceBoxX + (inBoxW - vLogW) / 2f) * fDpr) / fDpr
|
||||
vDst.y = kotlin.math.round((inDeviceBoxY + (inBoxH - vLogH) / 2f) * fDpr) / fDpr
|
||||
vDst.w = vLogW
|
||||
vDst.h = vLogH
|
||||
SDL_RenderTexture(vRenderer.reinterpret(), vCached.tex.reinterpret(), null, vDst.ptr)
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage gamma: alpha' = 255*(alpha/255)^kTextGamma. kTextGamma < 1
|
||||
// boosts partial coverage so antialiased stems read heavier and smoother
|
||||
// for light text on a dark background — closer to Skia's gamma-corrected
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.compose.sdl.renderer.sdl
|
||||
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
|
||||
// ==================
|
||||
// MARK: SdlDisplayList — captured draw commands for retained replay
|
||||
// ==================
|
||||
|
||||
/*
|
||||
Phase 4 (RENDERER.md §3/§13). A display list is a layer's drawing captured
|
||||
ONCE in layer-local space and replayed under the layer transform — instead of
|
||||
re-tessellating every frame (DeferredRenderNode) or round-tripping through an
|
||||
offscreen texture (SdlRenderNode: fixed-resolution soft + timing-nondeterministic).
|
||||
|
||||
It is an ORDERED command stream (z-order preserved) mixing three command kinds, each
|
||||
chosen to avoid the expensive recompute the way upstream skiko's Picture + Skia
|
||||
caches do, adapted to SDL:
|
||||
* GeometryBatch — tessellated untextured triangles (layer-local). SDL has no path/
|
||||
tessellation cache, so we cache the vertices (skiko relies on Skia's GPU path
|
||||
cache instead). Owned float copy ⇒ always safe.
|
||||
* TextRun — a "draw this run" command by PARAMETERS, mirroring skiko recording a
|
||||
glyph-run into a Picture. Replay re-looks-up the run texture via the text
|
||||
renderer's own per-run LRU (our analog of Skia's glyph atlas); if the cache
|
||||
evicted it, the lookup re-rasterises — so it NEVER holds a dangling texture
|
||||
pointer. Covers PLAIN text AND spanned text (one TextRun per styled run); only a
|
||||
run with a SpanStyle.background still defers (the background fill isn't captured).
|
||||
* IconRun — a Material Symbols glyph by PARAMETERS; replay re-draws via FreeTypeIcons'
|
||||
own glyph cache (eviction-safe), same lifetime story as TextRun.
|
||||
|
||||
`unsupported` trips on any op not yet capturable (image blits, saveLayer, shadow, a
|
||||
rounded/generic LAYER clip, alpha/blend/colorFilter/renderEffect, span backgrounds)
|
||||
so the node falls back to a crisp block-replay — nothing un-captured ever leaks to
|
||||
the GPU.
|
||||
*/
|
||||
|
||||
internal sealed class DisplayCommand
|
||||
|
||||
/* One captured untextured triangle batch in layer-local coords (8 floats/vertex:
|
||||
pos.xy, color.rgba, tex.xy — Sdl3DrawScope.fVertexData packing). */
|
||||
internal class GeometryBatch(
|
||||
val vertexData: FloatArray,
|
||||
val vertexCount: Int,
|
||||
) : DisplayCommand()
|
||||
|
||||
/* One captured plain text run — replayed by re-looking-up its cached texture
|
||||
(eviction-safe) and blitting at the transformed origin (glyph size stays logical,
|
||||
matching the immediate path's "layer scale reaches position, not glyph size"). */
|
||||
internal class TextRun(
|
||||
val fontFamily: String?,
|
||||
val text: String,
|
||||
val fontSizePx: Int,
|
||||
val variations: List<FontVariation.Setting>?,
|
||||
val style: Int,
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val w: Float,
|
||||
val h: Float,
|
||||
val colorArgb: Int,
|
||||
) : DisplayCommand()
|
||||
|
||||
/* One captured icon-font glyph (Material Symbols via FreeType). The box origin is
|
||||
layer-local (replay maps it through the layer affine); the box size stays logical
|
||||
(the glyph centres in it, like the immediate icon path). Replay re-draws through
|
||||
FreeTypeIcons' own glyph cache — eviction-safe (re-rasterises if dropped). */
|
||||
internal class IconRun(
|
||||
val fontFamily: String,
|
||||
val text: String,
|
||||
val fontSizePx: Int,
|
||||
val variations: List<FontVariation.Setting>?,
|
||||
val boxX: Float,
|
||||
val boxY: Float,
|
||||
val boxW: Float,
|
||||
val boxH: Float,
|
||||
val colorArgb: Int,
|
||||
) : DisplayCommand()
|
||||
|
||||
/* Sink the drawscope + text renderer capture into during a recording. */
|
||||
internal interface GeometrySink {
|
||||
fun captureGeometry(vertexData: FloatArray, vertexCount: Int)
|
||||
}
|
||||
internal interface TextRunSink {
|
||||
fun captureTextRun(
|
||||
fontFamily: String?,
|
||||
text: String,
|
||||
fontSizePx: Int,
|
||||
variations: List<FontVariation.Setting>?,
|
||||
style: Int,
|
||||
x: Float,
|
||||
y: Float,
|
||||
w: Float,
|
||||
h: Float,
|
||||
colorArgb: Int,
|
||||
)
|
||||
fun markUnsupported()
|
||||
}
|
||||
|
||||
internal class SdlDisplayList : GeometrySink, TextRunSink {
|
||||
val commands = ArrayList<DisplayCommand>()
|
||||
var unsupported = false
|
||||
|
||||
override fun captureGeometry(vertexData: FloatArray, vertexCount: Int) {
|
||||
if (vertexCount <= 0) return
|
||||
commands.add(GeometryBatch(vertexData.copyOf(vertexCount * kFloatsPerVertex), vertexCount))
|
||||
}
|
||||
|
||||
override fun captureTextRun(
|
||||
fontFamily: String?,
|
||||
text: String,
|
||||
fontSizePx: Int,
|
||||
variations: List<FontVariation.Setting>?,
|
||||
style: Int,
|
||||
x: Float,
|
||||
y: Float,
|
||||
w: Float,
|
||||
h: Float,
|
||||
colorArgb: Int,
|
||||
) {
|
||||
commands.add(TextRun(fontFamily, text, fontSizePx, variations, style, x, y, w, h, colorArgb))
|
||||
}
|
||||
|
||||
override fun markUnsupported() {
|
||||
unsupported = true
|
||||
}
|
||||
|
||||
/* Icon glyphs are captured directly by the canvas (not via a sink) since the
|
||||
canvas owns the icon-draw path. Box origin is layer-local; size logical. */
|
||||
fun captureIconRun(
|
||||
fontFamily: String,
|
||||
text: String,
|
||||
fontSizePx: Int,
|
||||
variations: List<FontVariation.Setting>?,
|
||||
boxX: Float,
|
||||
boxY: Float,
|
||||
boxW: Float,
|
||||
boxH: Float,
|
||||
colorArgb: Int,
|
||||
) {
|
||||
commands.add(IconRun(fontFamily, text, fontSizePx, variations, boxX, boxY, boxW, boxH, colorArgb))
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
commands.clear()
|
||||
unsupported = false
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
package com.compose.sdl.renderer.sdl
|
||||
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.isUnspecified
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RenderEffect
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.DefaultCameraDistance
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.compose.sdl.graphics.NativeRenderNode
|
||||
import com.compose.sdl.graphics.NativeShadowCanvas
|
||||
import com.compose.sdl.graphics.NativeShapeClipCanvas
|
||||
import com.compose.sdl.graphics.offscreenRenderer
|
||||
import com.compose.sdl.graphics.prepareLayerTransformationMatrix
|
||||
|
||||
// ==================
|
||||
// MARK: SdlDisplayListRenderNode — cached-GEOMETRY retained node (Phase 4)
|
||||
// ==================
|
||||
|
||||
/*
|
||||
The robust retained node. record() captures a leaf's tessellated geometry ONCE (in
|
||||
layer-local space, via a capture canvas that touches no GPU); drawInto() re-emits
|
||||
those cached vertices through the layer transform — crisp under ANY transform
|
||||
(vertices re-transformed, not a resampled texture), bit-exact (no texture round-trip),
|
||||
and with NO per-frame render-target state (so none of the texture node's timing-
|
||||
dependent nondeterminism). See RENDERER.md §3/§13.
|
||||
|
||||
Captures tessellated geometry, plain + spanned text runs, and Material Symbols icon
|
||||
glyphs. A leaf whose block draws something not-yet-capturable (image blits, saveLayer,
|
||||
a rounded/generic layer clip, alpha/blend/colorFilter/renderEffect, span backgrounds)
|
||||
— or that hosts a child layer — trips the capture list's `unsupported` flag / defer
|
||||
path and falls back to a crisp block-replay (== DeferredRenderNode): always correct,
|
||||
just not cached. Image capture is the next step to widen the fast path further.
|
||||
*/
|
||||
internal class SdlDisplayListRenderNode : NativeRenderNode {
|
||||
|
||||
override var topLeft: IntOffset = IntOffset.Zero
|
||||
override var size: IntSize = IntSize.Zero
|
||||
override var pivot: Offset = Offset.Unspecified
|
||||
override var alpha: Float = 1f
|
||||
override var scaleX: Float = 1f
|
||||
override var scaleY: Float = 1f
|
||||
override var translationX: Float = 0f
|
||||
override var translationY: Float = 0f
|
||||
override var rotationX: Float = 0f
|
||||
override var rotationY: Float = 0f
|
||||
override var rotationZ: Float = 0f
|
||||
override var cameraDistance: Float = DefaultCameraDistance
|
||||
override var shadowElevation: Float = 0f
|
||||
override var ambientShadowColor: Color = Color.Black
|
||||
override var spotShadowColor: Color = Color.Black
|
||||
override var blendMode: BlendMode = BlendMode.SrcOver
|
||||
override var colorFilter: ColorFilter? = null
|
||||
override var renderEffect: RenderEffect? = null
|
||||
override var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
|
||||
override var clip: Boolean = false
|
||||
|
||||
private var clipOutline: Outline? = null
|
||||
override fun setClipRect(left: Float, top: Float, right: Float, bottom: Float) {
|
||||
clipOutline = Outline.Rectangle(Rect(left, top, right, bottom))
|
||||
}
|
||||
override fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray) {
|
||||
clipOutline = Outline.Rounded(
|
||||
RoundRect(
|
||||
left = left, top = top, right = right, bottom = bottom,
|
||||
topLeftCornerRadius = CornerRadius(radii[0], radii[1]),
|
||||
topRightCornerRadius = CornerRadius(radii[2], radii[3]),
|
||||
bottomRightCornerRadius = CornerRadius(radii[4], radii[5]),
|
||||
bottomLeftCornerRadius = CornerRadius(radii[6], radii[7]),
|
||||
),
|
||||
)
|
||||
}
|
||||
override fun setClipPath(path: Path?) {
|
||||
clipOutline = if (path == null) null else Outline.Generic(path)
|
||||
}
|
||||
|
||||
private var recordedBlock: (DrawScope.() -> Unit)? = null
|
||||
private var recordedDensity: Density = Density(1f)
|
||||
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
|
||||
private val drawScope = CanvasDrawScope()
|
||||
|
||||
private var displayList: SdlDisplayList? = null
|
||||
private var deferMode = false // sticky: block drew a not-yet-capturable op / has a child layer
|
||||
private var sawChild = false // a child layer drew during this node's record
|
||||
|
||||
// Main-loop-thread stack of nodes currently recording. A child's drawInto (invoked
|
||||
// while its parent's block runs) flags the parent as a non-leaf so the parent
|
||||
// defers — nesting is by DEFER (children composite themselves on the parent's
|
||||
// block-replay), avoiding by-value baking + GPU leaks into the capture pass.
|
||||
private companion object {
|
||||
val fRecordingStack = ArrayList<SdlDisplayListRenderNode>()
|
||||
}
|
||||
|
||||
override fun record(
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
size: IntSize,
|
||||
block: DrawScope.() -> Unit,
|
||||
) {
|
||||
recordedDensity = density
|
||||
recordedLayoutDirection = layoutDirection
|
||||
this.size = size
|
||||
recordedBlock = block
|
||||
displayList = null
|
||||
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val renderer = offscreenRenderer as? Sdl3OffscreenRenderer
|
||||
if (deferMode || w <= 0 || h <= 0 || renderer == null) return
|
||||
|
||||
// Capture at identity base CTM ⇒ layer-local geometry. No GPU touched.
|
||||
val list = SdlDisplayList()
|
||||
val capture = renderer.createCaptureCanvas(list, Size(w.toFloat(), h.toFloat()))
|
||||
sawChild = false
|
||||
fRecordingStack.add(this)
|
||||
try {
|
||||
drawScope.draw(recordedDensity, recordedLayoutDirection, capture, Size(w.toFloat(), h.toFloat()), block)
|
||||
} finally {
|
||||
fRecordingStack.removeAt(fRecordingStack.size - 1)
|
||||
capture.finish()
|
||||
}
|
||||
|
||||
if (list.unsupported || sawChild) {
|
||||
deferMode = true
|
||||
} else {
|
||||
displayList = list
|
||||
}
|
||||
}
|
||||
|
||||
override fun drawInto(canvas: Canvas) {
|
||||
val block = recordedBlock ?: return
|
||||
// Drawn during a PARENT's record → flag the parent as a non-leaf and draw
|
||||
// NOTHING now. The parent will defer to a block-replay that draws us normally;
|
||||
// this prevents leaking our GPU ops into the target-less capture pass and
|
||||
// avoids by-value child baking.
|
||||
if (fRecordingStack.isNotEmpty()) {
|
||||
fRecordingStack[fRecordingStack.size - 1].sawChild = true
|
||||
return
|
||||
}
|
||||
val w = size.width.toFloat()
|
||||
val h = size.height.toFloat()
|
||||
|
||||
canvas.save()
|
||||
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
|
||||
|
||||
val hasTransform = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
|
||||
rotationX != 0f || rotationY != 0f || translationX != 0f || translationY != 0f
|
||||
if (hasTransform && w > 0f && h > 0f) {
|
||||
val pivotX = if (pivot.isUnspecified) w / 2f else pivot.x
|
||||
val pivotY = if (pivot.isUnspecified) h / 2f else pivot.y
|
||||
val matrix = Matrix()
|
||||
prepareLayerTransformationMatrix(
|
||||
matrix = matrix,
|
||||
pivotX = pivotX, pivotY = pivotY,
|
||||
translationX = translationX, translationY = translationY,
|
||||
rotationX = rotationX, rotationY = rotationY, rotationZ = rotationZ,
|
||||
scaleX = scaleX, scaleY = scaleY, cameraDistance = cameraDistance,
|
||||
)
|
||||
canvas.concat(matrix)
|
||||
}
|
||||
|
||||
// Drop shadow — parent-space, before content (drawn on the real canvas).
|
||||
if (shadowElevation > 0f && w > 0f && h > 0f) {
|
||||
val outline = clipOutline
|
||||
if (outline != null) {
|
||||
(canvas as? NativeShadowCanvas)
|
||||
?.drawDropShadow(outline, shadowElevation, ambientShadowColor, spotShadowColor)
|
||||
}
|
||||
}
|
||||
|
||||
val list = displayList
|
||||
// Layer-level alpha / blend / colorFilter / renderEffect aren't in the captured
|
||||
// geometry (they're applied at replay); until geo replay folds them in, defer
|
||||
// those to the crisp block-replay.
|
||||
//
|
||||
// A ROUNDED / PATH content clip also forces the block-replay: the fast path
|
||||
// re-emits raw geometry via SDL_RenderGeometry, which the SDL canvas clips only
|
||||
// to a RECT (SDL_SetRenderClipRect). Its rounded-corner mask is an offscreen
|
||||
// realized lazily from drawRect/admitDraw — a path replayBatch bypasses — so a
|
||||
// rounded layer clip would go UNCUT on the fast path. That was the Carousel
|
||||
// diff: each item's morphing rounded mask was ignored, so the coloured card
|
||||
// overflowed its silhouette (wrong shape + apparent horizontal shift). The
|
||||
// block-replay re-runs the block through drawRect, which realizes the mask
|
||||
// correctly (== DeferredRenderNode). A plain RECTANGLE clip is honoured by
|
||||
// SDL_RenderGeometry directly, so it stays on the fast path.
|
||||
val outline = clipOutline
|
||||
val hasShapeClip = clip && (outline is Outline.Rounded || outline is Outline.Generic)
|
||||
val needsCompositing = alpha < 1f || blendMode != BlendMode.SrcOver ||
|
||||
colorFilter != null || renderEffect != null || hasShapeClip
|
||||
|
||||
if (list != null && !needsCompositing && canvas is Sdl3Canvas && w > 0f && h > 0f) {
|
||||
// GEO fast path: re-emit captured commands (geometry + text) through the
|
||||
// current transform. Crisp at any scale/rotation, bit-exact, no texture.
|
||||
// A rectangular content clip narrows the SDL clip rect first (honoured by
|
||||
// SDL_RenderGeometry), keeping cached geometry inside the layer bounds.
|
||||
if (clip && outline is Outline.Rectangle) {
|
||||
canvas.clipRect(outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom)
|
||||
}
|
||||
canvas.replayDisplayList(list)
|
||||
} else {
|
||||
// Block-replay fallback (== DeferredRenderNode): clip + alpha + re-run block.
|
||||
if (clip && w > 0f && h > 0f) applyClip(canvas)
|
||||
val needsAlphaLayer = alpha < 1f && w > 0f && h > 0f
|
||||
if (needsAlphaLayer) {
|
||||
canvas.saveLayer(Rect(0f, 0f, w, h), Paint().apply { alpha = this@SdlDisplayListRenderNode.alpha })
|
||||
}
|
||||
drawScope.draw(recordedDensity, recordedLayoutDirection, canvas, Size(w, h), block)
|
||||
if (needsAlphaLayer) canvas.restore()
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun applyClip(canvas: Canvas) {
|
||||
when (val outline = clipOutline) {
|
||||
is Outline.Rectangle -> canvas.clipRect(
|
||||
outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom,
|
||||
)
|
||||
is Outline.Rounded -> {
|
||||
val shapeClip = canvas as? NativeShapeClipCanvas
|
||||
if (shapeClip != null) shapeClip.clipRoundRect(outline.roundRect)
|
||||
else { val p = Path().apply { addRoundRect(outline.roundRect) }; canvas.clipPath(p) }
|
||||
}
|
||||
is Outline.Generic -> canvas.clipPath(outline.path)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
recordedBlock = null
|
||||
displayList = null
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
package com.compose.sdl.renderer.sdl
|
||||
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.geometry.isUnspecified
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmapConfig
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RenderEffect
|
||||
import androidx.compose.ui.graphics.colorspace.ColorSpaces
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.DefaultCameraDistance
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.compose.sdl.graphics.DrawStats
|
||||
import com.compose.sdl.graphics.NativeRenderNode
|
||||
import com.compose.sdl.graphics.NativeShadowCanvas
|
||||
import com.compose.sdl.graphics.NativeShapeClipCanvas
|
||||
import com.compose.sdl.graphics.offscreenRenderer
|
||||
import com.compose.sdl.graphics.prepareLayerTransformationMatrix
|
||||
|
||||
// ==================
|
||||
// MARK: SdlRenderNode — RETAINED (texture-cached) NativeRenderNode
|
||||
// ==================
|
||||
|
||||
/*
|
||||
The SDL caching node — the perf payoff. Records a layer's drawing ONCE into an
|
||||
offscreen SDL texture; replay is a cheap textured blit under the layer transform,
|
||||
NOT a re-tessellation (compare DeferredRenderNode, which re-runs the block every
|
||||
replay). Gated behind CDN_LAYERCACHE=1 while it's verified; DeferredRenderNode
|
||||
stays the default.
|
||||
|
||||
CORRECT-BY-CONSTRUCTION nesting, no upstream divergence: SDL textures composite
|
||||
children BY VALUE, unlike skiko's by-reference Picture, so a naive "cache every
|
||||
layer" would show stale children. Instead we **cache leaves, defer parents**: a
|
||||
node auto-detects on its first record whether its block drew any CHILD layer (via
|
||||
the recording stack below). If it did → it's a parent → fall back to replay-the-
|
||||
block (children composite their own live textures, so no staleness). If it didn't
|
||||
→ it's a leaf → keep the texture and just blit it henceforth. Leaves (text runs,
|
||||
icons, shapes — the bulk of static content like the sidebar) stop re-tessellating;
|
||||
parents cheaply re-composite. See RENDERER.md §4b / §13.
|
||||
*/
|
||||
internal class SdlRenderNode : NativeRenderNode {
|
||||
|
||||
override var topLeft: IntOffset = IntOffset.Zero
|
||||
override var size: IntSize = IntSize.Zero
|
||||
override var pivot: Offset = Offset.Unspecified
|
||||
override var alpha: Float = 1f
|
||||
override var scaleX: Float = 1f
|
||||
override var scaleY: Float = 1f
|
||||
override var translationX: Float = 0f
|
||||
override var translationY: Float = 0f
|
||||
override var rotationX: Float = 0f
|
||||
override var rotationY: Float = 0f
|
||||
override var rotationZ: Float = 0f
|
||||
override var cameraDistance: Float = DefaultCameraDistance
|
||||
override var shadowElevation: Float = 0f
|
||||
override var ambientShadowColor: Color = Color.Black
|
||||
override var spotShadowColor: Color = Color.Black
|
||||
override var blendMode: BlendMode = BlendMode.SrcOver
|
||||
override var colorFilter: ColorFilter? = null
|
||||
override var renderEffect: RenderEffect? = null
|
||||
override var compositingStrategy: CompositingStrategy = CompositingStrategy.Auto
|
||||
override var clip: Boolean = false
|
||||
|
||||
private var clipOutline: Outline? = null
|
||||
override fun setClipRect(left: Float, top: Float, right: Float, bottom: Float) {
|
||||
clipOutline = Outline.Rectangle(Rect(left, top, right, bottom))
|
||||
}
|
||||
override fun setClipRRect(left: Float, top: Float, right: Float, bottom: Float, radii: FloatArray) {
|
||||
clipOutline = Outline.Rounded(
|
||||
RoundRect(
|
||||
left = left, top = top, right = right, bottom = bottom,
|
||||
topLeftCornerRadius = CornerRadius(radii[0], radii[1]),
|
||||
topRightCornerRadius = CornerRadius(radii[2], radii[3]),
|
||||
bottomRightCornerRadius = CornerRadius(radii[4], radii[5]),
|
||||
bottomLeftCornerRadius = CornerRadius(radii[6], radii[7]),
|
||||
),
|
||||
)
|
||||
}
|
||||
override fun setClipPath(path: Path?) {
|
||||
clipOutline = if (path == null) null else Outline.Generic(path)
|
||||
}
|
||||
|
||||
// ============
|
||||
// Record
|
||||
|
||||
private var recordedBlock: (DrawScope.() -> Unit)? = null
|
||||
private var recordedDensity: Density = Density(1f)
|
||||
private var recordedLayoutDirection: LayoutDirection = LayoutDirection.Ltr
|
||||
private val drawScope = CanvasDrawScope()
|
||||
|
||||
// Caching state (auto-detected — see class doc).
|
||||
private var deferMode = false // true once a child layer was seen: replay the block
|
||||
private var cached = false // true once a leaf texture is valid
|
||||
private var bitmap: ImageBitmap? = null
|
||||
private var bitmapW = 0
|
||||
private var bitmapH = 0
|
||||
private var sawChildDuringRecord = false
|
||||
|
||||
override fun record(
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
size: IntSize,
|
||||
block: DrawScope.() -> Unit,
|
||||
) {
|
||||
recordedDensity = density
|
||||
recordedLayoutDirection = layoutDirection
|
||||
this.size = size
|
||||
recordedBlock = block
|
||||
cached = false
|
||||
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val renderer = offscreenRenderer
|
||||
// Parents (deferMode) / zero-size / no offscreen support → just keep the block
|
||||
// and replay it in drawInto (identical to DeferredRenderNode).
|
||||
if (deferMode || w <= 0 || h <= 0 || renderer == null) return
|
||||
|
||||
// Attempt a leaf-cache: render the block into an offscreen texture, watching
|
||||
// for any child layer being drawn (which flips us to deferMode).
|
||||
val bmp = ensureBitmap(w, h) ?: return
|
||||
val target = renderer.createCanvas(bmp) ?: return
|
||||
|
||||
sawChildDuringRecord = false
|
||||
// Watch for image/vector/icon blits: content with partial alpha (a decoded
|
||||
// image, a tinted icon) does NOT round-trip bit-exact through the 8-bit
|
||||
// premultiplied offscreen (the heart painterResource rendered visibly wrong),
|
||||
// so a leaf that draws any image bails to the crisp block-replay. Text/shapes/
|
||||
// solid fills bump other DrawStats counters and stay cached (pixel-equal).
|
||||
// (A true bit-exact cache of such content is Phase 4's cached-geometry list,
|
||||
// which has no texture round-trip.) Costs little: icons are blits, not
|
||||
// tessellation — the big tessellation cost is text, which still caches.
|
||||
val imgBlitsBefore = DrawStats.imageBlits
|
||||
fRecordingStack.add(this)
|
||||
try {
|
||||
// Content is recorded at layer-local origin; the transform is applied at
|
||||
// replay (blit). Clip is baked into the texture so rounded corners are
|
||||
// transparent in the cached pixels.
|
||||
if (clip) applyClip(target)
|
||||
drawScope.draw(recordedDensity, recordedLayoutDirection, target, Size(w.toFloat(), h.toFloat()), block)
|
||||
} finally {
|
||||
fRecordingStack.removeAt(fRecordingStack.size - 1)
|
||||
(target as? com.compose.sdl.graphics.NativeFinishableCanvas)?.finish()
|
||||
}
|
||||
val drewImage = DrawStats.imageBlits > imgBlitsBefore
|
||||
|
||||
if (sawChildDuringRecord || drewImage) {
|
||||
// Not a cacheable leaf (has a child layer, or drew an image) — abandon
|
||||
// caching for this node's lifetime and replay the block instead.
|
||||
deferMode = true
|
||||
releaseBitmap()
|
||||
} else {
|
||||
cached = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureBitmap(w: Int, h: Int): ImageBitmap? {
|
||||
val renderer = offscreenRenderer ?: return null
|
||||
if (bitmap == null || bitmapW != w || bitmapH != h) {
|
||||
releaseBitmap()
|
||||
bitmap = renderer.createImageBitmap(w, h, ImageBitmapConfig.Argb8888, true, ColorSpaces.Srgb)
|
||||
bitmapW = w
|
||||
bitmapH = h
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun releaseBitmap() {
|
||||
(bitmap as? SdlImageBitmap)?.close()
|
||||
bitmap = null
|
||||
bitmapW = 0
|
||||
bitmapH = 0
|
||||
}
|
||||
|
||||
// ============
|
||||
// Replay
|
||||
|
||||
override fun drawInto(canvas: Canvas) {
|
||||
val block = recordedBlock ?: return
|
||||
val w = size.width.toFloat()
|
||||
val h = size.height.toFloat()
|
||||
|
||||
// If I am being drawn while a parent is recording, that parent has a child
|
||||
// layer (me) → it must NOT leaf-cache. (Detected here rather than in the
|
||||
// parent so it works for any nesting depth.)
|
||||
if (fRecordingStack.isNotEmpty()) fRecordingStack[fRecordingStack.size - 1].sawChildDuringRecord = true
|
||||
|
||||
canvas.save()
|
||||
canvas.translate(topLeft.x.toFloat(), topLeft.y.toFloat())
|
||||
|
||||
val hasTransform = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
|
||||
rotationX != 0f || rotationY != 0f || translationX != 0f || translationY != 0f
|
||||
if (hasTransform && w > 0f && h > 0f) {
|
||||
val pivotX = if (pivot.isUnspecified) w / 2f else pivot.x
|
||||
val pivotY = if (pivot.isUnspecified) h / 2f else pivot.y
|
||||
val matrix = Matrix()
|
||||
prepareLayerTransformationMatrix(
|
||||
matrix = matrix,
|
||||
pivotX = pivotX, pivotY = pivotY,
|
||||
translationX = translationX, translationY = translationY,
|
||||
rotationX = rotationX, rotationY = rotationY, rotationZ = rotationZ,
|
||||
scaleX = scaleX, scaleY = scaleY, cameraDistance = cameraDistance,
|
||||
)
|
||||
canvas.concat(matrix)
|
||||
}
|
||||
|
||||
// Drop shadow — parent-space, before content (never baked into the texture).
|
||||
if (shadowElevation > 0f && w > 0f && h > 0f) {
|
||||
val outline = clipOutline
|
||||
if (outline != null) {
|
||||
(canvas as? NativeShadowCanvas)
|
||||
?.drawDropShadow(outline, shadowElevation, ambientShadowColor, spotShadowColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Blit the cached texture ONLY for an opaque, untransformed, effect-free leaf
|
||||
// — the exact subset that a plain blit reproduces pixel-equal (verified). We
|
||||
// bail to a crisp block-replay when:
|
||||
// * scale/rotation — a fixed-resolution texture would bilinear-resample (soft);
|
||||
// * alpha<1 / blendMode / colorFilter / renderEffect — the blit can't
|
||||
// reproduce the offscreen compositing (premultiplied-alpha blit differs).
|
||||
// So caching is strictly non-regressing: the static-content case (the win)
|
||||
// blits; everything else replays exactly as before. Phase 4's cached-geometry
|
||||
// display list would widen this (crisp under transform, real compositing).
|
||||
val hasScaleOrRotation = scaleX != 1f || scaleY != 1f ||
|
||||
rotationZ != 0f || rotationX != 0f || rotationY != 0f
|
||||
val needsCompositing = alpha < 1f || blendMode != BlendMode.SrcOver ||
|
||||
colorFilter != null || renderEffect != null
|
||||
val bmp = bitmap
|
||||
if (cached && bmp != null && !hasScaleOrRotation && !needsCompositing && w > 0f && h > 0f) {
|
||||
// Leaf fast path: blit the cached texture (clip already baked in).
|
||||
canvas.drawImageRect(
|
||||
image = bmp,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(bitmapW, bitmapH),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(bitmapW, bitmapH),
|
||||
paint = Paint(),
|
||||
)
|
||||
} else {
|
||||
// Parent / uncached fallback: replay the block (== DeferredRenderNode).
|
||||
if (clip && w > 0f && h > 0f) applyClip(canvas)
|
||||
val needsAlphaLayer = alpha < 1f && w > 0f && h > 0f
|
||||
if (needsAlphaLayer) {
|
||||
canvas.saveLayer(Rect(0f, 0f, w, h), Paint().apply { alpha = this@SdlRenderNode.alpha })
|
||||
}
|
||||
drawScope.draw(recordedDensity, recordedLayoutDirection, canvas, Size(w, h), block)
|
||||
if (needsAlphaLayer) canvas.restore()
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun applyClip(canvas: Canvas) {
|
||||
when (val outline = clipOutline) {
|
||||
is Outline.Rectangle -> canvas.clipRect(
|
||||
outline.rect.left, outline.rect.top, outline.rect.right, outline.rect.bottom,
|
||||
)
|
||||
is Outline.Rounded -> {
|
||||
val shapeClip = canvas as? NativeShapeClipCanvas
|
||||
if (shapeClip != null) shapeClip.clipRoundRect(outline.roundRect)
|
||||
else { val p = Path().apply { addRoundRect(outline.roundRect) }; canvas.clipPath(p) }
|
||||
}
|
||||
is Outline.Generic -> canvas.clipPath(outline.path)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
recordedBlock = null
|
||||
releaseBitmap()
|
||||
}
|
||||
|
||||
// The main-loop-thread stack of nodes currently recording. A child's drawInto
|
||||
// (invoked while its parent's block runs) flags the parent as a non-leaf.
|
||||
private companion object {
|
||||
val fRecordingStack = ArrayList<SdlRenderNode>()
|
||||
}
|
||||
}
|
||||
+604
@@ -0,0 +1,604 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// DERIVED (copy + edit) from compose-multiplatform-core -- MANUAL VENDOR, NON-IDEMPOTENT.
|
||||
// VENDOR-BASE: compose/ui/ui-graphics/src/skikoMain/kotlin/androidx/compose/ui/graphics/SkiaBackedCanvas.skiko.kt @ v1.12.0-beta03+dev4483
|
||||
// ONLY change vs upstream: this class also implements the port draw contracts
|
||||
// (NativeTextCanvas / NativePainterCanvas / NativeShadowCanvas / NativeFinishableCanvas) so
|
||||
// the port text engine (SkiaTextRenderer, P3.1 metrics) + resource-image cache + elevation
|
||||
// shadows keep working on the Skia leg, which now draws through THIS canvas instead of the
|
||||
// retired project SkiaCanvas. Bridged methods forward to `skiaLeafDrawer` using
|
||||
// `internalSkiaCanvas`. See RENDERER.md (B6.1, section 4).
|
||||
|
||||
package androidx.compose.ui.graphics
|
||||
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import com.compose.sdl.renderer.skia.skiaLeafDrawer
|
||||
import org.jetbrains.skia.FilterBlurMode
|
||||
import org.jetbrains.skia.MaskFilter
|
||||
import org.jetbrains.skia.RRect as SkRRect
|
||||
import org.jetbrains.skia.Color as SkColorNs
|
||||
import androidx.compose.ui.InternalComposeUiApi
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import org.jetbrains.skia.Canvas as SkCanvas
|
||||
import org.jetbrains.skia.ClipMode as SkClipMode
|
||||
import org.jetbrains.skia.CubicResampler
|
||||
import org.jetbrains.skia.FilterMipmap
|
||||
import org.jetbrains.skia.FilterMode
|
||||
import org.jetbrains.skia.Image
|
||||
import org.jetbrains.skia.Matrix44
|
||||
import org.jetbrains.skia.MipmapMode
|
||||
import org.jetbrains.skia.Paint as SkPaint
|
||||
import org.jetbrains.skia.SamplingMode
|
||||
import org.jetbrains.skia.impl.use
|
||||
|
||||
@Deprecated(
|
||||
message = "Use direct reference to org.jetbrains.skia.Canvas instead of typealias",
|
||||
replaceWith = ReplaceWith("Canvas", "org.jetbrains.skia.Canvas"),
|
||||
level = DeprecationLevel.ERROR,
|
||||
)
|
||||
actual typealias NativeCanvas = SkCanvas
|
||||
|
||||
internal actual fun ActualCanvas(image: ImageBitmap): Canvas {
|
||||
val skiaBitmap = image.asSkiaBitmap()
|
||||
require(!skiaBitmap.isImmutable) {
|
||||
"Cannot draw on immutable ImageBitmap"
|
||||
}
|
||||
return SkiaBackedCanvas(SkCanvas(skiaBitmap))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the [org.jetbrains.skia.Canvas] instance into a Compose-compatible Canvas
|
||||
*/
|
||||
fun SkCanvas.asComposeCanvas(): Canvas = SkiaBackedCanvas(this)
|
||||
|
||||
/**
|
||||
* Provides access to the underlying [org.jetbrains.skia.Canvas] instance.
|
||||
*
|
||||
* It throws an exception if accessed on unsupported types.
|
||||
*/
|
||||
val Canvas.skiaCanvas: SkCanvas
|
||||
get() {
|
||||
requirePrecondition(this is SkiaBackedCanvas) {
|
||||
"Extracting skia canvas reference is only supported from androidx.compose.ui.graphics.SkiaBackedCanvas instances but received ${this::class}"
|
||||
}
|
||||
return internalSkiaCanvas
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
message = "Naming alignment to avoid ambiguity: use [Canvas.skiaCanvas] extension instead",
|
||||
replaceWith = ReplaceWith("skiaCanvas", "androidx.compose.ui.graphics.skiaCanvas"),
|
||||
)
|
||||
val Canvas.nativeCanvas: SkCanvas
|
||||
get() = skiaCanvas
|
||||
|
||||
// This was added for internal usage from old render layers (another submodule),
|
||||
// but wasn't properly marked as internal. Keep it as deprecated for some time to be safe.
|
||||
@InternalComposeApi
|
||||
@Deprecated(
|
||||
level = DeprecationLevel.ERROR,
|
||||
message = "This API is not supposed to be used outside of Compose UI package"
|
||||
)
|
||||
var Canvas.alphaMultiplier: Float
|
||||
get() = (this as SkiaBackedCanvas).alphaMultiplier
|
||||
set(value) { (this as SkiaBackedCanvas).alphaMultiplier = value }
|
||||
|
||||
internal class SkiaBackedCanvas(
|
||||
internal val internalSkiaCanvas: SkCanvas,
|
||||
) : Canvas,
|
||||
com.compose.sdl.text.NativeTextCanvas,
|
||||
com.compose.sdl.graphics.NativePainterCanvas,
|
||||
com.compose.sdl.graphics.NativeShadowCanvas,
|
||||
com.compose.sdl.graphics.NativeFinishableCanvas {
|
||||
internal var alphaMultiplier: Float = 1.0f
|
||||
|
||||
private fun Paint.asSkiaPaintWithAppliedAlphaMultiplier(): SkPaint {
|
||||
require(this is SkiaBackedPaint)
|
||||
this.alphaMultiplier = this@SkiaBackedCanvas.alphaMultiplier
|
||||
return internalSkiaPaint
|
||||
}
|
||||
|
||||
override fun save() {
|
||||
internalSkiaCanvas.save()
|
||||
}
|
||||
|
||||
override fun restore() {
|
||||
internalSkiaCanvas.restore()
|
||||
}
|
||||
|
||||
override fun saveLayer(bounds: Rect, paint: Paint) {
|
||||
internalSkiaCanvas.saveLayer(
|
||||
bounds.left,
|
||||
bounds.top,
|
||||
bounds.right,
|
||||
bounds.bottom,
|
||||
paint.asSkiaPaintWithAppliedAlphaMultiplier()
|
||||
)
|
||||
}
|
||||
|
||||
override fun translate(dx: Float, dy: Float) {
|
||||
internalSkiaCanvas.translate(dx, dy)
|
||||
}
|
||||
|
||||
override fun scale(sx: Float, sy: Float) {
|
||||
internalSkiaCanvas.scale(sx, sy)
|
||||
}
|
||||
|
||||
override fun rotate(degrees: Float) {
|
||||
internalSkiaCanvas.rotate(degrees)
|
||||
}
|
||||
|
||||
override fun skew(sx: Float, sy: Float) {
|
||||
internalSkiaCanvas.skew(sx, sy)
|
||||
}
|
||||
|
||||
override fun concat(matrix: Matrix) {
|
||||
if (!matrix.isIdentity()) {
|
||||
internalSkiaCanvas.concat(matrix.toSkia())
|
||||
}
|
||||
}
|
||||
|
||||
override fun clipRect(left: Float, top: Float, right: Float, bottom: Float, clipOp: ClipOp) {
|
||||
val antiAlias = true
|
||||
internalSkiaCanvas.clipRect(
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
mode = clipOp.toSkia(),
|
||||
antiAlias = antiAlias
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(InternalComposeUiApi::class)
|
||||
override fun clipPath(path: Path, clipOp: ClipOp) {
|
||||
val antiAlias = true
|
||||
internalSkiaCanvas.clipPath(path.materializeSkiaPath(), clipOp.toSkia(), antiAlias)
|
||||
}
|
||||
|
||||
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
|
||||
internalSkiaCanvas.drawLine(
|
||||
x0 = p1.x,
|
||||
y0 = p1.y,
|
||||
x1 = p2.x,
|
||||
y1 = p2.y,
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
||||
internalSkiaCanvas.drawRect(
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawRoundRect(
|
||||
left: Float,
|
||||
top: Float,
|
||||
right: Float,
|
||||
bottom: Float,
|
||||
radiusX: Float,
|
||||
radiusY: Float,
|
||||
paint: Paint
|
||||
) {
|
||||
internalSkiaCanvas.drawRRect(
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
radii = floatArrayOf(radiusX, radiusY),
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
||||
internalSkiaCanvas.drawOval(
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
|
||||
internalSkiaCanvas.drawCircle(
|
||||
x = center.x,
|
||||
y = center.y,
|
||||
radius = radius,
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawArc(
|
||||
left: Float,
|
||||
top: Float,
|
||||
right: Float,
|
||||
bottom: Float,
|
||||
startAngle: Float,
|
||||
sweepAngle: Float,
|
||||
useCenter: Boolean,
|
||||
paint: Paint
|
||||
) {
|
||||
internalSkiaCanvas.drawArc(
|
||||
left = left,
|
||||
top = top,
|
||||
right = right,
|
||||
bottom = bottom,
|
||||
startAngle = startAngle,
|
||||
sweepAngle = sweepAngle,
|
||||
includeCenter = useCenter,
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(InternalComposeUiApi::class)
|
||||
override fun drawPath(path: Path, paint: Paint) {
|
||||
internalSkiaCanvas.drawPath(
|
||||
path = path.materializeSkiaPath(),
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
|
||||
drawImageRect(
|
||||
image = image,
|
||||
srcLeft = 0f,
|
||||
srcTop = 0f,
|
||||
srcRight = image.width.toFloat(),
|
||||
srcBottom = image.height.toFloat(),
|
||||
dstLeft = topLeftOffset.x,
|
||||
dstTop = topLeftOffset.y,
|
||||
dstRight = topLeftOffset.x + image.width.toFloat(),
|
||||
dstBottom = topLeftOffset.y + image.height.toFloat(),
|
||||
paint = paint,
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawImageRect(
|
||||
image: ImageBitmap,
|
||||
srcOffset: IntOffset,
|
||||
srcSize: IntSize,
|
||||
dstOffset: IntOffset,
|
||||
dstSize: IntSize,
|
||||
paint: Paint
|
||||
) {
|
||||
drawImageRect(
|
||||
image = image,
|
||||
srcLeft = srcOffset.x.toFloat(),
|
||||
srcTop = srcOffset.y.toFloat(),
|
||||
srcRight = srcOffset.x.toFloat() + srcSize.width.toFloat(),
|
||||
srcBottom = srcOffset.y.toFloat() + srcSize.height.toFloat(),
|
||||
dstLeft = dstOffset.x.toFloat(),
|
||||
dstTop = dstOffset.y.toFloat(),
|
||||
dstRight = dstOffset.x.toFloat() + dstSize.width.toFloat(),
|
||||
dstBottom = dstOffset.y.toFloat() + dstSize.height.toFloat(),
|
||||
paint = paint,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO(demin): probably this method should be in the common Canvas
|
||||
private fun drawImageRect(
|
||||
image: ImageBitmap,
|
||||
srcLeft: Float,
|
||||
srcTop: Float,
|
||||
srcRight: Float,
|
||||
srcBottom: Float,
|
||||
dstLeft: Float,
|
||||
dstTop: Float,
|
||||
dstRight: Float,
|
||||
dstBottom: Float,
|
||||
paint: Paint
|
||||
) {
|
||||
val bitmap = image.asSkiaBitmap()
|
||||
|
||||
Image.makeFromBitmap(bitmap).use { skiaImage ->
|
||||
internalSkiaCanvas.drawImageRect(
|
||||
image = skiaImage,
|
||||
srcLeft = srcLeft,
|
||||
srcTop = srcTop,
|
||||
srcRight = srcRight,
|
||||
srcBottom = srcBottom,
|
||||
dstLeft = dstLeft,
|
||||
dstTop = dstTop,
|
||||
dstRight = dstRight,
|
||||
dstBottom = dstBottom,
|
||||
samplingMode = paint.filterQuality.toSkia(),
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
strict = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun drawPoints(pointMode: PointMode, points: List<Offset>, paint: Paint) {
|
||||
when (pointMode) {
|
||||
// Draw a line between each pair of points, each point has at most one line
|
||||
// If the number of points is odd, then the last point is ignored.
|
||||
PointMode.Lines -> drawLines(points, paint, 2)
|
||||
|
||||
// Connect each adjacent point with a line
|
||||
PointMode.Polygon -> drawLines(points, paint, 1)
|
||||
|
||||
// Draw a point at each provided coordinate
|
||||
PointMode.Points -> drawPoints(points, paint)
|
||||
}
|
||||
}
|
||||
|
||||
override fun enableZ() = Unit
|
||||
|
||||
override fun disableZ() = Unit
|
||||
|
||||
private fun drawPoints(points: List<Offset>, paint: Paint) {
|
||||
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
|
||||
points.fastForEach { point ->
|
||||
internalSkiaCanvas.drawPoint(
|
||||
x = point.x,
|
||||
y = point.y,
|
||||
paint = skiaPaint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw lines connecting points based on the corresponding step.
|
||||
*
|
||||
* ex. 3 points with a step of 1 would draw 2 lines between the first and second points
|
||||
* and another between the second and third
|
||||
*
|
||||
* ex. 4 points with a step of 2 would draw 2 lines between the first and second and another
|
||||
* between the third and fourth. If there is an odd number of points, the last point is
|
||||
* ignored
|
||||
*
|
||||
* @see drawRawLines
|
||||
*/
|
||||
private fun drawLines(points: List<Offset>, paint: Paint, stepBy: Int) {
|
||||
if (points.size >= 2) {
|
||||
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
|
||||
var i = 0
|
||||
while (i < points.size - 1) {
|
||||
val p1 = points[i]
|
||||
val p2 = points[i + 1]
|
||||
internalSkiaCanvas.drawLine(p1.x, p1.y, p2.x, p2.y, skiaPaint)
|
||||
i += stepBy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException if a non even number of points is provided
|
||||
*/
|
||||
override fun drawRawPoints(pointMode: PointMode, points: FloatArray, paint: Paint) {
|
||||
if (points.size % 2 != 0) {
|
||||
throw IllegalArgumentException("points must have an even number of values")
|
||||
}
|
||||
when (pointMode) {
|
||||
PointMode.Lines -> drawRawLines(points, paint, 2)
|
||||
PointMode.Polygon -> drawRawLines(points, paint, 1)
|
||||
PointMode.Points -> drawRawPoints(points, paint, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawRawPoints(points: FloatArray, paint: Paint, stepBy: Int) {
|
||||
if (points.size % 2 == 0) {
|
||||
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
|
||||
var i = 0
|
||||
while (i < points.size - 1) {
|
||||
val x = points[i]
|
||||
val y = points[i + 1]
|
||||
internalSkiaCanvas.drawPoint(x, y, skiaPaint)
|
||||
i += stepBy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw lines connecting points based on the corresponding step. The points are interpreted
|
||||
* as x, y coordinate pairs in alternating index positions
|
||||
*
|
||||
* ex. 3 points with a step of 1 would draw 2 lines between the first and second points
|
||||
* and another between the second and third
|
||||
*
|
||||
* ex. 4 points with a step of 2 would draw 2 lines between the first and second and another
|
||||
* between the third and fourth. If there is an odd number of points, the last point is
|
||||
* ignored
|
||||
*
|
||||
* @see drawLines
|
||||
*/
|
||||
private fun drawRawLines(points: FloatArray, paint: Paint, stepBy: Int) {
|
||||
// Float array is treated as alternative set of x and y coordinates
|
||||
// x1, y1, x2, y2, x3, y3, ... etc.
|
||||
if (points.size >= 4 && points.size % 2 == 0) {
|
||||
val skiaPaint = paint.asSkiaPaintWithAppliedAlphaMultiplier()
|
||||
var i = 0
|
||||
while (i < points.size - 3) {
|
||||
val x1 = points[i]
|
||||
val y1 = points[i + 1]
|
||||
val x2 = points[i + 2]
|
||||
val y2 = points[i + 3]
|
||||
internalSkiaCanvas.drawLine(x1, y1, x2, y2, skiaPaint)
|
||||
i += stepBy * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun drawVertices(vertices: Vertices, blendMode: BlendMode, paint: Paint) {
|
||||
internalSkiaCanvas.drawVertices(
|
||||
vertexMode = vertices.vertexMode.toSkiaVertexMode(),
|
||||
positions = vertices.positions,
|
||||
colors = vertices.colors,
|
||||
texCoords = vertices.textureCoordinates,
|
||||
indices = vertices.indices,
|
||||
blendMode = blendMode.toSkia(),
|
||||
paint = paint.asSkiaPaintWithAppliedAlphaMultiplier(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ClipOp.toSkia() = when (this) {
|
||||
ClipOp.Difference -> SkClipMode.DIFFERENCE
|
||||
ClipOp.Intersect -> SkClipMode.INTERSECT
|
||||
else -> SkClipMode.INTERSECT
|
||||
}
|
||||
|
||||
private fun Matrix.toSkia() = Matrix44(
|
||||
this[0, 0],
|
||||
this[1, 0],
|
||||
this[2, 0],
|
||||
this[3, 0],
|
||||
|
||||
this[0, 1],
|
||||
this[1, 1],
|
||||
this[2, 1],
|
||||
this[3, 1],
|
||||
|
||||
this[0, 2],
|
||||
this[1, 2],
|
||||
this[2, 2],
|
||||
this[3, 2],
|
||||
|
||||
this[0, 3],
|
||||
this[1, 3],
|
||||
this[2, 3],
|
||||
this[3, 3]
|
||||
)
|
||||
|
||||
// These constants are chosen to correspond the old implementation of SkFilterQuality:
|
||||
// https://github.com/google/skia/blob/1f193df9b393d50da39570dab77a0bb5d28ec8ef/src/image/SkImage.cpp#L809
|
||||
// https://github.com/google/skia/blob/1f193df9b393d50da39570dab77a0bb5d28ec8ef/include/core/SkSamplingOptions.h#L86
|
||||
private fun FilterQuality.toSkia(): SamplingMode = when (this) {
|
||||
FilterQuality.Low -> FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE)
|
||||
FilterQuality.Medium -> FilterMipmap(FilterMode.LINEAR, MipmapMode.NEAREST)
|
||||
FilterQuality.High -> CubicResampler(1 / 3.0f, 1 / 3.0f)
|
||||
else -> FilterMipmap(FilterMode.NEAREST, MipmapMode.NONE)
|
||||
}
|
||||
|
||||
// ============
|
||||
// Port draw contracts (B6.1) -- forward to the port renderers via skiaLeafDrawer.
|
||||
|
||||
override fun finish() { /* Skia save/restore is balanced per call */ }
|
||||
|
||||
override fun drawNativeText(
|
||||
inText: String,
|
||||
inSpans: List<androidx.compose.ui.text.AnnotatedString.Range<androidx.compose.ui.text.SpanStyle>>?,
|
||||
inX: Float,
|
||||
inY: Float,
|
||||
inBoxWidth: Float,
|
||||
inBoxHeight: Float,
|
||||
inColor: Color,
|
||||
inFontSizePx: Int,
|
||||
inTextAlign: androidx.compose.ui.text.style.TextAlign,
|
||||
inSoftWrap: Boolean,
|
||||
inFontFamily: String?,
|
||||
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?,
|
||||
inBaseItalic: Boolean,
|
||||
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
|
||||
inLineHeightPx: Float,
|
||||
inTrimFirstLine: Boolean,
|
||||
) {
|
||||
val drawer = skiaLeafDrawer ?: return
|
||||
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
|
||||
val vLineThrough = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.LineThrough) == true
|
||||
drawer.textRenderer.drawText(
|
||||
inCanvas = internalSkiaCanvas,
|
||||
inText = inText,
|
||||
inX = inX,
|
||||
inY = inY,
|
||||
inBoxWidth = inBoxWidth.toInt(),
|
||||
inBoxHeight = inBoxHeight.toInt(),
|
||||
inColor = inColor,
|
||||
inFontSize = inFontSizePx,
|
||||
inAlign = inTextAlign,
|
||||
inSoftWrap = inSoftWrap,
|
||||
inFontFamily = inFontFamily,
|
||||
inFontVariations = inFontVariations,
|
||||
inSpans = inSpans,
|
||||
inBaseItalic = inBaseItalic,
|
||||
inBaseUnderline = vUnderline,
|
||||
inBaseLineThrough = vLineThrough,
|
||||
inLineHeightPx = inLineHeightPx,
|
||||
inTrimFirstLine = inTrimFirstLine,
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawNativePainter(
|
||||
inResourcePath: String,
|
||||
inKind: com.compose.sdl.res.ResourceKind,
|
||||
inX: Float,
|
||||
inY: Float,
|
||||
inWidth: Float,
|
||||
inHeight: Float,
|
||||
inContentScale: androidx.compose.ui.layout.ContentScale,
|
||||
inAlpha: Float,
|
||||
) {
|
||||
val drawer = skiaLeafDrawer ?: return
|
||||
drawer.imageCache.draw(
|
||||
inCanvas = internalSkiaCanvas,
|
||||
inPath = inResourcePath,
|
||||
inKind = inKind,
|
||||
inX = inX,
|
||||
inY = inY,
|
||||
inW = inWidth,
|
||||
inH = inHeight,
|
||||
inScale = inContentScale,
|
||||
inAlpha = inAlpha,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(InternalComposeUiApi::class)
|
||||
override fun drawDropShadow(
|
||||
inOutline: Outline,
|
||||
inElevationPx: Float,
|
||||
inAmbientColor: Color,
|
||||
inSpotColor: Color,
|
||||
) {
|
||||
if (inElevationPx <= 0f) return
|
||||
val vOffsetY = inElevationPx * 0.4f
|
||||
val vSpot = inSpotColor.copy(alpha = 0.28f * inSpotColor.alpha)
|
||||
val vPaint = SkPaint().apply {
|
||||
color = SkColorNs.makeARGB(
|
||||
(vSpot.alpha * 255f).toInt().coerceIn(0, 255),
|
||||
(vSpot.red * 255f).toInt().coerceIn(0, 255),
|
||||
(vSpot.green * 255f).toInt().coerceIn(0, 255),
|
||||
(vSpot.blue * 255f).toInt().coerceIn(0, 255),
|
||||
)
|
||||
maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, sigma = inElevationPx * 0.5f + 0.5f)
|
||||
}
|
||||
when (inOutline) {
|
||||
is Outline.Rectangle -> {
|
||||
val r = inOutline.rect
|
||||
internalSkiaCanvas.drawRRect(SkRRect.makeLTRB(r.left, r.top + vOffsetY, r.right, r.bottom + vOffsetY, 0f), vPaint)
|
||||
}
|
||||
is Outline.Rounded -> {
|
||||
val rr = inOutline.roundRect
|
||||
internalSkiaCanvas.drawRRect(SkRRect.makeLTRB(rr.left, rr.top + vOffsetY, rr.right, rr.bottom + vOffsetY, rr.topLeftCornerRadius.x), vPaint)
|
||||
}
|
||||
is Outline.Generic -> {
|
||||
internalSkiaCanvas.save()
|
||||
internalSkiaCanvas.translate(0f, vOffsetY)
|
||||
internalSkiaCanvas.drawPath(inOutline.path.materializeSkiaPath(), vPaint)
|
||||
internalSkiaCanvas.restore()
|
||||
}
|
||||
}
|
||||
vPaint.close()
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
@file:Suppress("OPTIONAL_DECLARATION_USAGE_IN_NON_COMMON_SOURCE", "LESS_VISIBLE_TYPE_ACCESS_IN_INLINE")
|
||||
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// DERIVED (copy + edit) from compose-multiplatform-core -- MANUAL VENDOR, NON-IDEMPOTENT.
|
||||
// VENDOR-BASE: compose/ui/ui-graphics/src/skikoMain/kotlin/androidx/compose/ui/graphics/SkiaImageAsset.skiko.kt @ v1.12.0-beta03+dev4483
|
||||
// FLATTENED for our single-native-source-set layout: upstream splits Image.toBitmap /
|
||||
// ByteArray.putBytesInto into expect (here) + actual (skikoExcludingWebMain / nativeMain).
|
||||
// Those collapse into the same skikoRenderer source set here, which is illegal, so the two
|
||||
// bodies are inlined (impls from Actuals.skikoExcludingWeb.kt + NativeImageAsset.native.kt).
|
||||
// Everything else is byte-for-byte upstream. See RENDERER.md B6.1.
|
||||
|
||||
package androidx.compose.ui.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.colorspace.ColorSpace
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.posix.memcpy
|
||||
import androidx.compose.ui.graphics.colorspace.ColorSpaces
|
||||
import kotlin.math.abs
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ColorInfo
|
||||
import org.jetbrains.skia.ColorType
|
||||
import org.jetbrains.skia.Image
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
|
||||
/**
|
||||
* Create an [ImageBitmap] from the given [Bitmap]. Note this does
|
||||
* not create a copy of the original [Bitmap] and changes to it
|
||||
* will modify the returned [ImageBitmap]
|
||||
*/
|
||||
fun Bitmap.asComposeImageBitmap(): ImageBitmap = SkiaBackedImageBitmap(this)
|
||||
|
||||
/**
|
||||
* Create an [ImageBitmap] from the given [Image].
|
||||
*/
|
||||
fun Image.toComposeImageBitmap(): ImageBitmap = SkiaBackedImageBitmap(toBitmap())
|
||||
|
||||
// Split upstream into expect/actual (fast non-web impl); inlined here (flat source set).
|
||||
internal fun Image.toBitmap(): Bitmap {
|
||||
val bitmap = Bitmap()
|
||||
bitmap.allocPixels(ImageInfo.makeN32(width, height, ColorAlphaType.PREMUL))
|
||||
val canvas = org.jetbrains.skia.Canvas(bitmap)
|
||||
canvas.drawImage(this, 0f, 0f)
|
||||
bitmap.setImmutable()
|
||||
return bitmap
|
||||
}
|
||||
|
||||
internal actual fun ActualImageBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
config: ImageBitmapConfig,
|
||||
hasAlpha: Boolean,
|
||||
colorSpace: ColorSpace
|
||||
): ImageBitmap {
|
||||
require(width > 0 && height > 0) { "width and height must be > 0" }
|
||||
val colorType = config.toSkiaColorType()
|
||||
val alphaType = if (hasAlpha) ColorAlphaType.PREMUL else ColorAlphaType.OPAQUE
|
||||
val skiaColorSpace = colorSpace.toSkiaColorSpace()
|
||||
val colorInfo = ColorInfo(colorType, alphaType, skiaColorSpace)
|
||||
val imageInfo = ImageInfo(colorInfo, width, height)
|
||||
val bitmap = Bitmap()
|
||||
bitmap.allocPixels(imageInfo)
|
||||
return SkiaBackedImageBitmap(bitmap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a reference to the [org.jetbrains.skia.Bitmap]
|
||||
*
|
||||
* @Throws UnsupportedOperationException if this [ImageBitmap] is not backed by an
|
||||
* org.jetbrains.skia.Image
|
||||
*/
|
||||
fun ImageBitmap.asSkiaBitmap(): Bitmap =
|
||||
when (this) {
|
||||
is SkiaBackedImageBitmap -> bitmap
|
||||
else -> throw UnsupportedOperationException("Unable to obtain org.jetbrains.skia.Image")
|
||||
}
|
||||
|
||||
private class SkiaBackedImageBitmap(val bitmap: Bitmap) : ImageBitmap {
|
||||
override val colorSpace = bitmap.colorSpace.toComposeColorSpace()
|
||||
override val config = bitmap.colorType.toComposeConfig()
|
||||
override val hasAlpha = !bitmap.isOpaque
|
||||
override val height get() = bitmap.height
|
||||
override val width get() = bitmap.width
|
||||
override fun prepareToDraw() = Unit
|
||||
|
||||
override fun readPixels(
|
||||
buffer: IntArray,
|
||||
startX: Int,
|
||||
startY: Int,
|
||||
width: Int,
|
||||
height: Int,
|
||||
bufferOffset: Int,
|
||||
stride: Int
|
||||
) {
|
||||
// similar to https://cs.android.com/android/platform/superproject/+/42c50042d1f05d92ecc57baebe3326a57aeecf77:frameworks/base/graphics/java/android/graphics/Bitmap.java;l=2007
|
||||
val lastScanline: Int = bufferOffset + (height - 1) * stride
|
||||
require(startX >= 0 && startY >= 0)
|
||||
require(width > 0 && startX + width <= this.width)
|
||||
require(height > 0 && startY + height <= this.height)
|
||||
require(abs(stride) >= width)
|
||||
require(bufferOffset >= 0 && bufferOffset + width <= buffer.size)
|
||||
require(lastScanline >= 0 && lastScanline + width <= buffer.size)
|
||||
|
||||
// similar to https://cs.android.com/android/platform/superproject/+/9054ca2b342b2ea902839f629e820546d8a2458b:frameworks/base/libs/hwui/jni/Bitmap.cpp;l=898;bpv=1
|
||||
val colorInfo = ColorInfo(
|
||||
ColorType.BGRA_8888,
|
||||
ColorAlphaType.UNPREMUL,
|
||||
org.jetbrains.skia.ColorSpace.sRGB
|
||||
)
|
||||
val imageInfo = ImageInfo(colorInfo, width, height)
|
||||
val bytesPerPixel = 4
|
||||
val bytes = bitmap.readPixels(imageInfo, stride * bytesPerPixel, startX, startY)!!
|
||||
bytes.putBytesInto(buffer, bufferOffset, bytes.size / bytesPerPixel)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun ByteArray.putBytesInto(array: IntArray, offset: Int, length: Int) {
|
||||
this.usePinned { bytes ->
|
||||
array.usePinned { ints ->
|
||||
// Assuming little endian.
|
||||
memcpy(ints.addressOf(offset), bytes.addressOf(0), (length * 4).toULong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(demin): [API] maybe we should use:
|
||||
// `else -> throw UnsupportedOperationException()`
|
||||
// in toSkiaColorType/toComposeConfig/toComposeColorSpace/toSkiaColorSpace
|
||||
// see [https://android-review.googlesource.com/c/platform/frameworks/support/+/1429835/comment/c219501b_63c3d1fe/]
|
||||
|
||||
private fun ImageBitmapConfig.toSkiaColorType() = when (this) {
|
||||
ImageBitmapConfig.Argb8888 -> ColorType.N32
|
||||
ImageBitmapConfig.Alpha8 -> ColorType.ALPHA_8
|
||||
ImageBitmapConfig.Rgb565 -> ColorType.RGB_565
|
||||
ImageBitmapConfig.F16 -> ColorType.RGBA_F16
|
||||
else -> ColorType.N32
|
||||
}
|
||||
|
||||
private fun ColorType.toComposeConfig() = when (this) {
|
||||
ColorType.N32 -> ImageBitmapConfig.Argb8888
|
||||
ColorType.ALPHA_8 -> ImageBitmapConfig.Alpha8
|
||||
ColorType.RGB_565 -> ImageBitmapConfig.Rgb565
|
||||
ColorType.RGBA_F16 -> ImageBitmapConfig.F16
|
||||
else -> ImageBitmapConfig.Argb8888
|
||||
}
|
||||
|
||||
private fun org.jetbrains.skia.ColorSpace?.toComposeColorSpace(): ColorSpace {
|
||||
return when (this) {
|
||||
org.jetbrains.skia.ColorSpace.sRGB -> ColorSpaces.Srgb
|
||||
org.jetbrains.skia.ColorSpace.sRGBLinear -> ColorSpaces.LinearSrgb
|
||||
org.jetbrains.skia.ColorSpace.displayP3 -> ColorSpaces.DisplayP3
|
||||
else -> ColorSpaces.Srgb
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(demin): support all color spaces.
|
||||
// to do this we need to implement SkColorSpace::MakeRGB in skia
|
||||
private fun ColorSpace.toSkiaColorSpace(): org.jetbrains.skia.ColorSpace {
|
||||
return when (this) {
|
||||
ColorSpaces.Srgb -> org.jetbrains.skia.ColorSpace.sRGB
|
||||
ColorSpaces.LinearSrgb -> org.jetbrains.skia.ColorSpace.sRGBLinear
|
||||
ColorSpaces.DisplayP3 -> org.jetbrains.skia.ColorSpace.displayP3
|
||||
else -> org.jetbrains.skia.ColorSpace.sRGB
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import androidx.compose.ui.graphics.GraphicsContext
|
||||
import androidx.compose.ui.graphics.SkiaGraphicsContext
|
||||
|
||||
// ==================
|
||||
// MARK: createGraphicsContext — Skia renderer actual (B6.2)
|
||||
// ==================
|
||||
|
||||
/* The Skia leg uses upstream's SkiaGraphicsContext, which owns a skiko
|
||||
RenderNodeContext and creates upstream GraphicsLayer(skiko.RenderNode) instances.
|
||||
See RENDERER.md (B6.2, section 4). */
|
||||
internal actual fun createGraphicsContext(): GraphicsContext = SkiaGraphicsContext()
|
||||
-503
@@ -1,503 +0,0 @@
|
||||
package com.compose.sdl.renderer.skia
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.ClipOp
|
||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.LinearGradient
|
||||
import androidx.compose.ui.graphics.Matrix
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.PaintingStyle
|
||||
import androidx.compose.ui.graphics.Path as ComposePath
|
||||
import androidx.compose.ui.graphics.RadialGradient
|
||||
import androidx.compose.ui.graphics.SkiaBackedPath
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.SweepGradient
|
||||
import androidx.compose.ui.graphics.TileMode as ComposeTileMode
|
||||
import androidx.compose.ui.graphics.asSkiaColorFilter
|
||||
import androidx.compose.ui.graphics.asSkiaPath
|
||||
import androidx.compose.ui.graphics.PointMode
|
||||
import androidx.compose.ui.graphics.StrokeCap as ComposeStrokeCap
|
||||
import androidx.compose.ui.graphics.Vertices
|
||||
import androidx.compose.ui.graphics.toSkia
|
||||
import com.compose.sdl.graphics.PathCommand
|
||||
import com.compose.sdl.graphics.ProjectPath
|
||||
import com.compose.sdl.graphics.a8
|
||||
import com.compose.sdl.graphics.b8
|
||||
import com.compose.sdl.graphics.g8
|
||||
import com.compose.sdl.graphics.gradientCenter
|
||||
import com.compose.sdl.graphics.gradientColors
|
||||
import com.compose.sdl.graphics.gradientEnd
|
||||
import com.compose.sdl.graphics.gradientRadius
|
||||
import com.compose.sdl.graphics.gradientStart
|
||||
import com.compose.sdl.graphics.gradientStops
|
||||
import com.compose.sdl.graphics.gradientTileMode
|
||||
import com.compose.sdl.graphics.r8
|
||||
import org.jetbrains.skia.Canvas as SkCanvas
|
||||
import org.jetbrains.skia.ClipMode
|
||||
import org.jetbrains.skia.Color as SkColor
|
||||
import org.jetbrains.skia.Color4f
|
||||
import org.jetbrains.skia.FilterTileMode
|
||||
import org.jetbrains.skia.Gradient
|
||||
import org.jetbrains.skia.Paint as SkPaint
|
||||
import org.jetbrains.skia.PaintMode
|
||||
import org.jetbrains.skia.PaintStrokeCap
|
||||
import org.jetbrains.skia.Path as SkPath
|
||||
import org.jetbrains.skia.PathBuilder
|
||||
import org.jetbrains.skia.Point
|
||||
import org.jetbrains.skia.Shader as SkShader
|
||||
import org.jetbrains.skia.RRect
|
||||
import org.jetbrains.skia.Rect as SkRect
|
||||
|
||||
// ==================
|
||||
// MARK: SkiaCanvas
|
||||
// ==================
|
||||
|
||||
/*
|
||||
Phase 9 — Skia implementation of `androidx.compose.ui.graphics.Canvas`, mirror of
|
||||
`Sdl3Canvas`. Wraps a live `org.jetbrains.skia.Canvas` (from the active
|
||||
SkiaBridge — Metal / OpenGL / CPU raster) and translates every upstream draw
|
||||
call into a Skia primitive.
|
||||
|
||||
Text + painter bridges delegate to SkiaTextRenderer / SkiaImageCache exactly
|
||||
like Sdl3Canvas. Gradient brushes are currently solid-color fallback (Skia's
|
||||
Shader API takes a Gradient descriptor that we don't build yet).
|
||||
*/
|
||||
internal class SkiaCanvas(
|
||||
private val fCanvas: SkCanvas,
|
||||
@Suppress("UNUSED_PARAMETER") inSize: Size,
|
||||
private val fTextRenderer: SkiaTextRenderer? = null,
|
||||
private val fImageCache: SkiaImageCache? = null,
|
||||
) : Canvas,
|
||||
com.compose.sdl.text.NativeTextCanvas,
|
||||
com.compose.sdl.graphics.NativePainterCanvas,
|
||||
com.compose.sdl.graphics.NativeShadowCanvas {
|
||||
|
||||
fun finish() { /* Skia save/restore is balanced per call */ }
|
||||
|
||||
// ============
|
||||
// Drop shadow (NativeShadowCanvas) — a real Gaussian blur MaskFilter on
|
||||
// the layer's outline, offset downward for the spot-light look. Called by
|
||||
// ProjectOwnedLayer in layer-local coords (canvas already translated).
|
||||
|
||||
override fun drawDropShadow(
|
||||
inOutline: androidx.compose.ui.graphics.Outline,
|
||||
inElevationPx: Float,
|
||||
inAmbientColor: androidx.compose.ui.graphics.Color,
|
||||
inSpotColor: androidx.compose.ui.graphics.Color,
|
||||
) {
|
||||
if (inElevationPx <= 0f) return
|
||||
val vOffsetY = inElevationPx * 0.4f
|
||||
val vPaint = SkPaint().apply {
|
||||
color = toSkiaColor(inSpotColor.copy(alpha = 0.28f * inSpotColor.alpha))
|
||||
maskFilter = org.jetbrains.skia.MaskFilter.makeBlur(
|
||||
org.jetbrains.skia.FilterBlurMode.NORMAL,
|
||||
sigma = inElevationPx * 0.5f + 0.5f,
|
||||
)
|
||||
}
|
||||
when (inOutline) {
|
||||
is androidx.compose.ui.graphics.Outline.Rectangle -> {
|
||||
val vR = inOutline.rect
|
||||
fCanvas.drawRRect(
|
||||
org.jetbrains.skia.RRect.makeLTRB(vR.left, vR.top + vOffsetY, vR.right, vR.bottom + vOffsetY, 0f),
|
||||
vPaint,
|
||||
)
|
||||
}
|
||||
is androidx.compose.ui.graphics.Outline.Rounded -> {
|
||||
val vRr = inOutline.roundRect
|
||||
fCanvas.drawRRect(
|
||||
org.jetbrains.skia.RRect.makeLTRB(
|
||||
vRr.left, vRr.top + vOffsetY, vRr.right, vRr.bottom + vOffsetY,
|
||||
vRr.topLeftCornerRadius.x,
|
||||
),
|
||||
vPaint,
|
||||
)
|
||||
}
|
||||
is androidx.compose.ui.graphics.Outline.Generic -> {
|
||||
// Blur the ACTUAL path — CutCornerShape / GenericShape shadows
|
||||
// follow the real outline instead of the bounding rect.
|
||||
fCanvas.save()
|
||||
fCanvas.translate(0f, vOffsetY)
|
||||
fCanvas.drawPath(toSkiaPath(inOutline.path), vPaint)
|
||||
fCanvas.restore()
|
||||
}
|
||||
}
|
||||
vPaint.close()
|
||||
}
|
||||
|
||||
// ============
|
||||
// Transform / clip state — delegated to Skia's own stack.
|
||||
|
||||
override fun save() { fCanvas.save() }
|
||||
override fun restore() { fCanvas.restore() }
|
||||
override fun saveLayer(bounds: Rect, paint: Paint) {
|
||||
// Ignore caller-computed bounds and pass a huge rect. ProjectOwnedLayer.drawLayer
|
||||
// computes bounds from THIS layer's own fPosition + fTranslation + fSize —
|
||||
// it can't see inner-layer translations that would paint outside those bounds.
|
||||
// Concrete failure: dragging a tab with `graphicsLayer(alpha=0.65f, translationX=X)`
|
||||
// — the layer's saveLayer bounds don't include any subsequent inner-layer
|
||||
// translation, so on Skia GPU backends the offscreen buffer is allocated too
|
||||
// small and the ghost gets clipped as it slides. Even with the single-layer app
|
||||
// fix, Skia's saveLayer(bounds, alphaPaint) allocates an offscreen SIZED to
|
||||
// bounds — passing tight (fPos + fTrans + size) bounds is exactly the layer's
|
||||
// own painted rect, so anything drawn OUTSIDE that rect via child modifiers
|
||||
// (padding overshoot, border stroke overshoot, drop shadow) is trimmed.
|
||||
// A large explicit rect avoids overload resolution ambiguity that `null as
|
||||
// SkRect?` had (didn't seem to hit the null branch consistently). Alpha compositing
|
||||
// still works — Skia does the alpha modulation via layerPaint on restore().
|
||||
// SaveLayer paint carries alpha (for compositing) — no shape geometry, so
|
||||
// pass zero size / origin; the gradient/shader branches don't fire for a
|
||||
// layer paint.
|
||||
val vLayerPaint = toSkiaPaint(paint, Size(0f, 0f), Offset.Zero)
|
||||
fCanvas.saveLayer(-100_000f, -100_000f, 100_000f, 100_000f, vLayerPaint)
|
||||
vLayerPaint.close()
|
||||
}
|
||||
override fun translate(dx: Float, dy: Float) { fCanvas.translate(dx, dy) }
|
||||
override fun scale(sx: Float, sy: Float) { fCanvas.scale(sx, sy) }
|
||||
override fun rotate(degrees: Float) { fCanvas.rotate(degrees) }
|
||||
override fun skew(sx: Float, sy: Float) { fCanvas.skew(sx, sy) }
|
||||
override fun concat(matrix: Matrix) {
|
||||
val vM = org.jetbrains.skia.Matrix33(
|
||||
matrix.values[0], matrix.values[4], matrix.values[12],
|
||||
matrix.values[1], matrix.values[5], matrix.values[13],
|
||||
matrix.values[3], matrix.values[7], matrix.values[15],
|
||||
)
|
||||
fCanvas.concat(vM)
|
||||
}
|
||||
|
||||
override fun clipRect(left: Float, top: Float, right: Float, bottom: Float, clipOp: ClipOp) {
|
||||
fCanvas.clipRect(
|
||||
SkRect.makeLTRB(left, top, right, bottom),
|
||||
if (clipOp == ClipOp.Difference) ClipMode.DIFFERENCE else ClipMode.INTERSECT,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
override fun clipPath(path: ComposePath, clipOp: ClipOp) {
|
||||
fCanvas.clipPath(
|
||||
toSkiaPath(path),
|
||||
if (clipOp == ClipOp.Difference) ClipMode.DIFFERENCE else ClipMode.INTERSECT,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// ============
|
||||
// Draw primitives.
|
||||
|
||||
override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
||||
val vSize = Size(right - left, bottom - top)
|
||||
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
|
||||
fCanvas.drawRect(SkRect.makeLTRB(left, top, right, bottom), vSk)
|
||||
vSk.close()
|
||||
}
|
||||
|
||||
override fun drawRoundRect(
|
||||
left: Float, top: Float, right: Float, bottom: Float,
|
||||
radiusX: Float, radiusY: Float, paint: Paint,
|
||||
) {
|
||||
val vSize = Size(right - left, bottom - top)
|
||||
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
|
||||
fCanvas.drawRRect(
|
||||
RRect.makeLTRB(left, top, right, bottom, radiusX, radiusY),
|
||||
vSk,
|
||||
)
|
||||
vSk.close()
|
||||
}
|
||||
|
||||
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
||||
val vSize = Size(right - left, bottom - top)
|
||||
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
|
||||
fCanvas.drawOval(SkRect.makeLTRB(left, top, right, bottom), vSk)
|
||||
vSk.close()
|
||||
}
|
||||
|
||||
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
|
||||
val vSize = Size(radius * 2f, radius * 2f)
|
||||
val vSk = toSkiaPaint(paint, vSize, Offset(center.x - radius, center.y - radius))
|
||||
fCanvas.drawCircle(center.x, center.y, radius, vSk)
|
||||
vSk.close()
|
||||
}
|
||||
|
||||
override fun drawArc(
|
||||
left: Float, top: Float, right: Float, bottom: Float,
|
||||
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
|
||||
) {
|
||||
val vSize = Size(right - left, bottom - top)
|
||||
val vSk = toSkiaPaint(paint, vSize, Offset(left, top))
|
||||
fCanvas.drawArc(left, top, right, bottom, startAngle, sweepAngle, useCenter, vSk)
|
||||
vSk.close()
|
||||
}
|
||||
|
||||
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
|
||||
val vSk = toSkiaPaint(paint, Size(0f, 0f), Offset.Zero)
|
||||
fCanvas.drawLine(p1.x, p1.y, p2.x, p2.y, vSk)
|
||||
vSk.close()
|
||||
}
|
||||
|
||||
override fun drawPath(path: ComposePath, paint: Paint) {
|
||||
val vSk = toSkiaPaint(paint, Size(0f, 0f), Offset.Zero)
|
||||
fCanvas.drawPath(toSkiaPath(path), vSk)
|
||||
vSk.close()
|
||||
}
|
||||
// ============
|
||||
// Native bridges.
|
||||
|
||||
override fun drawNativeText(
|
||||
inText: String,
|
||||
inSpans: List<androidx.compose.ui.text.AnnotatedString.Range<androidx.compose.ui.text.SpanStyle>>?,
|
||||
inX: Float,
|
||||
inY: Float,
|
||||
inBoxWidth: Float,
|
||||
inBoxHeight: Float,
|
||||
inColor: ComposeColor,
|
||||
inFontSizePx: Int,
|
||||
inTextAlign: androidx.compose.ui.text.style.TextAlign,
|
||||
inSoftWrap: Boolean,
|
||||
inFontFamily: String?,
|
||||
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?,
|
||||
inBaseItalic: Boolean,
|
||||
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
|
||||
) {
|
||||
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
|
||||
val vLineThrough = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.LineThrough) == true
|
||||
fTextRenderer?.drawText(
|
||||
inCanvas = fCanvas,
|
||||
inText = inText,
|
||||
inX = inX,
|
||||
inY = inY,
|
||||
inBoxWidth = inBoxWidth.toInt(),
|
||||
inBoxHeight = inBoxHeight.toInt(),
|
||||
inColor = inColor,
|
||||
inFontSize = inFontSizePx,
|
||||
inAlign = inTextAlign,
|
||||
inSoftWrap = inSoftWrap,
|
||||
inFontFamily = inFontFamily,
|
||||
inFontVariations = inFontVariations,
|
||||
inSpans = inSpans,
|
||||
inBaseItalic = inBaseItalic,
|
||||
inBaseUnderline = vUnderline,
|
||||
inBaseLineThrough = vLineThrough,
|
||||
)
|
||||
}
|
||||
|
||||
override fun drawNativePainter(
|
||||
inResourcePath: String,
|
||||
inKind: com.compose.sdl.res.ResourceKind,
|
||||
inX: Float,
|
||||
inY: Float,
|
||||
inWidth: Float,
|
||||
inHeight: Float,
|
||||
inContentScale: androidx.compose.ui.layout.ContentScale,
|
||||
inAlpha: Float,
|
||||
) {
|
||||
fImageCache?.draw(
|
||||
inCanvas = fCanvas,
|
||||
inPath = inResourcePath,
|
||||
inKind = inKind,
|
||||
inX = inX,
|
||||
inY = inY,
|
||||
inW = inWidth,
|
||||
inH = inHeight,
|
||||
inScale = inContentScale,
|
||||
inAlpha = inAlpha,
|
||||
)
|
||||
}
|
||||
|
||||
// ============
|
||||
// Native image (ImageBitmap-backed vector — see SkiaOffscreen). The offscreen
|
||||
// registers itself lazily, so anything not backed by a SkiaImageBitmap surface
|
||||
// (a hypothetical stub) is silently skipped.
|
||||
|
||||
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
|
||||
val vBmp = image as? SkiaImageBitmap ?: return
|
||||
val vImg = vBmp.snapshot()
|
||||
val vSkPaint = imageBlitPaint(paint)
|
||||
fCanvas.drawImage(vImg, topLeftOffset.x, topLeftOffset.y, vSkPaint)
|
||||
vSkPaint.close()
|
||||
}
|
||||
|
||||
override fun drawImageRect(
|
||||
image: ImageBitmap,
|
||||
srcOffset: androidx.compose.ui.unit.IntOffset,
|
||||
srcSize: androidx.compose.ui.unit.IntSize,
|
||||
dstOffset: androidx.compose.ui.unit.IntOffset,
|
||||
dstSize: androidx.compose.ui.unit.IntSize,
|
||||
paint: Paint,
|
||||
) {
|
||||
val vBmp = image as? SkiaImageBitmap ?: return
|
||||
val vImg = vBmp.snapshot()
|
||||
val vSrc = SkRect.makeXYWH(
|
||||
srcOffset.x.toFloat(), srcOffset.y.toFloat(),
|
||||
srcSize.width.toFloat(), srcSize.height.toFloat(),
|
||||
)
|
||||
val vDst = SkRect.makeXYWH(
|
||||
dstOffset.x.toFloat(), dstOffset.y.toFloat(),
|
||||
dstSize.width.toFloat(), dstSize.height.toFloat(),
|
||||
)
|
||||
val vSkPaint = imageBlitPaint(paint)
|
||||
fCanvas.drawImageRect(vImg, vSrc, vDst, vSkPaint, true)
|
||||
vSkPaint.close()
|
||||
}
|
||||
|
||||
// Blit paint: alpha modulated as the paint colour's alpha; ColorFilter (Icon
|
||||
// tint arrives here) forwarded to Skia so the vendored VectorPainter's
|
||||
// ColorFilter.tint recolours the icon at composite time.
|
||||
private fun imageBlitPaint(inPaint: Paint): SkPaint {
|
||||
val vP = SkPaint()
|
||||
vP.color = SkColor.makeARGB(
|
||||
(inPaint.alpha * 255f).toInt().coerceIn(0, 255), 255, 255, 255,
|
||||
)
|
||||
vP.isAntiAlias = true
|
||||
inPaint.colorFilter?.let {
|
||||
vP.colorFilter = it.asSkiaColorFilter()
|
||||
}
|
||||
return vP
|
||||
}
|
||||
|
||||
override fun drawPoints(pointMode: PointMode, points: List<Offset>, paint: Paint) {}
|
||||
override fun drawRawPoints(pointMode: PointMode, points: FloatArray, paint: Paint) {}
|
||||
override fun drawVertices(vertices: Vertices, blendMode: BlendMode, paint: Paint) {}
|
||||
override fun enableZ() {}
|
||||
override fun disableZ() {}
|
||||
|
||||
// ============
|
||||
// Paint translation. Recovers a gradient Brush stashed on paint.shader (see
|
||||
// CanvasPaintActuals.native.kt — ShaderBrush.applyTo drops everything but
|
||||
// color=Black otherwise) and builds the matching Skia Shader, using the
|
||||
// shape's [inShapeSize] to resolve infinite-anchor gradients. Forwards
|
||||
// paint.colorFilter and paint.blendMode (BlendMode.Clear zeroes the region,
|
||||
// which DrawCache relies on to clear the offscreen before rasterising a
|
||||
// vector).
|
||||
|
||||
private fun toSkiaPaint(inPaint: Paint, inShapeSize: Size, inShapeOrigin: Offset): SkPaint {
|
||||
val vPaint = SkPaint()
|
||||
vPaint.isAntiAlias = true
|
||||
vPaint.mode = if (inPaint.style == PaintingStyle.Stroke) PaintMode.STROKE else PaintMode.FILL
|
||||
vPaint.strokeWidth = inPaint.strokeWidth
|
||||
vPaint.strokeCap = when (inPaint.strokeCap) {
|
||||
ComposeStrokeCap.Round -> PaintStrokeCap.ROUND
|
||||
ComposeStrokeCap.Square -> PaintStrokeCap.SQUARE
|
||||
else -> PaintStrokeCap.BUTT
|
||||
}
|
||||
val vBrush = inPaint.shader?.brush
|
||||
val vBaseColor = SkColor.makeARGB(
|
||||
(inPaint.color.alpha * inPaint.alpha * 255f).toInt().coerceIn(0, 255),
|
||||
(inPaint.color.red * 255f).toInt().coerceIn(0, 255),
|
||||
(inPaint.color.green * 255f).toInt().coerceIn(0, 255),
|
||||
(inPaint.color.blue * 255f).toInt().coerceIn(0, 255),
|
||||
)
|
||||
when (vBrush) {
|
||||
null, is SolidColor -> {
|
||||
vPaint.color = vBaseColor
|
||||
}
|
||||
is LinearGradient -> {
|
||||
vPaint.color = whiteAlpha(inPaint.alpha)
|
||||
vPaint.shader = makeLinearShader(vBrush, inShapeSize, inShapeOrigin)
|
||||
}
|
||||
is RadialGradient -> {
|
||||
vPaint.color = whiteAlpha(inPaint.alpha)
|
||||
vPaint.shader = makeRadialShader(vBrush, inShapeSize, inShapeOrigin)
|
||||
}
|
||||
is SweepGradient -> {
|
||||
vPaint.color = whiteAlpha(inPaint.alpha)
|
||||
vPaint.shader = makeSweepShader(vBrush, inShapeSize, inShapeOrigin)
|
||||
}
|
||||
else -> {
|
||||
vPaint.color = vBaseColor
|
||||
}
|
||||
}
|
||||
inPaint.colorFilter?.let { vPaint.colorFilter = it.asSkiaColorFilter() }
|
||||
vPaint.blendMode = inPaint.blendMode.toSkia()
|
||||
return vPaint
|
||||
}
|
||||
|
||||
private fun whiteAlpha(inAlpha: Float): Int =
|
||||
SkColor.makeARGB((inAlpha * 255f).toInt().coerceIn(0, 255), 255, 255, 255)
|
||||
|
||||
private fun makeLinearShader(inB: LinearGradient, inSize: Size, inOrigin: Offset): SkShader {
|
||||
val vSx = inOrigin.x + resolveX(inB.gradientStart.x, inSize.width)
|
||||
val vSy = inOrigin.y + resolveY(inB.gradientStart.y, inSize.height)
|
||||
val vEx = inOrigin.x + resolveX(inB.gradientEnd.x, inSize.width)
|
||||
val vEy = inOrigin.y + resolveY(inB.gradientEnd.y, inSize.height)
|
||||
return SkShader.makeLinearGradient(
|
||||
p0 = Point(vSx, vSy),
|
||||
p1 = Point(vEx, vEy),
|
||||
gradient = Gradient(skiaColorsFor(inB.gradientColors, inB.gradientStops, inB.gradientTileMode.toSkia())),
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeRadialShader(inB: RadialGradient, inSize: Size, inOrigin: Offset): SkShader {
|
||||
val vCx = inOrigin.x + resolveX(inB.gradientCenter.x, inSize.width)
|
||||
val vCy = inOrigin.y + resolveY(inB.gradientCenter.y, inSize.height)
|
||||
val vR = if (inB.gradientRadius.isFinite()) inB.gradientRadius else (inSize.minDimension / 2f)
|
||||
return SkShader.makeRadialGradient(
|
||||
center = Point(vCx, vCy),
|
||||
radius = vR,
|
||||
gradient = Gradient(skiaColorsFor(inB.gradientColors, inB.gradientStops, inB.gradientTileMode.toSkia())),
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeSweepShader(inB: SweepGradient, inSize: Size, inOrigin: Offset): SkShader {
|
||||
val vCx = inOrigin.x + resolveX(inB.gradientCenter.x, inSize.width)
|
||||
val vCy = inOrigin.y + resolveY(inB.gradientCenter.y, inSize.height)
|
||||
return SkShader.makeSweepGradient(
|
||||
center = Point(vCx, vCy),
|
||||
gradient = Gradient(skiaColorsFor(inB.gradientColors, inB.gradientStops, FilterTileMode.CLAMP)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun skiaColorsFor(
|
||||
inColors: List<ComposeColor>,
|
||||
inStops: List<Float>?,
|
||||
inTile: FilterTileMode,
|
||||
): Gradient.Colors = Gradient.Colors(
|
||||
colors = Array(inColors.size) { Color4f(r = inColors[it].r8 / 255f, g = inColors[it].g8 / 255f, b = inColors[it].b8 / 255f, a = inColors[it].a8 / 255f) },
|
||||
positions = inStops?.toFloatArray(),
|
||||
tileMode = inTile,
|
||||
)
|
||||
|
||||
private fun resolveX(inV: Float, inW: Float): Float = if (inV.isFinite()) inV else inW
|
||||
private fun resolveY(inV: Float, inH: Float): Float = if (inV.isFinite()) inV else inH
|
||||
|
||||
private fun ComposeTileMode.toSkia(): FilterTileMode = when (this) {
|
||||
ComposeTileMode.Clamp -> FilterTileMode.CLAMP
|
||||
ComposeTileMode.Repeated -> FilterTileMode.REPEAT
|
||||
ComposeTileMode.Mirror -> FilterTileMode.MIRROR
|
||||
ComposeTileMode.Decal -> FilterTileMode.DECAL
|
||||
else -> FilterTileMode.CLAMP
|
||||
}
|
||||
|
||||
// ============
|
||||
// Path translation. Two possible source types since our skikoRenderer source
|
||||
// set inherits BOTH the commonMain `ProjectPath` and the vendored
|
||||
// `SkiaBackedPath` (`actual fun Path(): Path = SkiaBackedPath()` lives in
|
||||
// vendor/skikoRenderer/…/SkiaBackedPath.skiko.kt). Upstream callers that build
|
||||
// a Path via `Path()` — including `MultiParagraph.getPathForRange`, which
|
||||
// drives text-selection highlight rendering — get a SkiaBackedPath. Our own
|
||||
// `ProjectPath` still surfaces from code paths that build via
|
||||
// `com.compose.sdl.graphics.ProjectPath()` directly. Try
|
||||
// SkiaBackedPath's own Skia handle first (zero-copy, avoids replaying a
|
||||
// command list); otherwise, replay ProjectPath's commands.
|
||||
//
|
||||
// Regression that motivated the branch: selection highlight painted an empty
|
||||
// Skia path — the SkiaBackedPath fell through the `as? ProjectPath` check and
|
||||
// returned an empty PathBuilder snapshot.
|
||||
|
||||
private fun toSkiaPath(inPath: ComposePath): SkPath {
|
||||
(inPath as? SkiaBackedPath)?.let { return (it as ComposePath).asSkiaPath() }
|
||||
val vB = PathBuilder()
|
||||
val vCommands = (inPath as? ProjectPath)?.commands ?: return vB.snapshot().also { vB.close() }
|
||||
for (vCmd in vCommands) when (vCmd) {
|
||||
is PathCommand.MoveTo -> vB.moveTo(vCmd.x, vCmd.y)
|
||||
is PathCommand.LineTo -> vB.lineTo(vCmd.x, vCmd.y)
|
||||
is PathCommand.QuadTo -> vB.quadTo(vCmd.cx, vCmd.cy, vCmd.x, vCmd.y)
|
||||
is PathCommand.CubicTo -> vB.cubicTo(vCmd.c1x, vCmd.c1y, vCmd.c2x, vCmd.c2y, vCmd.x, vCmd.y)
|
||||
PathCommand.Close -> vB.closePath()
|
||||
}
|
||||
return vB.snapshot().also { vB.close() }
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.compose.sdl.renderer.skia
|
||||
|
||||
// ==================
|
||||
// MARK: SkiaLeafDrawer — port text/image renderers behind the upstream canvas (B6.1)
|
||||
// ==================
|
||||
|
||||
/* The Skia leg draws through upstream's `SkiaBackedCanvas` (real gradients/paint/
|
||||
shader), but keeps the port's text engine (`SkiaTextRenderer`, incl. the P3.1
|
||||
metric work) and resource-image cache (`SkiaImageCache`). `SkiaBackedCanvas` is
|
||||
manual-vendored to implement the port `NativeTextCanvas`/`NativePainterCanvas`
|
||||
contracts and forwards to this drawer, which holds the two long-lived renderers
|
||||
the way the old per-frame `SkiaCanvas` used to. Set once by `SkiaRenderBackend`;
|
||||
any `SkiaBackedCanvas` (frame, offscreen, or a GraphicsLayer recording — B6.2)
|
||||
can then draw text/images onto its own `internalSkiaCanvas`. */
|
||||
internal var skiaLeafDrawer: SkiaLeafDrawer? = null
|
||||
|
||||
internal class SkiaLeafDrawer(
|
||||
val textRenderer: SkiaTextRenderer,
|
||||
val imageCache: SkiaImageCache,
|
||||
)
|
||||
+8
-93
@@ -1,93 +1,9 @@
|
||||
package com.compose.sdl.renderer.skia
|
||||
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmapConfig
|
||||
import androidx.compose.ui.graphics.colorspace.ColorSpace
|
||||
import androidx.compose.ui.graphics.colorspace.ColorSpaces
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import com.compose.sdl.graphics.EncodedImageDecoder
|
||||
import com.compose.sdl.graphics.OffscreenRenderer
|
||||
import org.jetbrains.skia.Image
|
||||
import org.jetbrains.skia.Surface
|
||||
|
||||
// ==================
|
||||
// MARK: Skia offscreen bitmap support
|
||||
// ==================
|
||||
|
||||
// An ImageBitmap backed by a Skia Surface. The vector rasterises INTO the
|
||||
// surface (via a Canvas returned by the factory) and SkiaCanvas.drawImageRect
|
||||
// blits it back — with an Icon's tint applied through the paint's ColorFilter.
|
||||
// prepareToDraw() invalidates the cached snapshot so the next blit re-samples
|
||||
// the freshly-drawn surface.
|
||||
internal class SkiaImageBitmap(
|
||||
override val width: Int,
|
||||
override val height: Int,
|
||||
override val config: ImageBitmapConfig,
|
||||
override val hasAlpha: Boolean,
|
||||
override val colorSpace: ColorSpace,
|
||||
) : ImageBitmap {
|
||||
|
||||
// Raster (CPU) surface: matches SkiaImageCache.rasterizeSvg's pattern and
|
||||
// works on every SkiaBridge (Metal / OpenGL / software raster). Icon-sized
|
||||
// vectors are tiny; a GPU surface would need a shared GrContext with the
|
||||
// active bridge which Skiko doesn't expose portably here.
|
||||
val surface: Surface = Surface.makeRasterN32Premul(width.coerceAtLeast(1), height.coerceAtLeast(1))
|
||||
|
||||
// Cached snapshot of the surface pixels. Cleared by prepareToDraw() so a
|
||||
// re-rasterised vector reuploads on the next blit; retained across frames
|
||||
// when the vector doesn't change.
|
||||
private var fSnapshot: Image? = null
|
||||
|
||||
fun snapshot(): Image {
|
||||
val vExisting = fSnapshot
|
||||
if (vExisting != null) return vExisting
|
||||
val vFresh = surface.makeImageSnapshot()
|
||||
fSnapshot = vFresh
|
||||
return vFresh
|
||||
}
|
||||
|
||||
override fun readPixels(
|
||||
buffer: IntArray,
|
||||
startX: Int,
|
||||
startY: Int,
|
||||
width: Int,
|
||||
height: Int,
|
||||
bufferOffset: Int,
|
||||
stride: Int,
|
||||
) {
|
||||
}
|
||||
|
||||
override fun prepareToDraw() {
|
||||
fSnapshot?.close()
|
||||
fSnapshot = null
|
||||
}
|
||||
}
|
||||
|
||||
// Registered on SkiaRenderBackend; hands the vendored DrawCache a real offscreen.
|
||||
internal class SkiaOffscreenRenderer(
|
||||
private val fTextRenderer: SkiaTextRenderer?,
|
||||
private val fImageCache: SkiaImageCache?,
|
||||
) : OffscreenRenderer {
|
||||
|
||||
override fun createImageBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
config: ImageBitmapConfig,
|
||||
hasAlpha: Boolean,
|
||||
colorSpace: ColorSpace,
|
||||
): ImageBitmap = SkiaImageBitmap(width, height, config, hasAlpha, colorSpace)
|
||||
|
||||
override fun createCanvas(image: ImageBitmap): Canvas? {
|
||||
val vBmp = image as? SkiaImageBitmap ?: return null
|
||||
return SkiaCanvas(
|
||||
vBmp.surface.canvas,
|
||||
Size(vBmp.width.toFloat(), vBmp.height.toFloat()),
|
||||
fTextRenderer,
|
||||
fImageCache,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: SkiaEncodedImageDecoder
|
||||
@@ -98,7 +14,12 @@ internal class SkiaOffscreenRenderer(
|
||||
(painterResource / SVG). Raster formats go through Image.makeFromEncoded;
|
||||
when that fails the bytes are retried as SVG through the same SVGDOM
|
||||
rasterisation SkiaImageCache uses. Pure CPU raster (no GrContext), so it is
|
||||
safe on the resources pipeline's Dispatchers.Default workers. */
|
||||
safe on the resources pipeline's Dispatchers.Default workers.
|
||||
|
||||
B6.1: produces an UPSTREAM ImageBitmap (Image.toComposeImageBitmap) — the Skia
|
||||
leg now uses upstream's SkiaBackedCanvas/SkiaImageAsset, so the offscreen +
|
||||
ImageBitmap-backed paths go through upstream's own actuals (the project
|
||||
SkiaImageBitmap / SkiaOffscreenRenderer are retired). */
|
||||
internal class SkiaEncodedImageDecoder : EncodedImageDecoder {
|
||||
|
||||
override fun decode(inBytes: ByteArray): ImageBitmap? {
|
||||
@@ -106,13 +27,7 @@ internal class SkiaEncodedImageDecoder : EncodedImageDecoder {
|
||||
val vImg = runCatching { Image.makeFromEncoded(inBytes) }.getOrNull()
|
||||
?: rasterizeSvg(inBytes)
|
||||
?: return null
|
||||
val vBmp = SkiaImageBitmap(
|
||||
vImg.width, vImg.height,
|
||||
config = ImageBitmapConfig.Argb8888,
|
||||
hasAlpha = true,
|
||||
colorSpace = ColorSpaces.Srgb,
|
||||
)
|
||||
vBmp.surface.canvas.drawImage(vImg, 0f, 0f)
|
||||
val vBmp = vImg.toComposeImageBitmap()
|
||||
vImg.close()
|
||||
return vBmp
|
||||
}
|
||||
|
||||
+29
-20
@@ -5,6 +5,7 @@ import com.compose.sdl.res.ImageLoader
|
||||
import com.compose.sdl.res.ResourceKind
|
||||
import com.compose.sdl.text.TextMeasurer
|
||||
import com.compose.sdl.*
|
||||
import androidx.compose.ui.graphics.asComposeCanvas
|
||||
import org.jetbrains.skia.Canvas
|
||||
import org.jetbrains.skia.Color as SkColor
|
||||
|
||||
@@ -19,10 +20,10 @@ import org.jetbrains.skia.Color as SkColor
|
||||
AUTO is resolved at the call site (ComposeWindow); this class only
|
||||
sees concrete modes.
|
||||
|
||||
NOTE: Phase 9 pivoted rendering to the upstream engine via drawRoot(host).
|
||||
A real SkiaCanvas : androidx.compose.ui.graphics.Canvas actual is TODO —
|
||||
until then the Skia path compiles but paints nothing (drawRoot inherits
|
||||
the RenderBackend default no-op). */
|
||||
B6.1: the composition draws through upstream's SkiaBackedCanvas (real
|
||||
paint/shader/gradients). The port's text engine (SkiaTextRenderer) +
|
||||
resource-image cache (SkiaImageCache) + elevation shadows ride behind it
|
||||
via skiaLeafDrawer + the SkiaBackedCanvas manual-vendor. */
|
||||
internal class SkiaRenderBackend(
|
||||
private val sdl: SDL3Backend,
|
||||
private val gpuMode: GpuMode,
|
||||
@@ -45,6 +46,15 @@ internal class SkiaRenderBackend(
|
||||
}
|
||||
}
|
||||
|
||||
// B6.1: the Skia leg draws through upstream SkiaBackedCanvas; its port draw
|
||||
// contracts (text/painter/shadow) forward to the port renderers via this global
|
||||
// drawer. It is per-WINDOW state (each window has its own text renderer + image
|
||||
// cache), so it must be re-pointed at THIS backend before every frame — exactly
|
||||
// like ComposeWindow.installGlobals() does for currentTextMeasurer. Setting it
|
||||
// once in the ctor left it dangling at a CLOSED window's (destroyed) renderer
|
||||
// after a multi-window teardown → crash on the surviving window's next frame.
|
||||
private val fLeafDrawer = SkiaLeafDrawer(fSkiaTextRenderer, fSkiaImageCache)
|
||||
|
||||
override val textMeasurer: TextMeasurer
|
||||
get() = fSkiaTextRenderer.textMeasurer
|
||||
|
||||
@@ -69,10 +79,8 @@ internal class SkiaRenderBackend(
|
||||
override fun beginFrame(inDpr: Float) {
|
||||
val canvas = fBridge.canvas
|
||||
// Clear the surface to Material dark. Without this the Metal back
|
||||
// buffer shows uninitialized GPU memory (pink) since Sdl3Renderer.draw
|
||||
// no longer runs and drawRoot is a no-op inherited default until
|
||||
// SkiaCanvas lands. Clearing before scale so it covers the whole
|
||||
// physical target.
|
||||
// buffer shows uninitialized GPU memory (pink). Clearing before scale
|
||||
// so it covers the whole physical target.
|
||||
canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12))
|
||||
canvas.save()
|
||||
if (inDpr != 1f) canvas.scale(inDpr, inDpr)
|
||||
@@ -86,19 +94,17 @@ internal class SkiaRenderBackend(
|
||||
fCurrentCanvas = canvas
|
||||
}
|
||||
|
||||
override fun drawRoot(inHost: com.compose.sdl.node.ComposeRootHost) {
|
||||
override fun drawRoot(inDraw: (androidx.compose.ui.graphics.Canvas) -> Unit) {
|
||||
val canvas = fCurrentCanvas ?: return
|
||||
// Register the offscreen (ImageBitmap) render path so the vendored
|
||||
// VectorPainter / DrawCache pipeline — hence material3's ImageVector
|
||||
// icons — renders. Idempotent.
|
||||
if (com.compose.sdl.graphics.offscreenRenderer == null) {
|
||||
com.compose.sdl.graphics.offscreenRenderer =
|
||||
SkiaOffscreenRenderer(fSkiaTextRenderer, fSkiaImageCache)
|
||||
}
|
||||
val vSize = androidx.compose.ui.geometry.Size(sdl.pixelWidth.toFloat(), sdl.pixelHeight.toFloat())
|
||||
val vCanvas = SkiaCanvas(canvas, vSize, fSkiaTextRenderer, fSkiaImageCache)
|
||||
inHost.rootNode.draw(vCanvas, null)
|
||||
vCanvas.finish()
|
||||
// Point the leaf-draw global at THIS window's renderers before drawing.
|
||||
skiaLeafDrawer = fLeafDrawer
|
||||
// Wrap the live skia canvas as upstream's SkiaBackedCanvas (real gradients/
|
||||
// paint/shader). Its port draw contracts forward to skiaLeafDrawer. The
|
||||
// ImageBitmap-backed offscreen (VectorPainter / DrawCache) now goes through
|
||||
// upstream's own ActualCanvas, so no project offscreenRenderer registration.
|
||||
val vCanvas = canvas.asComposeCanvas()
|
||||
inDraw(vCanvas)
|
||||
(vCanvas as? com.compose.sdl.graphics.NativeFinishableCanvas)?.finish()
|
||||
}
|
||||
|
||||
override fun endFrame() {
|
||||
@@ -110,6 +116,9 @@ internal class SkiaRenderBackend(
|
||||
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? = fBridge.snapshotBgra()
|
||||
|
||||
override fun destroy() {
|
||||
// Drop the global if it still points at this (about-to-be-destroyed) backend,
|
||||
// so a stray draw before the next window's drawRoot can't hit freed renderers.
|
||||
if (skiaLeafDrawer === fLeafDrawer) skiaLeafDrawer = null
|
||||
fSkiaImageCache.destroy()
|
||||
fSkiaTextRenderer.destroy()
|
||||
fBridge.destroy()
|
||||
|
||||
+23
-8
@@ -163,11 +163,22 @@ class SkiaTextRenderer {
|
||||
inBaseItalic: Boolean = false,
|
||||
inBaseUnderline: Boolean = false,
|
||||
inBaseLineThrough: Boolean = false,
|
||||
// The paragraph's per-line band (TextStyle.lineHeight) in px — stack lines at
|
||||
// the same advance layout used. <= 0 → font-metrics height (legacy callers).
|
||||
inLineHeightPx: Float = 0f,
|
||||
// Compat-trim (raw styles): the first line keeps the tight font cell. False for
|
||||
// M3's LineHeightStyle(Trim.None) — every line is the full band.
|
||||
inTrimFirstLine: Boolean = true,
|
||||
) {
|
||||
val vKey = TypefaceKey(inFontFamily, variationsKey(inFontVariations))
|
||||
val vBaseFont = getFont(vKey, inFontFamily, inFontVariations, inFontSize)
|
||||
val vBaseMetrics = vBaseFont.metrics
|
||||
val vBaseLineHeight = (vBaseMetrics.descent - vBaseMetrics.ascent).coerceAtLeast(1f)
|
||||
// Per-mode stacking (mirrors SdlParagraph): compat trim keeps the FIRST line at
|
||||
// the tight font cell; M3's Trim.None makes every line the full band. Following
|
||||
// lines advance by the band (inLineHeightPx) when supplied, else the font cell.
|
||||
val vFontCellH = (vBaseMetrics.descent - vBaseMetrics.ascent).coerceAtLeast(1f)
|
||||
val vBaseLineHeight = if (inLineHeightPx > 0f) inLineHeightPx else vFontCellH
|
||||
val vFirstLineH = if (inTrimFirstLine || inLineHeightPx <= 0f) vFontCellH else vBaseLineHeight
|
||||
val vCapHeight = if (vBaseMetrics.capHeight > 0f) vBaseMetrics.capHeight
|
||||
else inFontSize * 0.7f
|
||||
|
||||
@@ -191,9 +202,10 @@ class SkiaTextRenderer {
|
||||
inCanvas.drawString(expandTabs(vLines[0]), vPenX, vBaseline, vBaseFont, vFastPaint)
|
||||
} else {
|
||||
for ((vIdx, vLine) in vLines.withIndex()) {
|
||||
val vSlotTop = inY + vIdx * vBaseLineHeight
|
||||
if (vSlotTop + vBaseLineHeight < inViewTop || vSlotTop > inViewBottom) continue
|
||||
val vBaseline = vSlotTop + (vBaseLineHeight + vCapHeight) / 2f
|
||||
val vSlotH = if (vIdx == 0) vFirstLineH else vBaseLineHeight
|
||||
val vSlotTop = inY + (if (vIdx == 0) 0f else vFirstLineH + (vIdx - 1) * vBaseLineHeight)
|
||||
if (vSlotTop + vSlotH < inViewTop || vSlotTop > inViewBottom) continue
|
||||
val vBaseline = vSlotTop + (vSlotH + vCapHeight) / 2f
|
||||
val vPenX = alignX(inX, inBoxWidth, estimateTextWidth(vLine, inFontSize, inFontFamily, inFontVariations).toFloat(), inAlign)
|
||||
inCanvas.drawString(expandTabs(vLine), vPenX, vBaseline, vBaseFont, vFastPaint)
|
||||
}
|
||||
@@ -212,11 +224,14 @@ class SkiaTextRenderer {
|
||||
val vLineStart = vWrapped.lineStarts.getOrElse(vIdx) { 0 }
|
||||
val vLineH =
|
||||
if (vMetricSpans && inSpans != null) {
|
||||
styledLineCellHeight(
|
||||
vLine, vLineStart, inSpans, inFontSize, fDensity,
|
||||
textMeasurer, inFontFamily, inFontVariations,
|
||||
kotlin.math.max(
|
||||
styledLineCellHeight(
|
||||
vLine, vLineStart, inSpans, inFontSize, fDensity,
|
||||
textMeasurer, inFontFamily, inFontVariations,
|
||||
),
|
||||
if ((vIdx == 0 && inTrimFirstLine) || inLineHeightPx <= 0f) 0f else inLineHeightPx,
|
||||
).coerceAtLeast(1f)
|
||||
} else vBaseLineHeight
|
||||
} else if (vIdx == 0) vFirstLineH else vBaseLineHeight
|
||||
if (vLineY + vLineH < inViewTop || vLineY > inViewBottom) {
|
||||
vLineY += vLineH
|
||||
continue
|
||||
|
||||
+15
-9
@@ -13,15 +13,15 @@ plugins {
|
||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
// JVM parity target's Compose version — the dev build matching the pinned
|
||||
// JVM parity target's Compose version — the Maven build matching the pinned
|
||||
// COMPOSE_CORE_REF (see scripts/compose-fork/compose.properties). material3
|
||||
// rides its OWN release train: same +dev build number, different base
|
||||
// version. Gradle orders "+dev" qualifiers BELOW the plain version, so
|
||||
// currentOs's beta01 would win conflict resolution — force the core-repo
|
||||
// groups on every jvm configuration. Umbrella-repo groups (desktop,
|
||||
// components) are NOT forced: their dev numbering differs.
|
||||
val vComposeJvmVersion = "1.12.0-beta01+dev4324"
|
||||
val vComposeM3JvmVersion = "1.12.0-alpha03+dev4324"
|
||||
// rides its OWN release train (different base version). Forcing keeps the
|
||||
// core-repo groups locked to the pin on every jvm configuration even when
|
||||
// the umbrella plugin's own version would win conflict resolution (essential
|
||||
// for "+dev" pins, which Gradle orders BELOW the plain version). Umbrella-repo
|
||||
// groups (desktop, components) are NOT forced: their versioning differs.
|
||||
val vComposeJvmVersion = libs.versions.compose.get()
|
||||
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
|
||||
val vComposeJvmForced = mapOf(
|
||||
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
|
||||
"org.jetbrains.compose.ui" to vComposeJvmVersion,
|
||||
@@ -111,7 +111,7 @@ kotlin {
|
||||
// visibility check. The runtime is the official klib
|
||||
// everywhere, never substituted (jvm configs force the +dev
|
||||
// build above).
|
||||
implementation("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
implementation("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
implementation("org.jetbrains.compose.ui:ui:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.ui:ui-graphics:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.ui:ui-text:$vComposeJvmVersion")
|
||||
@@ -309,4 +309,10 @@ tasks.named<ProcessResources>("jvmProcessResources") {
|
||||
from(vFontFile) { into("font") }
|
||||
dependsOn(vDownloadTask)
|
||||
}
|
||||
// P0.3 (RENDERER.md §8): stage the SAME default font the native leg bundles
|
||||
// (data.kres font/NotoSans.ttf) onto the JVM classpath, so the parity harness's JVM leg
|
||||
// renders text with NotoSans too — collapsing the font-drift baseline to (near) just
|
||||
// rasterizer AA. MainJvm loads it from /font/NotoSans.ttf.
|
||||
from(notoSansFile) { into("font") }
|
||||
dependsOn(":ui:downloadNotoFonts")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.allocArray
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.toKString
|
||||
|
||||
// Current resident set (MB) via `ps -o rss=` (KB). See currentResidentMb in MainNative.kt.
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal actual fun currentResidentMb(): Long {
|
||||
val vPid = platform.posix.getpid()
|
||||
val vFp = platform.posix.popen("ps -o rss= -p $vPid", "r") ?: return -1
|
||||
val vKb = memScoped {
|
||||
val vBuf = allocArray<kotlinx.cinterop.ByteVar>(64)
|
||||
val vLine = platform.posix.fgets(vBuf, 64, vFp)?.toKString()?.trim()
|
||||
vLine?.toLongOrNull() ?: -1L
|
||||
}
|
||||
platform.posix.pclose(vFp)
|
||||
return if (vKb < 0) -1 else vKb / 1024
|
||||
}
|
||||
@@ -4,14 +4,20 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.platform.Font
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
@@ -40,6 +46,10 @@ fun main(args: Array<String>) {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (args.any { it == "--metrics" }) {
|
||||
printParagraphMetrics()
|
||||
return
|
||||
}
|
||||
application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
@@ -56,37 +66,168 @@ fun main(args: Array<String>) {
|
||||
private fun Array<String>.intArg(name: String, default: Int): Int =
|
||||
firstOrNull { it.startsWith("$name=") }?.substringAfter('=')?.toIntOrNull() ?: default
|
||||
|
||||
// P0.5 render-to-quiescence (mirrors the native leg): step a VIRTUAL 60fps clock and
|
||||
// re-render while the scene still has invalidations, so entrance animations settle
|
||||
// before capture. Infinite animations are CANCELLED via the upstream test
|
||||
// InfiniteAnimationPolicy (they freeze at their initial value), so looping screens
|
||||
// settle too. The cap only guards never-settling content the policy can't reach.
|
||||
private const val FRAME_NANOS = 16_666_667L
|
||||
private const val MAX_FRAMES = 300
|
||||
|
||||
private object CancelInfiniteAnimations : androidx.compose.ui.platform.InfiniteAnimationPolicy {
|
||||
override suspend fun <R> onInfiniteOperation(block: suspend () -> R): R =
|
||||
throw kotlinx.coroutines.CancellationException("infinite animations disabled for parity screenshots")
|
||||
}
|
||||
|
||||
/* Render each registered screen headlessly (density 1 to match the native
|
||||
physical-pixel screenshots) and write a PNG per screen. */
|
||||
physical-pixel screenshots) to quiescence and write a PNG per screen. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun screenshotAllScreens(outDir: File, width: Int, height: Int) {
|
||||
outDir.mkdirs()
|
||||
val screens = allCategories().flatMap { it.screens }.distinctBy { it.name }
|
||||
for (screen in screens) {
|
||||
val scene = ImageComposeScene(width, height, density = Density(1f)) {
|
||||
val scene = ImageComposeScene(
|
||||
width, height, density = Density(1f),
|
||||
coroutineContext = kotlinx.coroutines.Dispatchers.Unconfined + CancelInfiniteAnimations,
|
||||
) {
|
||||
ScreenHost { screen.content() }
|
||||
}
|
||||
try {
|
||||
val image = scene.render()
|
||||
var nanos = 0L
|
||||
var frames = 0
|
||||
var image = scene.render(nanos)
|
||||
while (scene.hasInvalidations() && frames < MAX_FRAMES) {
|
||||
nanos += FRAME_NANOS
|
||||
image = scene.render(nanos)
|
||||
frames++
|
||||
}
|
||||
val png = image.encodeToData(EncodedImageFormat.PNG) ?: continue
|
||||
File(outDir, "${screen.name}.png").writeBytes(png.bytes)
|
||||
println("jvm screenshot: ${screen.name} (settled after $frames frame(s))")
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
println("jvm screenshot: ${screen.name}")
|
||||
}
|
||||
}
|
||||
|
||||
/* Same wrapper as MainNative's --screen path. */
|
||||
// P0.3 (RENDERER.md §8): the SAME default font the native leg bundles
|
||||
// (font/NotoSans.ttf, staged onto the JVM classpath by jvmProcessResources). Loading it
|
||||
// into the parity JVM leg collapses the font-drift baseline to (near) just rasterizer AA —
|
||||
// so the parity % measures real divergence, not "different default typeface". Null (→ no
|
||||
// alignment, harness still runs) if the resource is missing.
|
||||
private val notoSans: FontFamily? by lazy {
|
||||
val bytes = object {}.javaClass.getResourceAsStream("/font/NotoSans.ttf")?.readBytes()
|
||||
if (bytes == null) {
|
||||
println("parity(jvm): /font/NotoSans.ttf not on classpath — font NOT aligned")
|
||||
null
|
||||
} else FontFamily(Font(identity = "NotoSans", data = bytes))
|
||||
}
|
||||
|
||||
/* A copy of the M3 Typography with every core style forced to [family] (Typography styles
|
||||
carry their own fontFamily, so overriding LocalTextStyle alone wouldn't reach them). */
|
||||
private fun notoTypography(family: FontFamily): Typography {
|
||||
val b = Typography()
|
||||
return b.copy(
|
||||
displayLarge = b.displayLarge.copy(fontFamily = family),
|
||||
displayMedium = b.displayMedium.copy(fontFamily = family),
|
||||
displaySmall = b.displaySmall.copy(fontFamily = family),
|
||||
headlineLarge = b.headlineLarge.copy(fontFamily = family),
|
||||
headlineMedium = b.headlineMedium.copy(fontFamily = family),
|
||||
headlineSmall = b.headlineSmall.copy(fontFamily = family),
|
||||
titleLarge = b.titleLarge.copy(fontFamily = family),
|
||||
titleMedium = b.titleMedium.copy(fontFamily = family),
|
||||
titleSmall = b.titleSmall.copy(fontFamily = family),
|
||||
bodyLarge = b.bodyLarge.copy(fontFamily = family),
|
||||
bodyMedium = b.bodyMedium.copy(fontFamily = family),
|
||||
bodySmall = b.bodySmall.copy(fontFamily = family),
|
||||
labelLarge = b.labelLarge.copy(fontFamily = family),
|
||||
labelMedium = b.labelMedium.copy(fontFamily = family),
|
||||
labelSmall = b.labelSmall.copy(fontFamily = family),
|
||||
)
|
||||
}
|
||||
|
||||
/* Upstream-Paragraph metrics for a font-size sweep at density 1 (NotoSans — the same
|
||||
font the native leg bundles). The JVM half of the metrics-alignment probe: MainNative's
|
||||
`--metricsprobe` prints the same table from SdlParagraph; aligning the numbers kills
|
||||
the accumulating vertical text drift in parity (P3.1). Runs inside a headless scene so
|
||||
LocalFontFamilyResolver supplies the platform resolver. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
private fun printParagraphMetrics() {
|
||||
val scene = ImageComposeScene(100, 100, density = Density(1f)) {
|
||||
val resolver = androidx.compose.ui.platform.LocalFontFamilyResolver.current
|
||||
val density = Density(1f)
|
||||
fun paragraph(text: String, size: Int, lineHeight: Int?, m3Style: Boolean): androidx.compose.ui.text.Paragraph =
|
||||
androidx.compose.ui.text.Paragraph(
|
||||
text = text,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = size.sp,
|
||||
lineHeight = lineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||
fontFamily = notoSans,
|
||||
lineHeightStyle = if (m3Style) {
|
||||
androidx.compose.ui.text.style.LineHeightStyle(
|
||||
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
|
||||
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
|
||||
)
|
||||
} else null,
|
||||
),
|
||||
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
|
||||
density = density,
|
||||
fontFamilyResolver = resolver,
|
||||
)
|
||||
// What the demo's Text actually resolves to on this leg — is lineHeightStyle set?
|
||||
println("metrics: m3 bodyMedium = ${Typography().bodyMedium.fontSize}/${Typography().bodyMedium.lineHeight} lineHeightStyle=${Typography().bodyMedium.lineHeightStyle}")
|
||||
for (size in listOf(11, 12, 14, 16, 22, 24)) {
|
||||
val lh = size + 6
|
||||
val cell = paragraph("Hg", size, null, false)
|
||||
val one = paragraph("Hg", size, lh, false)
|
||||
val three = paragraph("Hg\nHg\nHg", size, lh, false)
|
||||
val oneM3 = paragraph("Hg", size, lh, true)
|
||||
val threeM3 = paragraph("Hg\nHg\nHg", size, lh, true)
|
||||
println(
|
||||
"metrics: size=$size lh=$lh cell=${cell.height} " +
|
||||
"one=${one.height} three=${three.height} " +
|
||||
"base1=${one.firstBaseline} base3=${three.lastBaseline} " +
|
||||
"oneM3=${oneM3.height} threeM3=${threeM3.height} base1M3=${oneM3.firstBaseline}"
|
||||
)
|
||||
}
|
||||
// h-boundary cases: does upstream apply lineHeight at exactly 1em?
|
||||
for ((s, l) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
|
||||
val b = paragraph("Hg", s, l, true)
|
||||
println("metrics: boundary $s/$l m3=${b.height} base=${b.firstBaseline}")
|
||||
}
|
||||
// The band-smaller-than-cell case (Counter's fontSize=48 under body lineHeight).
|
||||
val big = paragraph("42", 48, 24, false)
|
||||
val bigM3 = paragraph("42", 48, 24, true)
|
||||
println("metrics: big48/lh24 raw=${big.height} base=${big.firstBaseline} m3=${bigM3.height} baseM3=${bigM3.firstBaseline}")
|
||||
println("metrics: DONE")
|
||||
}
|
||||
try {
|
||||
scene.render()
|
||||
} finally {
|
||||
scene.close()
|
||||
}
|
||||
}
|
||||
|
||||
/* Same wrapper as MainNative's --screen path — plus NotoSans font-alignment (P0.3) so the
|
||||
parity diff isn't dominated by the JVM's default typeface. */
|
||||
@Composable
|
||||
private fun ScreenHost(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
) { content() }
|
||||
val family = notoSans
|
||||
MaterialTheme(
|
||||
colorScheme = darkColorScheme(),
|
||||
typography = if (family != null) notoTypography(family) else Typography(),
|
||||
) {
|
||||
val base = LocalTextStyle.current
|
||||
CompositionLocalProvider(
|
||||
LocalTextStyle provides (if (family != null) base.copy(fontFamily = family) else base),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.allocArray
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.toKString
|
||||
|
||||
// Current resident set (MB) via `ps -o rss=` (KB). See currentResidentMb in MainNative.kt.
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal actual fun currentResidentMb(): Long {
|
||||
val vPid = platform.posix.getpid()
|
||||
val vFp = platform.posix.popen("ps -o rss= -p $vPid", "r") ?: return -1
|
||||
val vKb = memScoped {
|
||||
val vBuf = allocArray<kotlinx.cinterop.ByteVar>(64)
|
||||
val vLine = platform.posix.fgets(vBuf, 64, vFp)?.toKString()?.trim()
|
||||
vLine?.toLongOrNull() ?: -1L
|
||||
}
|
||||
platform.posix.pclose(vFp)
|
||||
return if (vKb < 0) -1 else vKb / 1024
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Windows has no `ps`/popen; the memory soak (--soaktest) is a macOS/Linux gate
|
||||
// (see scripts/verify-mac.sh), so current RSS is not sampled here.
|
||||
internal actual fun currentResidentMb(): Long = -1L
|
||||
@@ -25,6 +25,10 @@ import screens.ExtraWindows
|
||||
import utils.encodeBmpBgra32
|
||||
import utils.parseArgs
|
||||
import utils.writeFile
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.allocArray
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.toKString
|
||||
|
||||
// ==================
|
||||
// MARK: Entry point
|
||||
@@ -95,6 +99,12 @@ fun main(args: Array<String>) {
|
||||
runParagraphTest()
|
||||
return
|
||||
}
|
||||
// Prints Paragraph cell/line metrics for a size sweep — compare against the JVM
|
||||
// leg's `--metrics` output to align the SDL text metrics with upstream (P3.1).
|
||||
if (args.any { it == "--metricsprobe" }) {
|
||||
runMetricsProbe()
|
||||
return
|
||||
}
|
||||
// Verifies the Dialog appearance animation (upstream Dialog.skiko.kt parity):
|
||||
// opens a real m3 AlertDialog via an injected click, dumps mid-animation and
|
||||
// settled screenshots — the mid shots must show the dialog fainter and lower.
|
||||
@@ -117,6 +127,10 @@ fun main(args: Array<String>) {
|
||||
runNav3Test()
|
||||
return
|
||||
}
|
||||
if (args.any { it == "--soaktest" }) {
|
||||
runSoakTest()
|
||||
return
|
||||
}
|
||||
|
||||
val vCli = parseArgs(args)
|
||||
val vTitle = buildString {
|
||||
@@ -125,6 +139,15 @@ fun main(args: Array<String>) {
|
||||
append(" [").append(vCli.gpu).append("]")
|
||||
}
|
||||
|
||||
// Screenshot runs freeze infinite animations (rememberInfiniteTransition & co. cancel
|
||||
// at their initial value) so every screen can reach quiescence, and step the frame
|
||||
// clocks on VIRTUAL time (16.6ms/frame, like the JVM leg's render(nanos)) so animation
|
||||
// races resolve identically every run — both must be set before the first window composes.
|
||||
if (vCli.screenshot != null) {
|
||||
com.compose.sdl.disableInfiniteAnimations = true
|
||||
com.compose.sdl.useVirtualFrameTime = true
|
||||
}
|
||||
|
||||
// Multi-window app shell: the showcase window plus any extra windows opened
|
||||
// from WindowScreen's "Multi-window" section (state-driven, Compose Desktop
|
||||
// style — the count IS the windows' lifetime).
|
||||
@@ -136,14 +159,23 @@ fun main(args: Array<String>) {
|
||||
height = vCli.height,
|
||||
gpu = vCli.gpu,
|
||||
onFrame = if (vCli.screenshot != null) {
|
||||
// P0.5 render-to-quiescence: capture once the window reports no pending
|
||||
// invalidations for a few consecutive frames (entrance animations settled,
|
||||
// async loads applied), or at the --frames cap as a safety net. Replaces
|
||||
// the fixed frame-6 capture, whose mid-animation timing was run-dependent.
|
||||
var vQuietFrames = 0
|
||||
{ bridge, frameIndex ->
|
||||
if (frameIndex == vCli.frames) {
|
||||
vQuietFrames = if (com.compose.sdl.windowHasInvalidations()) 0 else vQuietFrames + 1
|
||||
if (vQuietFrames >= 3 || frameIndex >= vCli.maxFrames) {
|
||||
if (frameIndex >= vCli.maxFrames) {
|
||||
println("Screenshot: quiescence not reached by frame $frameIndex - capturing anyway")
|
||||
}
|
||||
val vSnap = bridge.snapshotBgra()
|
||||
if (vSnap != null) {
|
||||
val (vW, vH, vBgra) = vSnap
|
||||
val vBmp = encodeBmpBgra32(vW, vH, vBgra)
|
||||
writeFile(vCli.screenshot, vBmp)
|
||||
println("Wrote screenshot: ${vCli.screenshot} (${vW}x${vH})")
|
||||
println("Wrote screenshot: ${vCli.screenshot} (${vW}x${vH}, settled at frame $frameIndex)")
|
||||
} else println("Screenshot snapshot was null")
|
||||
false // quit
|
||||
} else true
|
||||
@@ -447,6 +479,96 @@ private fun runSearchEscTest() {
|
||||
Crashes here (e.g. enableSavedStateHandles' INITIALIZED/CREATED contract) never
|
||||
reproduce under --screen, which composes during the initial CREATED composition.
|
||||
PASS = a screenshot gets written and the app exits cleanly. */
|
||||
/* P2.2 soak — cycle through EVERY registered screen kCycles times in ONE process,
|
||||
disposing each via a changing key() so composition + layer (skiko RenderNode / SDL
|
||||
node) allocate-and-release is exercised repeatedly. After each full cycle, GC then
|
||||
record peak RSS (getrusage ru_maxrss). Peak RSS is monotonic, so after cycle 1 it
|
||||
already reflects visiting every screen once; if there is NO leak it plateaus, if
|
||||
there IS one it keeps climbing each cycle. PASS iff last-cycle peak stays within a
|
||||
ceiling of the first-cycle peak. Runs on both renderer legs. */
|
||||
@OptIn(kotlin.native.runtime.NativeRuntimeApi::class, ExperimentalForeignApi::class)
|
||||
private fun runSoakTest() {
|
||||
// Disable never-settling animations so RSS reflects composition/layer lifetime,
|
||||
// not live animation-state churn (same seed the screenshot path uses).
|
||||
com.compose.sdl.disableInfiniteAnimations = true
|
||||
com.compose.sdl.useVirtualFrameTime = true
|
||||
val vAll = allCategories().flatMap { it.screens }
|
||||
// CDN_SOAK_SCREEN=<name> repeats ONE screen 40x/cycle (bisect a specific screen);
|
||||
// default cycles through every screen.
|
||||
val vTarget = platform.posix.getenv("CDN_SOAK_SCREEN")?.toKString()
|
||||
val vScreens = if (vTarget != null) { val vS = vAll.first { it.name.equals(vTarget, ignoreCase = true) }; List(40) { vS } }
|
||||
else vAll
|
||||
val vIndex = mutableStateOf(0)
|
||||
// CDN_SOAK_STATIC=1: mount ONE screen once, never remount — measure RSS every 120 frames.
|
||||
// Isolates a per-FRAME leak (RSS climbs with no remounts) from a per-MOUNT leak.
|
||||
val vStatic = platform.posix.getenv("CDN_SOAK_STATIC")?.toKString() == "1"
|
||||
val kFramesPerScreen = if (vStatic) 120 else 3
|
||||
val kCycles = platform.posix.getenv("CDN_SOAK_CYCLES")?.toKString()?.toIntOrNull() ?: 3
|
||||
val vRssPerCycleMb = mutableListOf<Long>()
|
||||
var vShown = 0
|
||||
|
||||
nativeComposeWindow(
|
||||
title = "soaktest",
|
||||
width = 1000,
|
||||
height = 700,
|
||||
onFrame = { _, vFrame ->
|
||||
if (vFrame > 0 && vFrame % kFramesPerScreen == 0) {
|
||||
if (vStatic) {
|
||||
// No remount — just pump frames on the one mounted screen.
|
||||
kotlin.native.runtime.GC.collect()
|
||||
vRssPerCycleMb.add(currentResidentMb())
|
||||
println("soaktest[static]: measure ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
|
||||
} else {
|
||||
vShown++
|
||||
vIndex.value = vShown % vScreens.size
|
||||
if (vShown % vScreens.size == 0) {
|
||||
kotlin.native.runtime.GC.collect()
|
||||
vRssPerCycleMb.add(currentResidentMb())
|
||||
println("soaktest: cycle ${vRssPerCycleMb.size}: currentRSS=${vRssPerCycleMb.last()}MB")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vRssPerCycleMb.size >= kCycles) {
|
||||
val vFirst = vRssPerCycleMb.first()
|
||||
val vLast = vRssPerCycleMb.last()
|
||||
val vGrowth = vLast - vFirst
|
||||
val vCeiling = maxOf(48L, vFirst / 4) // 25% or 48MB, whichever larger
|
||||
println("soaktest: currentRSS/cycle(MB)=$vRssPerCycleMb (${vScreens.size} screens x $kCycles cycles)")
|
||||
if (vGrowth <= vCeiling) {
|
||||
println("soaktest: PASS (current RSS grew ${vGrowth}MB over cycles 1->$kCycles, within ${vCeiling}MB ceiling)")
|
||||
} else {
|
||||
println("soaktest: FAIL (current RSS grew ${vGrowth}MB > ${vCeiling}MB ceiling — possible leak)")
|
||||
}
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
val vScroll = rememberScrollState()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(vScroll)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
// key(vShown) forces a FULL dispose+recompose each advance (even when the
|
||||
// target screen repeats) — the allocate/release churn we soak. Reading
|
||||
// vIndex.value (a State) is what triggers the recomposition each mount.
|
||||
key(vShown) {
|
||||
vScreens[vIndex.value].content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* CURRENT resident set (MB): current RSS can DROP after GC, so it distinguishes a
|
||||
true leak (ratchets up) from K/N allocator high-water, unlike getrusage's
|
||||
monotonic peak. posix reads it from `ps`; mingw has no `ps`/popen and returns
|
||||
-1 (the soak gate runs on macOS/Linux — see scripts/verify-mac.sh). */
|
||||
internal expect fun currentResidentMb(): Long
|
||||
|
||||
private fun runNav3Test() {
|
||||
val vShow = mutableStateOf(false)
|
||||
nativeComposeWindow(
|
||||
@@ -652,6 +774,65 @@ private fun runParagraphTest() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Prints paragraph metrics (single-line cell, lineHeight-styled single + triple line,
|
||||
first baseline) for a font-size sweep at density 1 — the native half of the
|
||||
metrics-alignment probe. Mirror of MainJvm's `--metrics`; both must print the same
|
||||
numbers for the parity text drift to vanish. */
|
||||
private fun runMetricsProbe() {
|
||||
fun paragraph(inText: String, inSize: Int, inLineHeight: Int?, inM3Style: Boolean): androidx.compose.ui.text.Paragraph =
|
||||
androidx.compose.ui.text.Paragraph(
|
||||
text = inText,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = inSize.sp,
|
||||
lineHeight = inLineHeight?.sp ?: androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||
lineHeightStyle = if (inM3Style) {
|
||||
androidx.compose.ui.text.style.LineHeightStyle(
|
||||
alignment = androidx.compose.ui.text.style.LineHeightStyle.Alignment.Center,
|
||||
trim = androidx.compose.ui.text.style.LineHeightStyle.Trim.None,
|
||||
)
|
||||
} else null,
|
||||
),
|
||||
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 10_000),
|
||||
density = androidx.compose.ui.unit.Density(1f),
|
||||
fontFamilyResolver = androidx.compose.ui.text.font.createFontFamilyResolver(),
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Clip,
|
||||
)
|
||||
nativeComposeWindow(
|
||||
title = "metricsprobe",
|
||||
width = 300,
|
||||
height = 200,
|
||||
onFrame = { _, frameIndex ->
|
||||
if (frameIndex == 10) {
|
||||
for (vSize in listOf(11, 12, 14, 16, 22, 24)) {
|
||||
val vLh = vSize + 6
|
||||
val vCell = paragraph("Hg", vSize, null, false)
|
||||
val vOne = paragraph("Hg", vSize, vLh, false)
|
||||
val vThree = paragraph("Hg\nHg\nHg", vSize, vLh, false)
|
||||
val vOneM3 = paragraph("Hg", vSize, vLh, true)
|
||||
val vThreeM3 = paragraph("Hg\nHg\nHg", vSize, vLh, true)
|
||||
println(
|
||||
"metrics: size=$vSize lh=$vLh cell=${vCell.height} " +
|
||||
"one=${vOne.height} three=${vThree.height} " +
|
||||
"base1=${vOne.firstBaseline} base3=${vThree.lastBaseline} " +
|
||||
"oneM3=${vOneM3.height} threeM3=${vThreeM3.height} base1M3=${vOneM3.firstBaseline}"
|
||||
)
|
||||
}
|
||||
for ((vS, vL) in listOf(24 to 24, 24 to 25, 32 to 24, 16 to 16)) {
|
||||
val vB = paragraph("Hg", vS, vL, true)
|
||||
println("metrics: boundary $vS/$vL m3=${vB.height} base=${vB.firstBaseline}")
|
||||
}
|
||||
val vBig = paragraph("42", 48, 24, false)
|
||||
val vBigM3 = paragraph("42", 48, 24, true)
|
||||
println("metrics: big48/lh24 raw=${vBig.height} base=${vBig.firstBaseline} m3=${vBigM3.height} baseM3=${vBigM3.firstBaseline}")
|
||||
println("metricsprobe: DONE")
|
||||
false
|
||||
} else true
|
||||
},
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: clicktest
|
||||
// ==================
|
||||
|
||||
@@ -13,9 +13,10 @@ import com.compose.sdl.GpuMode
|
||||
sdl3.opengl | sdl3.metal | sdl3.vulkan |
|
||||
sdl3.d3d11 | sdl3.d3d12 (default: auto)
|
||||
--screen=Buttons | TextField | ... (default: full app w/ sidebar)
|
||||
--screenshot=path.bmp capture after frames and quit
|
||||
--screenshot=path.bmp capture at quiescence and quit
|
||||
--width=W --height=H (default 1000 / 700)
|
||||
--frames=N screenshot delay in frames
|
||||
--frames=N quiescence cap: capture at frame N even if
|
||||
the screen never settles (default 300)
|
||||
|
||||
Names match the Screen registry entries (case-insensitive). */
|
||||
internal data class CliArgs(
|
||||
@@ -24,7 +25,7 @@ internal data class CliArgs(
|
||||
val screenshot: String? = null,
|
||||
val width: Int = 1000,
|
||||
val height: Int = 700,
|
||||
val frames: Int = 6,
|
||||
val maxFrames: Int = 300,
|
||||
)
|
||||
|
||||
/* Translates the --gpu= string to the right GpuMode sealed instance.
|
||||
@@ -62,7 +63,7 @@ internal fun parseArgs(argv: Array<String>): CliArgs {
|
||||
"screenshot" -> vArgs.copy(screenshot = value)
|
||||
"width" -> vArgs.copy(width = value.toIntOrNull() ?: vArgs.width)
|
||||
"height" -> vArgs.copy(height = value.toIntOrNull() ?: vArgs.height)
|
||||
"frames" -> vArgs.copy(frames = value.toIntOrNull() ?: vArgs.frames)
|
||||
"frames" -> vArgs.copy(maxFrames = value.toIntOrNull() ?: vArgs.maxFrames)
|
||||
else -> vArgs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,20 +45,35 @@ dependencyResolutionManagement {
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// module build.gradle.kts — official coords, everywhere
|
||||
// module build.gradle.kts — official coords, everywhere.
|
||||
// Applying the plugin HERE exposes the `composeDesktopNative` extension, which
|
||||
// reports the exact Compose versions the port tracks, so you never hand-match
|
||||
// them against compose.properties.
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
id("com.bitsycore.compose-desktop-native.bridge")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
macosArm64(); linuxX64(); mingwX64() // + jvm()/android()/ios if you like
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
implementation("org.jetbrains.compose.ui:ui:<cmp-version>")
|
||||
implementation("org.jetbrains.compose.foundation:foundation:<cmp-version>")
|
||||
implementation("org.jetbrains.compose.material3:material3:<cmp-m3-version>")
|
||||
implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
|
||||
implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
|
||||
implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
|
||||
implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`composeDesktopNative` exposes four read-only values: `compose` (ui /
|
||||
foundation / animation / material), `composeMaterial3` (versioned separately
|
||||
upstream), `composeRuntime`, and `version` (the port klib version being
|
||||
substituted). You can still write literal versions if you prefer; the extension
|
||||
just removes the drift.
|
||||
|
||||
### Settings-wide or per-module
|
||||
|
||||
The plugin applies at either level:
|
||||
@@ -80,6 +95,16 @@ version (so module-level applications can omit it); the top-level
|
||||
Rule of thumb: single app module → apply in the module; multi-module builds →
|
||||
apply once in settings.
|
||||
|
||||
When applied from settings, the type-safe `composeDesktopNative` accessor is not
|
||||
generated in the module scripts. The same values are still available through
|
||||
project extra properties:
|
||||
|
||||
```kotlin
|
||||
val compose = project.extra["composeDesktopNative.compose"] as String
|
||||
val material3 = project.extra["composeDesktopNative.composeMaterial3"] as String
|
||||
val runtime = project.extra["composeDesktopNative.composeRuntime"] as String
|
||||
```
|
||||
|
||||
## compose.desktop.native — the application block for native
|
||||
|
||||
The native counterpart of `compose.desktop { application { mainClass } }`:
|
||||
@@ -122,10 +147,12 @@ overrides follow the default hierarchy (a `mingwX64Main` resource beats a
|
||||
- The substituted klib version defaults to the plugin's own version (both ship
|
||||
from the same tag). Override with `composeDesktopNative.version=<x>` in
|
||||
`gradle.properties` when mixing releases.
|
||||
- Match `<cmp-version>` to the CMP release the port's vendored sources track
|
||||
(see `scripts/compose-fork/compose.properties` at the release tag) —
|
||||
substitution replaces whatever version you request on native, but your
|
||||
jvm/android targets resolve the official artifacts at the version you write.
|
||||
- Use `composeDesktopNative.compose` / `.composeMaterial3` / `.composeRuntime`
|
||||
(above) for the official coords rather than hardcoding a version. Substitution
|
||||
replaces the requested version on native, but your jvm/android targets resolve
|
||||
the official artifacts at the version you write, so matching what the port
|
||||
tracks keeps every target on the same API. The values come straight from the
|
||||
release the plugin shipped with; no need to read `compose.properties` by hand.
|
||||
- Requires Gradle 8.8+ when applied in settings (`gradle.lifecycle.beforeProject`).
|
||||
- App windowing/main-loop (`com.bitsycore.compose.sdl:window`) and the icon
|
||||
font module (`material-symbols`) are the port's own APIs — depend on them
|
||||
|
||||
@@ -31,16 +31,30 @@ gradlePlugin {
|
||||
}
|
||||
|
||||
// The plugin defaults the substitution version to ITS OWN version (plugin and
|
||||
// klibs ship from the same tag), read at runtime from this generated resource.
|
||||
// klibs ship from the same tag) and also ADVERTISES the official Compose
|
||||
// Multiplatform versions the port tracks, so consumers declare the matching
|
||||
// coords without reading compose.properties by hand. Both are read at runtime
|
||||
// from this generated resource, sourced from the version catalog (single truth).
|
||||
val vVersionDir = layout.buildDirectory.dir("generated/bridge-version")
|
||||
val vGenerateVersion = tasks.register("generateBridgeVersionResource") {
|
||||
val vVersion = project.version.toString()
|
||||
val vCompose = libs.versions.compose.get()
|
||||
val vComposeMaterial3 = libs.versions.composeMaterial3.get()
|
||||
val vComposeRuntime = libs.versions.composeRuntime.get()
|
||||
val vOut = vVersionDir.get().file("com/bitsycore/compose/sdl/gradle/bridge-version.properties").asFile
|
||||
inputs.property("version", vVersion)
|
||||
inputs.property("compose", vCompose)
|
||||
inputs.property("composeMaterial3", vComposeMaterial3)
|
||||
inputs.property("composeRuntime", vComposeRuntime)
|
||||
outputs.dir(vVersionDir)
|
||||
doLast {
|
||||
vOut.parentFile.mkdirs()
|
||||
vOut.writeText("version=$vVersion\n")
|
||||
vOut.writeText(
|
||||
"version=$vVersion\n" +
|
||||
"compose=$vCompose\n" +
|
||||
"composeMaterial3=$vComposeMaterial3\n" +
|
||||
"composeRuntime=$vComposeRuntime\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
sourceSets["main"].resources.srcDir(vVersionDir)
|
||||
|
||||
+76
-7
@@ -45,6 +45,45 @@ private const val portGroup = "com.bitsycore.compose.sdl"
|
||||
/** Gradle property overriding the substitution version (default: the plugin's own). */
|
||||
private const val versionProperty = "composeDesktopNative.version"
|
||||
|
||||
// ==================
|
||||
// MARK: composeDesktopNative { } — version info exposed to the consumer
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* Read-only info the bridge exposes to the consuming build as the project
|
||||
* extension `composeDesktopNative`. Use it to declare the official Compose
|
||||
* Multiplatform coordinates that match the port's vendored sources, so
|
||||
* jvm/android targets stay aligned with what the native klibs were built from
|
||||
* (no more reading compose.properties at the release tag by hand):
|
||||
*
|
||||
* ```kotlin
|
||||
* commonMain.dependencies {
|
||||
* implementation("org.jetbrains.compose.runtime:runtime:${composeDesktopNative.composeRuntime}")
|
||||
* implementation("org.jetbrains.compose.ui:ui:${composeDesktopNative.compose}")
|
||||
* implementation("org.jetbrains.compose.foundation:foundation:${composeDesktopNative.compose}")
|
||||
* implementation("org.jetbrains.compose.material3:material3:${composeDesktopNative.composeMaterial3}")
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The same values are mirrored into `project.extra` (keys `composeDesktopNative.compose`,
|
||||
* `.composeMaterial3`, `.composeRuntime`, `.version`) for builds that apply the
|
||||
* plugin from settings, where the type-safe accessor is not generated.
|
||||
*
|
||||
* The substituted PORT klib version is overridden with the
|
||||
* `composeDesktopNative.version` Gradle property (see [version]); this extension
|
||||
* only reports the effective value.
|
||||
*/
|
||||
open class ComposeDesktopNativeBridgeExtension(
|
||||
/** The port klib version substituted on native targets (after the `composeDesktopNative.version` override). */
|
||||
val version: String,
|
||||
/** Official Compose version for ui / foundation / animation / material. */
|
||||
val compose: String,
|
||||
/** Official material3 version (versioned separately upstream). */
|
||||
val composeMaterial3: String,
|
||||
/** Official Compose runtime version (never substituted; serves every target). */
|
||||
val composeRuntime: String,
|
||||
)
|
||||
|
||||
// ==================
|
||||
// MARK: Plugin
|
||||
// ==================
|
||||
@@ -58,7 +97,9 @@ private const val versionProperty = "composeDesktopNative.version"
|
||||
*
|
||||
* Apply in settings.gradle.kts (covers every project) or in a single module's
|
||||
* build.gradle.kts. The substituted version defaults to the plugin's own
|
||||
* version — override with `composeSdl.version` in gradle.properties.
|
||||
* version — override with `composeDesktopNative.version` in gradle.properties.
|
||||
* The official Compose versions the port tracks are exposed via the
|
||||
* `composeDesktopNative` extension (see [ComposeDesktopNativeBridgeExtension]).
|
||||
*/
|
||||
class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
|
||||
|
||||
@@ -81,7 +122,30 @@ class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
|
||||
private fun installBridge(project: Project) {
|
||||
installResourcePackaging(project)
|
||||
installNativeApplicationDsl(project)
|
||||
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
|
||||
|
||||
// The effective port version: the override property, else the plugin's own.
|
||||
val version = project.providers.gradleProperty(versionProperty).orNull ?: bridgeProperty("version")
|
||||
|
||||
// Advertise the Compose versions the port tracks so consumers align their
|
||||
// official coords without guessing. Extension for the common per-module apply
|
||||
// (type-safe accessor); extra properties for the settings apply (no accessor).
|
||||
if (project.extensions.findByName("composeDesktopNative") == null) {
|
||||
project.extensions.create(
|
||||
"composeDesktopNative",
|
||||
ComposeDesktopNativeBridgeExtension::class.java,
|
||||
version,
|
||||
bridgeProperty("compose"),
|
||||
bridgeProperty("composeMaterial3"),
|
||||
bridgeProperty("composeRuntime"),
|
||||
)
|
||||
}
|
||||
project.extensions.extraProperties.let { extra ->
|
||||
extra["composeDesktopNative.version"] = version
|
||||
extra["composeDesktopNative.compose"] = bridgeProperty("compose")
|
||||
extra["composeDesktopNative.composeMaterial3"] = bridgeProperty("composeMaterial3")
|
||||
extra["composeDesktopNative.composeRuntime"] = bridgeProperty("composeRuntime")
|
||||
}
|
||||
|
||||
project.configurations.configureEach { configuration ->
|
||||
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
|
||||
configuration.resolutionStrategy.dependencySubstitution { substitutions ->
|
||||
@@ -98,12 +162,17 @@ private fun installBridge(project: Project) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin's own version, stamped into a resource at build time. */
|
||||
private val pluginVersion: String by lazy {
|
||||
/* The plugin's own version + the Compose versions it tracks, stamped into a
|
||||
resource at build time (from the version catalog). `version` is the plugin's
|
||||
own release; the rest are the official CMP versions the port's vendored
|
||||
sources build against. */
|
||||
private val bridgeProperties: Properties by lazy {
|
||||
val stream = ComposeDesktopNativeBridgePlugin::class.java
|
||||
.getResourceAsStream("bridge-version.properties")
|
||||
?: throw GradleException("com.bitsycore.compose-desktop-native.bridge: missing bridge-version.properties resource")
|
||||
val properties = Properties().apply { stream.use { load(it) } }
|
||||
properties.getProperty("version")
|
||||
?: throw GradleException("com.bitsycore.compose-desktop-native.bridge: bridge-version.properties has no 'version'")
|
||||
Properties().apply { stream.use { load(it) } }
|
||||
}
|
||||
|
||||
private fun bridgeProperty(key: String): String =
|
||||
bridgeProperties.getProperty(key)
|
||||
?: throw GradleException("com.bitsycore.compose-desktop-native.bridge: bridge-version.properties has no '$key'")
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
[versions]
|
||||
kotlin = "2.4.0"
|
||||
compose = "1.12.0-beta01"
|
||||
# Official Compose Multiplatform versions the port's vendored sources track and
|
||||
# the JVM parity leg forces. SINGLE SOURCE: consumed by the bridge plugin
|
||||
# (stamped into bridge-version.properties and exposed to consumers) and by
|
||||
# demo / apidemo / material-symbols. Keep in lockstep with the vendored ref in
|
||||
# scripts/compose-fork/compose.properties on each bump.
|
||||
# compose = ui / foundation / animation / material
|
||||
# composeMaterial3 = material3 (versioned separately upstream)
|
||||
# composeRuntime = the official runtime klibs (never substituted)
|
||||
compose = "1.12.0-beta02"
|
||||
composeMaterial3 = "1.12.0-alpha03"
|
||||
composeRuntime = "1.11.1"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
kotlinxDateTime = "0.8.0"
|
||||
kotlinxSerialization = "1.11.0"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
# Vendor-clean check (P0.7, RENDERER.md) - fails if src/vendor/ has drifted from
|
||||
# what the pinned upstream refs produce.
|
||||
#
|
||||
# src/vendor/ is REGENERATED OUTPUT (gitignored): nothing hand-made may live there. A
|
||||
# hand-edit that leaks in "works on my machine" but silently diverges from every clean
|
||||
# checkout, and is overwritten by the next sync. This check makes that loud:
|
||||
#
|
||||
# 1. hash every file under each module's src/vendor/ (and snapshot each compose-fork.txt)
|
||||
# 2. run sync.py (regenerates the trees byte-for-byte from the pinned clones,
|
||||
# deleting stale files and re-annotating manifests)
|
||||
# 3. re-hash and diff: ANY change/add/remove = the local tree did not match the pin
|
||||
#
|
||||
# On failure the tree has already been RESTORED to the pinned state (that is sync.py's
|
||||
# normal behaviour) - the report tells you what drifted. If the edit was intentional,
|
||||
# manual-vendor it instead (CLAUDE.md vendoring rule 3: move the file out of src/vendor/,
|
||||
# comment its manifest line out, re-sync). Manifest churn means the annotations were
|
||||
# stale - commit the refreshed manifest.
|
||||
#
|
||||
# Usage (from anywhere; ASCII-only output for cp1252 consoles):
|
||||
# python scripts/compose-fork/check-vendor-clean.py
|
||||
# Exit: 0 clean · 1 drift found (tree now restored) · 2 sync itself failed
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO_ROOT = os.path.abspath(os.path.join(HERE, '..', '..'))
|
||||
PRUNE_DIRS = {'build', '.gradle', '.git', 'node_modules', 'scripts'}
|
||||
|
||||
|
||||
# ============
|
||||
# Every module dir carrying a compose-fork.txt (same discovery rules as sync.py).
|
||||
def module_dirs():
|
||||
found = []
|
||||
for dirpath, dirs, files in os.walk(REPO_ROOT):
|
||||
dirs[:] = [d for d in dirs if d not in PRUNE_DIRS]
|
||||
if 'compose-fork.txt' in files:
|
||||
found.append(dirpath)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
# ============
|
||||
# {repo-relative-path: sha1} over one directory tree (empty dict if absent).
|
||||
def hash_tree(root):
|
||||
out = {}
|
||||
if not os.path.isdir(root):
|
||||
return out
|
||||
for dr, _, fs in os.walk(root):
|
||||
for fn in fs:
|
||||
p = os.path.join(dr, fn)
|
||||
with open(p, 'rb') as fh:
|
||||
digest = hashlib.sha1(fh.read()).hexdigest()
|
||||
out[os.path.relpath(p, REPO_ROOT).replace(os.sep, '/')] = digest
|
||||
return out
|
||||
|
||||
|
||||
def read_text(path):
|
||||
with open(path, 'rb') as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def main():
|
||||
modules = module_dirs()
|
||||
if not modules:
|
||||
sys.stderr.write('check-vendor-clean: no compose-fork.txt manifests found\n')
|
||||
return 2
|
||||
|
||||
# ---- 1. pre-sync snapshot: vendor hashes + manifest bytes
|
||||
before = {}
|
||||
manifests_before = {}
|
||||
for mod in modules:
|
||||
before.update(hash_tree(os.path.join(mod, 'src', 'vendor')))
|
||||
mf = os.path.join(mod, 'compose-fork.txt')
|
||||
manifests_before[mf] = read_text(mf)
|
||||
print('check-vendor-clean: %d vendored files across %d modules - running sync.py'
|
||||
% (len(before), len(modules)))
|
||||
|
||||
# ---- 2. regenerate from the pinned refs
|
||||
rc = subprocess.run([sys.executable, os.path.join(HERE, 'sync.py')], cwd=REPO_ROOT).returncode
|
||||
if rc != 0:
|
||||
sys.stderr.write('check-vendor-clean: sync.py failed (rc=%d) - cannot judge cleanliness\n' % rc)
|
||||
return 2
|
||||
|
||||
# ---- 3. post-sync diff
|
||||
after = {}
|
||||
for mod in modules:
|
||||
after.update(hash_tree(os.path.join(mod, 'src', 'vendor')))
|
||||
changed = sorted(p for p in before.keys() & after.keys() if before[p] != after[p])
|
||||
removed = sorted(before.keys() - after.keys())
|
||||
added = sorted(after.keys() - before.keys())
|
||||
stale_manifests = sorted(
|
||||
os.path.relpath(mf, REPO_ROOT).replace(os.sep, '/')
|
||||
for mf, text in manifests_before.items() if read_text(mf) != text)
|
||||
|
||||
ok = True
|
||||
if changed or removed or added:
|
||||
ok = False
|
||||
print('\ncheck-vendor-clean: FAIL - src/vendor/ did not match the pinned refs '
|
||||
'(now restored by the sync):')
|
||||
for p in changed:
|
||||
print(' changed : %s' % p)
|
||||
for p in removed:
|
||||
print(' stale : %s (removed - no manifest entry produces it)' % p)
|
||||
for p in added:
|
||||
print(' missing : %s (recreated - was absent locally)' % p)
|
||||
print('src/vendor/ is regenerated output - never hand-edit it. Intentional edits '
|
||||
'must be MANUAL-VENDORED (move out of src/vendor/, comment the manifest line, '
|
||||
're-sync); see CLAUDE.md vendoring rule 3.')
|
||||
if stale_manifests:
|
||||
ok = False
|
||||
print('\ncheck-vendor-clean: FAIL - manifest annotations were stale (sync refreshed '
|
||||
'them; review + commit):')
|
||||
for p in stale_manifests:
|
||||
print(' manifest: %s' % p)
|
||||
|
||||
if ok:
|
||||
print('check-vendor-clean: PASS - src/vendor/ matches the pinned refs '
|
||||
'(%d files) and all manifests are stable' % len(after))
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Vendor-drift tripwire (RENDERER.md P0.6 / §8).
|
||||
|
||||
Manually-vendored files (copy-edited from upstream, NON-IDEMPOTENT) carry a
|
||||
machine-readable provenance line near their header:
|
||||
|
||||
// VENDOR-BASE: <upstream-repo-relative-path> @ <commit-or-tag>
|
||||
// VENDOR-BASE(<VARNAME>): <path> @ <ref> for a non-core repo, naming the
|
||||
compose.properties pin it tracks
|
||||
(e.g. COMPOSE_REF for the umbrella repo)
|
||||
|
||||
recording the exact upstream ref the file was last reconciled against. This script
|
||||
compares each to the CURRENT pin for its repo (COMPOSE_CORE_REF by default) from
|
||||
scripts/compose-fork/compose.properties. If they differ, the manual vendor MAY be
|
||||
stale — upstream could have changed that file since it was reconciled — and must be
|
||||
re-checked by hand. Exits non-zero if any file is stale, so it can gate a ref bump.
|
||||
|
||||
Deeper (optional) check: if the repo's local clone is found (../cmp-ref[-core] for the
|
||||
core repo, ../cmp-ref-compose-multiplatform for COMPOSE_REF), it runs
|
||||
`git diff <base>..<pin> -- <path>` and reports whether the upstream file ACTUALLY changed
|
||||
between the recorded base and the current pin — turning "might be stale" into
|
||||
"did / didn't change".
|
||||
|
||||
Run at each pin bump (see RENDERER.md §9 ref-bump runbook).
|
||||
No third-party deps; Windows / macOS / Linux.
|
||||
"""
|
||||
import re, sys, subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
PROPS = REPO / "scripts" / "compose-fork" / "compose.properties"
|
||||
MARKER = re.compile(r"VENDOR-BASE(?:\(([A-Z_][A-Z0-9_]*)\))?:\s*(\S+)\s*@\s*(\S+)")
|
||||
|
||||
# Pin variable -> candidate clone dir names (sibling of the repo checkout).
|
||||
CLONES = {
|
||||
"COMPOSE_CORE_REF": ("cmp-ref", "cmp-ref-core"),
|
||||
"COMPOSE_REF": ("cmp-ref-compose-multiplatform",),
|
||||
}
|
||||
|
||||
|
||||
def read_pins() -> dict:
|
||||
pins = {}
|
||||
for line in PROPS.read_text(encoding="utf-8").splitlines():
|
||||
s = line.strip()
|
||||
if s and not s.startswith("#") and "=" in s:
|
||||
name, val = s.split("=", 1)
|
||||
pins[name.strip()] = val.strip()
|
||||
if "COMPOSE_CORE_REF" not in pins:
|
||||
raise SystemExit("check-vendor-drift: COMPOSE_CORE_REF not found in compose.properties")
|
||||
return pins
|
||||
|
||||
|
||||
def find_clone(var: str):
|
||||
for name in CLONES.get(var, ()):
|
||||
p = REPO.parent / name
|
||||
if (p / ".git").exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def upstream_changed(clone: Path, path: str, base: str, pin: str):
|
||||
"""True if <path> differs between base..pin in the clone; None if it couldn't run
|
||||
(including the path not existing at the pin — a rename/move needs human eyes)."""
|
||||
try:
|
||||
present = subprocess.run(
|
||||
["git", "-C", str(clone), "ls-tree", "--name-only", pin, "--", path],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if present.returncode != 0 or not present.stdout.strip():
|
||||
return None # ref missing locally or path gone at the pin
|
||||
r = subprocess.run(
|
||||
["git", "-C", str(clone), "diff", "--quiet", f"{base}..{pin}", "--", path],
|
||||
capture_output=True,
|
||||
)
|
||||
return r.returncode != 0 # `git diff --quiet` exits 1 when there ARE differences
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
pins = read_pins()
|
||||
|
||||
entries = []
|
||||
for f in REPO.rglob("*.kt"):
|
||||
if "/src/vendor/" in f.as_posix():
|
||||
continue # gitignored verbatim re-sync tree — not manual vendors
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8", errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
for m in MARKER.finditer(text):
|
||||
var = m.group(1) or "COMPOSE_CORE_REF"
|
||||
entries.append((f, var, m.group(2), m.group(3)))
|
||||
|
||||
if not entries:
|
||||
print("check-vendor-drift: no VENDOR-BASE annotations found.")
|
||||
return 0
|
||||
|
||||
used_vars = sorted({var for _, var, _, _ in entries})
|
||||
for var in used_vars:
|
||||
print(f"check-vendor-drift: pin {var} = {pins.get(var, '(NOT IN compose.properties!)')}"
|
||||
f" clone = {find_clone(var) or '(none — ref-compare only)'}")
|
||||
print()
|
||||
|
||||
stale = []
|
||||
for f, var, path, base in sorted(entries, key=lambda e: e[0].as_posix()):
|
||||
rel = f.relative_to(REPO).as_posix()
|
||||
pin = pins.get(var)
|
||||
if pin is None:
|
||||
stale.append(rel)
|
||||
print(f" STALE {rel}\n tracks unknown pin variable {var!r}")
|
||||
continue
|
||||
if base == pin:
|
||||
print(f" OK {rel} (base == pin {var})")
|
||||
continue
|
||||
note = ""
|
||||
clone = find_clone(var)
|
||||
if clone:
|
||||
ch = upstream_changed(clone, path, base, pin)
|
||||
note = (" <-- upstream CHANGED base..pin; RECONCILE" if ch is True
|
||||
else " (upstream unchanged base..pin; safe to bump the VENDOR-BASE ref)"
|
||||
if ch is False else " (clone diff unavailable)")
|
||||
stale.append(rel)
|
||||
print(f" STALE {rel}\n base @ {base}\n pin @ {pin} ({var}){note}")
|
||||
|
||||
if stale:
|
||||
print(f"\ncheck-vendor-drift: {len(stale)} stale manual-vendor(s). Reconcile each against "
|
||||
f"its pinned upstream, then update its VENDOR-BASE ref.")
|
||||
return 1
|
||||
print("\ncheck-vendor-drift: all manual vendors match their pins. OK.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -2,8 +2,15 @@
|
||||
# module's compose-fork.txt as SET_REPO=<url>@<VARNAME>. Bump a ref here and
|
||||
# re-run scripts/compose-fork/sync.sh to re-sync everything to it.
|
||||
|
||||
# JetBrains/compose-multiplatform-core (tag v1.12.0-beta01+dev4324)
|
||||
COMPOSE_CORE_REF=1be9d64ad12db0c79f44aedf9d9388e644c60871
|
||||
# JetBrains/compose-multiplatform-core -- pinned to the durable TAG (fetchable by name
|
||||
# from any fresh clone, unlike a bare unreferenced SHA which upstream could GC).
|
||||
# 2026-07-18 O.2 bump: beta02 -> v1.12.0-beta03+dev4483. No CLEAN beta03/rc/stable tag
|
||||
# exists on either repo yet, and beta03 is NOT on Maven Central, so this is the latest
|
||||
# DURABLE (fetchable-by-name tag) ref. VENDORED (native) side only: the JVM parity leg's
|
||||
# Maven forcing (compose 1.12.0-beta02 / m3 1.12.0-alpha03, latest PUBLISHED) stays put --
|
||||
# a documented native(beta03+dev) vs JVM(beta02) skew until beta03 hits Maven Central, at
|
||||
# which point bump the vComposeJvmVersion forcing in demo/apidemo/material-symbols to match.
|
||||
COMPOSE_CORE_REF=v1.12.0-beta03+dev4483
|
||||
|
||||
# JetBrains/compose-multiplatform (umbrella repo -- :components-resources runtime)
|
||||
COMPOSE_REF=v1.12.0-beta01
|
||||
COMPOSE_REF=v1.12.0-beta03+dev4483
|
||||
|
||||
@@ -359,8 +359,14 @@ def ensure_clone(url, ref, sparse_dirs):
|
||||
git(clone_dir, ['sparse-checkout', 'set', *sparse_dirs], quiet=True)
|
||||
if git(clone_dir, ['checkout', '-q', ref], quiet=True, check=False).returncode != 0:
|
||||
print('ref %s not present locally -- fetching' % ref)
|
||||
git(clone_dir, ['fetch', 'origin', ref])
|
||||
git(clone_dir, ['checkout', '-q', ref])
|
||||
# Try as a TAG first, mapping it to a local tag ref -- a plain `fetch origin <tag>`
|
||||
# only lands in FETCH_HEAD, so the checkout below would fail and every future sync
|
||||
# would need the network again. Branches / bare SHAs fall back to the plain fetch.
|
||||
if git(clone_dir, ['fetch', 'origin', 'refs/tags/%s:refs/tags/%s' % (ref, ref)],
|
||||
quiet=True, check=False).returncode != 0:
|
||||
git(clone_dir, ['fetch', 'origin', ref])
|
||||
if git(clone_dir, ['checkout', '-q', ref], quiet=True, check=False).returncode != 0:
|
||||
git(clone_dir, ['checkout', '-q', 'FETCH_HEAD'])
|
||||
desc = subprocess.run(['git', '-C', clone_dir, 'describe', '--tags', '--always'],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
print('upstream %s @ %s' % (url.rstrip('/').split('/')[-1], desc))
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"macosArm64/sdl3": {
|
||||
"Animation": 3.11,
|
||||
"AnnotatedString": 7.46,
|
||||
"AppBars": 1.99,
|
||||
"BasicText": 4.62,
|
||||
"BasicTextField": 4.99,
|
||||
"Brushes": 2.18,
|
||||
"Buttons": 1.92,
|
||||
"ButtonsExtra": 2.24,
|
||||
"Canvas": 5.03,
|
||||
"Cards": 2.28,
|
||||
"Carousel": 2.06,
|
||||
"Chips": 1.62,
|
||||
"Clipboard": 2.94,
|
||||
"Colors": 0.68,
|
||||
"Counter": 0.65,
|
||||
"CustomLayout": 19.13,
|
||||
"Dialogs": 2.54,
|
||||
"DragAndDrop": 1.85,
|
||||
"Drawers": 1.3,
|
||||
"Fab": 1.94,
|
||||
"FabExtra": 1.35,
|
||||
"FlowLayout": 2.46,
|
||||
"FocusRequester": 2.85,
|
||||
"Gestures": 2.68,
|
||||
"GraphicsLayer": 4.7,
|
||||
"GridsExtra": 1.29,
|
||||
"Icons": 1.29,
|
||||
"Images": 16.81,
|
||||
"Interaction": 1.41,
|
||||
"InteractionSource": 2.96,
|
||||
"Layout": 2.12,
|
||||
"LazyColumn": 2.7,
|
||||
"LazyExtra": 2.91,
|
||||
"LazyGrid": 1.67,
|
||||
"Lists": 2.45,
|
||||
"M3Misc": 1.65,
|
||||
"MaterialSymbols": 2.99,
|
||||
"ModShortcuts": 2.27,
|
||||
"Modifiers": 2.28,
|
||||
"NavRails": 1.38,
|
||||
"Navigation": 1.79,
|
||||
"Navigation3": 2.71,
|
||||
"Pager": 0.98,
|
||||
"Path": 6.22,
|
||||
"Pickers": 19.47,
|
||||
"PointerInput": 2.68,
|
||||
"Recomposition": 1.51,
|
||||
"Remember": 1.74,
|
||||
"Scroll": 2.82,
|
||||
"Search": 4.5,
|
||||
"Shadows": 1.49,
|
||||
"Shapes": 1.34,
|
||||
"Sheets": 1.35,
|
||||
"Tabs": 1.88,
|
||||
"Text": 3.37,
|
||||
"TextField": 1.95,
|
||||
"Widgets": 2.21
|
||||
},
|
||||
"macosArm64/skia": {
|
||||
"Animation": 3.11,
|
||||
"AnnotatedString": 7.46,
|
||||
"AppBars": 1.99,
|
||||
"BasicText": 4.62,
|
||||
"BasicTextField": 4.99,
|
||||
"Brushes": 2.18,
|
||||
"Buttons": 1.92,
|
||||
"ButtonsExtra": 2.24,
|
||||
"Canvas": 5.03,
|
||||
"Cards": 2.28,
|
||||
"Carousel": 2.06,
|
||||
"Chips": 1.62,
|
||||
"Clipboard": 2.94,
|
||||
"Colors": 0.68,
|
||||
"Counter": 0.65,
|
||||
"CustomLayout": 19.13,
|
||||
"Dialogs": 2.54,
|
||||
"DragAndDrop": 1.85,
|
||||
"Drawers": 1.3,
|
||||
"Fab": 1.94,
|
||||
"FabExtra": 1.35,
|
||||
"FlowLayout": 2.46,
|
||||
"FocusRequester": 2.85,
|
||||
"Gestures": 2.68,
|
||||
"GraphicsLayer": 4.65,
|
||||
"GridsExtra": 1.29,
|
||||
"Icons": 1.29,
|
||||
"Images": 16.81,
|
||||
"Interaction": 1.41,
|
||||
"InteractionSource": 2.96,
|
||||
"Layout": 2.12,
|
||||
"LazyColumn": 2.7,
|
||||
"LazyExtra": 2.91,
|
||||
"LazyGrid": 1.67,
|
||||
"Lists": 2.45,
|
||||
"M3Misc": 1.65,
|
||||
"MaterialSymbols": 2.99,
|
||||
"ModShortcuts": 2.27,
|
||||
"Modifiers": 2.28,
|
||||
"NavRails": 1.38,
|
||||
"Navigation": 1.79,
|
||||
"Navigation3": 2.71,
|
||||
"Pager": 0.98,
|
||||
"Path": 6.22,
|
||||
"Pickers": 19.47,
|
||||
"PointerInput": 2.68,
|
||||
"Recomposition": 1.51,
|
||||
"Remember": 1.74,
|
||||
"Scroll": 2.82,
|
||||
"Search": 4.5,
|
||||
"Shadows": 1.49,
|
||||
"Shapes": 1.34,
|
||||
"Sheets": 1.35,
|
||||
"Tabs": 1.88,
|
||||
"Text": 3.37,
|
||||
"TextField": 1.95,
|
||||
"Widgets": 2.21
|
||||
},
|
||||
"mingwX64/sdl3": {
|
||||
"Animation": 3.0,
|
||||
"AnnotatedString": 8.13,
|
||||
"AppBars": 1.96,
|
||||
"BasicText": 2.81,
|
||||
"BasicTextField": 1.82,
|
||||
"Brushes": 2.21,
|
||||
"Buttons": 1.9,
|
||||
"ButtonsExtra": 2.19,
|
||||
"Canvas": 4.3,
|
||||
"Cards": 2.29,
|
||||
"Carousel": 1.9,
|
||||
"Chips": 1.59,
|
||||
"Clipboard": 3.03,
|
||||
"Colors": 0.65,
|
||||
"Counter": 0.58,
|
||||
"CustomLayout": 4.1,
|
||||
"Dialogs": 2.64,
|
||||
"DragAndDrop": 1.84,
|
||||
"Drawers": 1.38,
|
||||
"Fab": 1.86,
|
||||
"FabExtra": 1.38,
|
||||
"FlowLayout": 2.34,
|
||||
"FocusRequester": 2.84,
|
||||
"Gestures": 2.62,
|
||||
"GraphicsLayer": 2.46,
|
||||
"GridsExtra": 1.33,
|
||||
"Icons": 1.3,
|
||||
"Images": 16.61,
|
||||
"Interaction": 1.44,
|
||||
"InteractionSource": 2.87,
|
||||
"Layout": 2.04,
|
||||
"LazyColumn": 3.46,
|
||||
"LazyExtra": 2.85,
|
||||
"LazyGrid": 1.62,
|
||||
"Lists": 2.33,
|
||||
"M3Misc": 1.67,
|
||||
"MaterialSymbols": 2.91,
|
||||
"ModShortcuts": 1.87,
|
||||
"Modifiers": 2.3,
|
||||
"NavRails": 1.35,
|
||||
"Navigation": 1.68,
|
||||
"Navigation3": 2.25,
|
||||
"Pager": 1.0,
|
||||
"Path": 3.56,
|
||||
"Pickers": 19.47,
|
||||
"PointerInput": 2.77,
|
||||
"Recomposition": 1.48,
|
||||
"Remember": 1.72,
|
||||
"Scroll": 2.78,
|
||||
"Search": 5.27,
|
||||
"Shadows": 1.53,
|
||||
"Shapes": 1.31,
|
||||
"Sheets": 1.4,
|
||||
"Tabs": 1.77,
|
||||
"Text": 3.08,
|
||||
"TextField": 1.93,
|
||||
"Widgets": 2.2
|
||||
}
|
||||
}
|
||||
+146
-20
@@ -1,20 +1,23 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parity harness — render every demo screen on the NATIVE (SDL/Skia) stack and on
|
||||
Parity harness - render every demo screen on the NATIVE (SDL/Skia) stack and on
|
||||
the JVM upstream-Compose stack, then pixel-diff them per screen.
|
||||
|
||||
The two stacks share the exact same commonMain screen composables, so a screen
|
||||
that suddenly diverges from its usual difference level is a PORT REGRESSION
|
||||
(missing content, wrong shape/colour, broken clip). Absolute pixel-perfection
|
||||
is NOT the goal — fonts differ between stacks, so text-heavy screens carry a
|
||||
is NOT the goal - fonts differ between stacks, so text-heavy screens carry a
|
||||
steady baseline difference. The signal is the RANKING: a screen that jumps from
|
||||
~8% to ~60% different is the bug. Four of the renderer regressions this project
|
||||
hit would have surfaced here.
|
||||
|
||||
Usage (from repo root):
|
||||
python scripts/parity/parity.py # all screens
|
||||
python scripts/parity/parity.py # all screens, host's native target
|
||||
python scripts/parity/parity.py Buttons Shapes # a subset
|
||||
python scripts/parity/parity.py --no-build # reuse existing screenshots
|
||||
python scripts/parity/parity.py --renderer=sdl3 # force the SDL leg (default on mingw;
|
||||
# on macOS/Linux this -Prenderer=sdl3 build)
|
||||
python scripts/parity/parity.py --target=macosArm64 # cross/explicit native target
|
||||
|
||||
Outputs to build/parity/ (gitignored):
|
||||
<pct>_<Name>_compare.png native | jvm | amplified-diff, side by side
|
||||
@@ -22,16 +25,42 @@ Outputs to build/parity/ (gitignored):
|
||||
report.txt ranked table
|
||||
The <pct> prefix is zero-padded so a plain file listing sorts worst-first.
|
||||
|
||||
Windows-only for the native leg today (mingwX64 exe). Needs Pillow.
|
||||
Native uses the SDL renderer; pass --gpu to change.
|
||||
TARGET-AWARE (P0.1): the native leg runs on whatever host you invoke it from - Windows
|
||||
(mingwX64, always the SDL renderer), or macOS/Linux (default the Skia renderer, or the SDL
|
||||
renderer under --renderer=sdl3). macOS/Linux run BOTH renderers, so one Mac verifies both
|
||||
legs. Needs Pillow.
|
||||
"""
|
||||
import subprocess, sys, os, shutil
|
||||
import subprocess, sys, os, shutil, platform, json
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
OUT = REPO / "build" / "parity"
|
||||
NATIVE_EXE = REPO / "demo" / "build" / "bin" / "mingwX64" / "debugExecutable" / "demo.exe"
|
||||
|
||||
# native target -> (gradle link-task suffix, K/N executable filename).
|
||||
TARGETS = {
|
||||
"mingwX64": ("MingwX64", "demo.exe"),
|
||||
"macosArm64": ("MacosArm64", "demo.kexe"),
|
||||
"macosX64": ("MacosX64", "demo.kexe"),
|
||||
"linuxX64": ("LinuxX64", "demo.kexe"),
|
||||
"linuxArm64": ("LinuxArm64", "demo.kexe"),
|
||||
}
|
||||
|
||||
|
||||
def host_target() -> str:
|
||||
s, m = platform.system(), platform.machine().lower()
|
||||
arm = m in ("arm64", "aarch64")
|
||||
if s == "Windows": return "mingwX64"
|
||||
if s == "Darwin": return "macosArm64" if arm else "macosX64"
|
||||
if s == "Linux": return "linuxArm64" if arm else "linuxX64"
|
||||
raise SystemExit(f"parity: unsupported host {s}/{m}")
|
||||
|
||||
|
||||
def native_exe(target: str) -> Path:
|
||||
_, exe = TARGETS[target]
|
||||
return REPO / "demo" / "build" / "bin" / target / "debugExecutable" / exe
|
||||
|
||||
|
||||
WIDTH, HEIGHT = 1000, 700
|
||||
# Per-channel tolerance: below this a pixel counts as "same" (JPEG-ish noise,
|
||||
# sub-pixel AA, font hinting). Tuned so unrelated screens sit well under it.
|
||||
@@ -45,26 +74,50 @@ def run(cmd, **kw):
|
||||
return subprocess.run(cmd, cwd=REPO, **kw)
|
||||
|
||||
|
||||
def build():
|
||||
run([GRADLEW, ":demo:linkDebugExecutableMingwX64", "--console=plain"], check=True)
|
||||
def build(target: str, renderer: str):
|
||||
suffix, _ = TARGETS[target]
|
||||
cmd = [GRADLEW, f":demo:linkDebugExecutable{suffix}", "--console=plain"]
|
||||
# macOS/Linux pick the renderer at BUILD time via -Prenderer; mingw is always sdl3
|
||||
# (no property needed, and omitting it keeps the default Windows invocation stable).
|
||||
if renderer == "sdl3" and target != "mingwX64":
|
||||
cmd.append("-Prenderer=sdl3")
|
||||
run(cmd, check=True)
|
||||
|
||||
|
||||
def jvm_shots(dst: Path):
|
||||
run([GRADLEW, ":demo:run", f"--args=--screenshot-all={dst}", "--console=plain"], check=True)
|
||||
|
||||
|
||||
def native_shot(name: str, dst: Path, gpu: str):
|
||||
def native_shot(name: str, dst: Path, exe: Path, gpu: str):
|
||||
bmp = dst / f"{name}.bmp"
|
||||
run([str(NATIVE_EXE), f"--screen={name}", f"--screenshot={bmp}",
|
||||
f"--gpu={gpu}", f"--width={WIDTH}", f"--height={HEIGHT}"],
|
||||
check=False, timeout=60)
|
||||
cmd = [str(exe), f"--screen={name}", f"--screenshot={bmp}",
|
||||
f"--width={WIDTH}", f"--height={HEIGHT}"]
|
||||
if gpu: # runtime driver within the renderer; omit to let the app pick its default
|
||||
cmd.append(f"--gpu={gpu}")
|
||||
run(cmd, check=False, timeout=60)
|
||||
if bmp.exists():
|
||||
Image.open(bmp).convert("RGB").save(dst / f"{name}.png")
|
||||
bmp.unlink()
|
||||
|
||||
|
||||
def align_native(native: Image.Image, jvm: Image.Image) -> Image.Image:
|
||||
"""Normalise the native render to the JVM reference's dimensions.
|
||||
|
||||
The native leg draws at the machine's physical pixel density (2000x1400 on a
|
||||
Retina Mac, DPR 2.0), while the JVM ImageComposeScene reference is fixed at
|
||||
density 1.0 (1000x700). Both represent the SAME 1000x700-dp viewport, so a
|
||||
proportional comparison must resize native down to the reference size rather
|
||||
than crop it (a top-left crop would compare only a 2x-magnified quarter — the
|
||||
old behaviour, invisible on Windows where both legs were already 1000x700).
|
||||
A no-op when the sizes already match."""
|
||||
if native.size != jvm.size:
|
||||
return native.resize(jvm.size, Image.LANCZOS)
|
||||
return native
|
||||
|
||||
|
||||
def diff_pair(native: Image.Image, jvm: Image.Image):
|
||||
"""Return (percent_differing, amplified_diff_image)."""
|
||||
native = align_native(native, jvm)
|
||||
w = min(native.width, jvm.width)
|
||||
h = min(native.height, jvm.height)
|
||||
a = native.crop((0, 0, w, h)).convert("RGB")
|
||||
@@ -80,6 +133,7 @@ def diff_pair(native: Image.Image, jvm: Image.Image):
|
||||
|
||||
|
||||
def side_by_side(native, jvm, diff, path):
|
||||
native = align_native(native, jvm)
|
||||
w = min(native.width, jvm.width)
|
||||
h = min(native.height, jvm.height)
|
||||
canvas = Image.new("RGB", (w * 3 + 20, h + 20), (16, 16, 16))
|
||||
@@ -89,11 +143,51 @@ def side_by_side(native, jvm, diff, path):
|
||||
canvas.save(path)
|
||||
|
||||
|
||||
# P0.4 (RENDERER.md §8): per-(target/renderer) golden baselines so parity GATES
|
||||
# (non-zero exit) instead of only ranking. A screen regresses if it exceeds its baseline by
|
||||
# more than max(ABS_MARGIN pts, baseline*REL_MARGIN) - tolerant of run-to-run AA jitter,
|
||||
# strict on a real jump. `--update-baselines` reseeds the current target/renderer.
|
||||
BASELINES = REPO / "scripts" / "parity" / "baselines.json"
|
||||
ABS_MARGIN = 3.0 # percentage points
|
||||
REL_MARGIN = 0.25 # +25% of the baseline
|
||||
|
||||
|
||||
def load_baselines() -> dict:
|
||||
if BASELINES.exists():
|
||||
try:
|
||||
return json.loads(BASELINES.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
print("parity: baselines.json unreadable - treating as empty", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
def threshold(base: float) -> float:
|
||||
return base + max(ABS_MARGIN, base * REL_MARGIN)
|
||||
|
||||
|
||||
def main():
|
||||
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
flags = [a for a in sys.argv[1:] if a.startswith("--")]
|
||||
no_build = "--no-build" in flags
|
||||
gpu = next((f.split("=", 1)[1] for f in flags if f.startswith("--gpu=")), "sdl3")
|
||||
target = next((f.split("=", 1)[1] for f in flags if f.startswith("--target=")), host_target())
|
||||
if target not in TARGETS:
|
||||
print(f"parity: unknown --target={target} (known: {', '.join(TARGETS)})", file=sys.stderr)
|
||||
return 2
|
||||
# Renderer: mingw is always SDL; macOS/Linux default to Skia unless --renderer=sdl3.
|
||||
renderer = next((f.split("=", 1)[1] for f in flags if f.startswith("--renderer=")),
|
||||
"sdl3" if target == "mingwX64" else "skia")
|
||||
# Runtime GPU driver: default "sdl3" for the SDL renderer; omit for Skia (app default,
|
||||
# e.g. Metal on macOS). Explicit --gpu= always wins.
|
||||
gpu = next((f.split("=", 1)[1] for f in flags if f.startswith("--gpu=")),
|
||||
"sdl3" if renderer == "sdl3" else "")
|
||||
exe = native_exe(target)
|
||||
update_baselines = "--update-baselines" in flags
|
||||
baseline_key = f"{target}/{renderer}"
|
||||
baselines = load_baselines()
|
||||
base_for_key = baselines.get(baseline_key, {})
|
||||
print(f"parity: target={target} renderer={renderer} gpu={gpu or '(default)'} exe={exe}")
|
||||
print(f"parity: baseline key '{baseline_key}' - {len(base_for_key)} screen(s) baselined"
|
||||
f"{' [--update-baselines: WILL RESEED]' if update_baselines else ''}")
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
jvm_dir = OUT / "_jvm"
|
||||
@@ -103,13 +197,13 @@ def main():
|
||||
old.unlink()
|
||||
|
||||
if not no_build:
|
||||
build()
|
||||
build(target, renderer)
|
||||
if jvm_dir.exists():
|
||||
shutil.rmtree(jvm_dir)
|
||||
jvm_dir.mkdir(parents=True)
|
||||
jvm_shots(jvm_dir)
|
||||
|
||||
# Screen set: the JVM run enumerates the full registry → use its PNGs as the
|
||||
# Screen set: the JVM run enumerates the full registry -> use its PNGs as the
|
||||
# source of truth for names (native takes one exe launch each).
|
||||
names = sorted(p.stem for p in jvm_dir.glob("*.png"))
|
||||
if argv:
|
||||
@@ -123,7 +217,7 @@ def main():
|
||||
for name in names:
|
||||
jvm_png = jvm_dir / f"{name}.png"
|
||||
if not no_build or not (OUT / f"{name}.native.png").exists():
|
||||
native_shot(name, OUT, gpu)
|
||||
native_shot(name, OUT, exe, gpu)
|
||||
src = OUT / f"{name}.png"
|
||||
if src.exists():
|
||||
src.replace(OUT / f"{name}.native.png")
|
||||
@@ -134,21 +228,53 @@ def main():
|
||||
native = Image.open(native_png).convert("RGB")
|
||||
jvm = Image.open(jvm_png).convert("RGB")
|
||||
pct, amp = diff_pair(native, jvm)
|
||||
# Zero-padded pct prefix → worst-first in any file listing.
|
||||
# Zero-padded pct prefix -> worst-first in any file listing.
|
||||
prefix = f"{pct:06.2f}"
|
||||
amp.save(OUT / f"{prefix}_{name}_diff.png")
|
||||
side_by_side(native, jvm, amp, OUT / f"{prefix}_{name}_compare.png")
|
||||
results.append((name, pct))
|
||||
print(f" {name:28s} {pct:6.2f}% differing")
|
||||
|
||||
# --update-baselines: write the current run as the golden baseline for this key.
|
||||
if update_baselines:
|
||||
baselines[baseline_key] = {n: round(p, 2) for n, p in results if p is not None}
|
||||
BASELINES.write_text(json.dumps(baselines, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"\nparity: reseeded {len(baselines[baseline_key])} baseline(s) for "
|
||||
f"'{baseline_key}' -> {BASELINES}")
|
||||
return 0
|
||||
|
||||
results.sort(key=lambda r: (-1 if r[1] is None else r[1]), reverse=True)
|
||||
lines = ["screen %differ", "-" * 40]
|
||||
regressions = []
|
||||
lines = [f"screen %differ baseline verdict ({baseline_key})", "-" * 66]
|
||||
for name, pct in results:
|
||||
lines.append(f"{name:28s} {'NATIVE FAILED' if pct is None else f'{pct:6.2f}%'}")
|
||||
base = base_for_key.get(name)
|
||||
if pct is None:
|
||||
verdict = "NATIVE FAILED"
|
||||
regressions.append(name)
|
||||
lines.append(f"{name:28s} {'-':>7} {base if base is None else f'{base:6.2f}%':>8} {verdict}")
|
||||
continue
|
||||
if base is None:
|
||||
verdict = "(no baseline)"
|
||||
elif pct > threshold(base):
|
||||
verdict = f"REGRESSION (+{pct - base:.2f})"
|
||||
regressions.append(name)
|
||||
else:
|
||||
verdict = "ok"
|
||||
base_s = " - " if base is None else f"{base:6.2f}%"
|
||||
lines.append(f"{name:28s} {pct:6.2f}% {base_s:>8} {verdict}")
|
||||
report = "\n".join(lines)
|
||||
(OUT / "report.txt").write_text(report + "\n", encoding="utf-8")
|
||||
print("\n" + report)
|
||||
print(f"\nDiff images + report in {OUT}")
|
||||
|
||||
if not base_for_key:
|
||||
print(f"\nparity: NO baselines for '{baseline_key}' - not gating. "
|
||||
f"Seed with: python scripts/parity/parity.py --update-baselines")
|
||||
return 0
|
||||
if regressions:
|
||||
print(f"\nparity: FAIL - {len(regressions)} regression(s): {', '.join(regressions)}")
|
||||
return 1
|
||||
print(f"\nparity: PASS - no screen exceeds its baseline (+{ABS_MARGIN}pts / +{REL_MARGIN:.0%}).")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# MAC-VERIFY runbook (P0.2, RENDERER.md) - the one-command gate for renderer work.
|
||||
#
|
||||
# Runs on macOS or Linux and exercises BOTH renderers:
|
||||
# 0. DRIFT-CHECK: vendor-clean (sync.py + diff src/vendor, P0.7) + manual-vendor
|
||||
# provenance tripwire (P0.6)
|
||||
# 1. Build :demo + :apidemo for the host target (Skia leg, then -Prenderer=sdl3)
|
||||
# 2. Run the interaction probes on each leg, gate on their PASS/FAIL output
|
||||
# 3. Run scripts/parity/parity.py for each leg (gates on baselines.json when seeded)
|
||||
# 4. CDN_PROFILE draw-ms spot-check on LazyColumn/Tabs (fails on >20% regression vs the
|
||||
# last recorded run; build/verify-mac/perf-baseline.txt, seeded on first run)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/verify-mac.sh # full run, non-zero exit on any failure
|
||||
# scripts/verify-mac.sh --update-perf-baseline # re-seed the perf baseline first
|
||||
#
|
||||
# Notes:
|
||||
# - Probes self-terminate on a frame counter; a hang means the leg is broken anyway.
|
||||
# - Toggling -Prenderer= keys a separate Gradle configuration-cache entry; if the SDL
|
||||
# leg fails with "couldn't find sdl3_ttf", rm -rf .gradle/configuration-cache (see
|
||||
# CLAUDE.md "Common pitfalls") and re-run.
|
||||
|
||||
set -u
|
||||
|
||||
REPO="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO"
|
||||
|
||||
# ============
|
||||
# Host target
|
||||
case "$(uname -s)/$(uname -m)" in
|
||||
Darwin/arm64) TARGET=MacosArm64; EXE_DIR=macosArm64 ;;
|
||||
Darwin/x86_64) TARGET=MacosX64; EXE_DIR=macosX64 ;;
|
||||
Linux/aarch64) TARGET=LinuxArm64; EXE_DIR=linuxArm64 ;;
|
||||
Linux/x86_64) TARGET=LinuxX64; EXE_DIR=linuxX64 ;;
|
||||
*) echo "verify-mac: unsupported host $(uname -s)/$(uname -m)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
DEMO_EXE="demo/build/bin/$EXE_DIR/debugExecutable/demo.kexe"
|
||||
PROBES=(nav3test backtest clicktest scrolltest multiwintest)
|
||||
PERF_DIR="build/verify-mac"
|
||||
PERF_BASELINE="$PERF_DIR/perf-baseline.txt"
|
||||
mkdir -p "$PERF_DIR"
|
||||
[ "${1:-}" = "--update-perf-baseline" ] && rm -f "$PERF_BASELINE"
|
||||
|
||||
FAILURES=()
|
||||
note() { echo; echo "==== verify-mac: $*"; }
|
||||
fail() { FAILURES+=("$1"); echo "verify-mac: FAIL - $1" >&2; }
|
||||
|
||||
# ============
|
||||
# Perf spot-check: run one screen for ~8s under the frame profiler, parse the last
|
||||
# "draw=<avg>/<max>ms" line, compare avg against the recorded baseline (+20%, +0.2ms
|
||||
# absolute guard so sub-ms values don't false-positive on noise).
|
||||
perf_check() { # $1=leg $2=screen
|
||||
local leg="$1" s="$2"
|
||||
local log="$PERF_DIR/profile_${leg}_${s}.log"
|
||||
rm -f "$log"
|
||||
note "[$leg] perf spot-check $s"
|
||||
CDN_PROFILE="$log" CDN_FORCERENDER=1 "$DEMO_EXE" --screen="$s" &
|
||||
local pid=$!
|
||||
sleep 8
|
||||
kill "$pid" 2>/dev/null
|
||||
wait "$pid" 2>/dev/null
|
||||
local avg
|
||||
avg=$(tail -1 "$log" 2>/dev/null | grep -o 'draw=[0-9.]*' | head -1 | cut -d= -f2)
|
||||
if [ -z "$avg" ]; then
|
||||
fail "[$leg] perf $s (no profiler output in $log)"
|
||||
return
|
||||
fi
|
||||
local base
|
||||
base=$(grep "^$leg $s " "$PERF_BASELINE" 2>/dev/null | awk '{print $3}')
|
||||
if [ -z "$base" ]; then
|
||||
echo "$leg $s $avg" >> "$PERF_BASELINE"
|
||||
echo "verify-mac: [$leg] $s draw=${avg}ms (baseline seeded)"
|
||||
elif awk -v a="$avg" -v b="$base" 'BEGIN { exit !(a > b * 1.2 && a > b + 0.2) }'; then
|
||||
fail "[$leg] perf $s regression: draw=${avg}ms vs baseline ${base}ms (+20% gate)"
|
||||
else
|
||||
echo "verify-mac: [$leg] $s draw=${avg}ms (baseline ${base}ms) ok"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============
|
||||
# One leg = build + probes + parity + perf.
|
||||
run_leg() { # $1=skia|sdl3
|
||||
local leg="$1"
|
||||
local props=()
|
||||
[ "$leg" = "sdl3" ] && props=(-Prenderer=sdl3)
|
||||
|
||||
note "[$leg] build :demo + :apidemo ($TARGET)"
|
||||
if ! ./gradlew ${props[@]+"${props[@]}"} ":demo:linkDebugExecutable$TARGET" \
|
||||
":apidemo:linkDebugExecutable$TARGET" --console=plain; then
|
||||
fail "[$leg] build"
|
||||
return
|
||||
fi
|
||||
|
||||
local p out
|
||||
for p in "${PROBES[@]}"; do
|
||||
note "[$leg] probe --$p"
|
||||
out=$("$DEMO_EXE" "--$p" 2>&1 | tail -40)
|
||||
if ! echo "$out" | grep -q "PASS"; then
|
||||
echo "$out"
|
||||
fail "[$leg] probe $p"
|
||||
fi
|
||||
done
|
||||
|
||||
note "[$leg] parity ($leg leg)"
|
||||
python3 scripts/parity/parity.py --renderer="$leg" || fail "[$leg] parity"
|
||||
|
||||
# P2.2 memory soak: cycle every screen x6, assert current RSS doesn't ratchet up
|
||||
# (regression guard for the snapshot-observation / dispose leak fixed in c59caf72).
|
||||
note "[$leg] soak (memory)"
|
||||
out=$(CDN_SOAK_CYCLES=6 "$DEMO_EXE" --soaktest 2>&1 | tail -5)
|
||||
if ! echo "$out" | grep -q "soaktest: PASS"; then
|
||||
echo "$out"
|
||||
fail "[$leg] soak"
|
||||
fi
|
||||
|
||||
perf_check "$leg" LazyColumn
|
||||
perf_check "$leg" Tabs
|
||||
}
|
||||
|
||||
# ============
|
||||
# DRIFT-CHECK first (cheap, cross-platform): the legs must build from a vendor tree
|
||||
# that matches the pinned refs, with no manual vendor lagging the pin.
|
||||
note "drift-check: vendor-clean (sync.py + diff src/vendor)"
|
||||
python3 scripts/compose-fork/check-vendor-clean.py || fail "drift-check: src/vendor drifted from the pinned refs"
|
||||
note "drift-check: manual-vendor provenance"
|
||||
python3 scripts/compose-fork/check-vendor-drift.py || fail "drift-check: a manual vendor lags the pinned ref"
|
||||
|
||||
run_leg skia
|
||||
run_leg sdl3
|
||||
|
||||
# ============
|
||||
# Summary
|
||||
echo
|
||||
if [ ${#FAILURES[@]} -gt 0 ]; then
|
||||
echo "verify-mac: FAILED (${#FAILURES[@]}):"
|
||||
printf ' - %s\n' "${FAILURES[@]}"
|
||||
exit 1
|
||||
fi
|
||||
echo "verify-mac: ALL GREEN (both legs: build + probes + parity + perf)"
|
||||
exit 0
|
||||
@@ -47,8 +47,8 @@ val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
// material3 rides its own release train (same +dev build, different base).
|
||||
// Gradle orders "+dev" BELOW the plain version, so force the core-repo
|
||||
// groups on jvm configurations (mirrors :demo's forcing).
|
||||
val vComposeJvmVersion = "1.12.0-beta01+dev4324"
|
||||
val vComposeM3JvmVersion = "1.12.0-alpha03+dev4324"
|
||||
val vComposeJvmVersion = libs.versions.compose.get()
|
||||
val vComposeM3JvmVersion = libs.versions.composeMaterial3.get()
|
||||
val vComposeJvmForced = mapOf(
|
||||
"org.jetbrains.compose.runtime" to vComposeJvmVersion,
|
||||
"org.jetbrains.compose.ui" to vComposeJvmVersion,
|
||||
@@ -79,7 +79,7 @@ kotlin {
|
||||
commonMain.dependencies {
|
||||
// The official runtime klibs — the SAME artifact/version the port
|
||||
// itself uses (:ui pins it); resolves for metadata, jvm AND native.
|
||||
api("org.jetbrains.compose.runtime:runtime:1.11.1")
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
// Official Maven coordinates for the API surface (Modifier / Color
|
||||
// / Dp / material3's LocalContentColor). Metadata + jvm resolve
|
||||
// them from Maven; NATIVE configurations substitute them for
|
||||
|
||||
Reference in New Issue
Block a user