mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-05 10:47:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
aad70f342b | ||
|
|
cf597c12e0 | ||
|
|
cbddf8ca0b | ||
|
|
ad738a643e | ||
|
|
8f5c59bd64 | ||
|
|
914bab840d | ||
|
|
8f96ba9478 | ||
|
|
5a9e7b0f7f | ||
|
|
5dfd9200f8 | ||
|
|
8c7168a1a9 | ||
|
|
377f3aa775 | ||
|
|
662975c802 | ||
|
|
3b420a7bad | ||
|
|
2dad6c9bcc | ||
|
|
723f80c8af | ||
|
|
450247175f | ||
|
|
5d6f772a1c | ||
|
|
575c19f921 | ||
|
|
328663b9e7 | ||
|
|
c9ddd3488d | ||
|
|
9958386792 | ||
|
|
f5fae6d32e | ||
|
|
b94f1f7bbb | ||
|
|
ffbb87eddd | ||
|
|
63fad4083f | ||
|
|
872d740ce7 | ||
|
|
4c2d46429d | ||
|
|
120abbad93 | ||
|
|
a39002f1b8 | ||
|
|
fb470ecf0c | ||
|
|
2b6ac65a1f | ||
|
|
1b2e83a155 | ||
|
|
8a6cb868d9 | ||
|
|
4a7080b313 | ||
|
|
1afb13aae0 | ||
|
|
3d519bcfcd | ||
|
|
72287fc995 | ||
|
|
0b4a170910 | ||
|
|
72e6f145b3 | ||
|
|
367ed94c01 | ||
|
|
fa9bd93ada | ||
|
|
d1551bc9c1 |
@@ -18,9 +18,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Each host runs only the publish tasks Gradle actually generated for it.
|
||||
# macOS also publishes the shared kotlinMultiplatform metadata module.
|
||||
# No library module declares jvm() today — apps do their own JVM parity
|
||||
# build against upstream Compose, so JVM isn't part of the publication.
|
||||
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
|
||||
# (see its matrix entry) + the jvm jar + the bridge plugin.
|
||||
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
||||
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
||||
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
||||
@@ -31,13 +30,8 @@ jobs:
|
||||
host: macos
|
||||
target: MacosArm64
|
||||
exe_ext: kexe
|
||||
# jvm publication: only :material-symbols declares a jvm() target
|
||||
# (its common API renders via upstream Compose on JVM) — publish
|
||||
# its host-independent jar from the same host as the metadata.
|
||||
tasks: >-
|
||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||
publishMacosArm64PublicationToGitHubPackagesRepository
|
||||
publishJvmPublicationToGitHubPackagesRepository
|
||||
- runner: ubuntu-22.04
|
||||
host: linux
|
||||
target: LinuxX64
|
||||
@@ -49,8 +43,18 @@ jobs:
|
||||
host: windows
|
||||
target: MingwX64
|
||||
exe_ext: exe
|
||||
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
|
||||
# the ONLY host that declares every target (vHostSupportsMingw is
|
||||
# host-gated), so only its generated .module files carry the full
|
||||
# variant table — the macOS-published roots of v0.1.15 had NO
|
||||
# mingwX64 variants and Windows consumers could not resolve them.
|
||||
# The host-independent jvm jar (:material-symbols) and the bridge
|
||||
# plugin ride along on the same job.
|
||||
tasks: >-
|
||||
publishMingwX64PublicationToGitHubPackagesRepository
|
||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||
publishJvmPublicationToGitHubPackagesRepository
|
||||
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
@@ -135,16 +139,65 @@ jobs:
|
||||
shell: bash
|
||||
run: bash scripts/compose-fork/sync.sh
|
||||
|
||||
# GitHub Packages sometimes returns 409 Conflict on a fresh upload when
|
||||
# the same coordinate was recently deleted (soft-delete propagation).
|
||||
# `--continue` lets Gradle finish the rest of the publish tasks past a
|
||||
# single 409, and the outer retry re-runs the whole thing (Gradle skips
|
||||
# already-uploaded artifacts). Two attempts is enough for the transient
|
||||
# cases we've hit; the third pass falls through as a hard failure.
|
||||
# GitHub Packages FORBIDS overwriting a file that already exists in a
|
||||
# version, and maven-publish uploads each publication's files
|
||||
# SEQUENTIALLY from the first one. So when an upload dies mid-publication
|
||||
# (the v0.1.13 macOS run lost DNS mid-publish), a blind retry hits 409
|
||||
# Conflict on the first already-uploaded file and aborts BEFORE reaching
|
||||
# the missing tail files — the version is poisoned and can never
|
||||
# complete. A 409 is therefore only benign when the publication had
|
||||
# already completed; you can't tell from the 409 alone. The fix: between
|
||||
# attempts, DELETE this host's package versions for the current release
|
||||
# and re-upload from a clean slate. Each host owns DISJOINT package
|
||||
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
|
||||
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
|
||||
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
|
||||
# a 403 on delete just degrades to the old blind-retry behaviour.
|
||||
- name: Publish to GitHub Packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
VER="${PUBLISH_VERSION#v}"
|
||||
OWNER="${GITHUB_REPOSITORY%%/*}"
|
||||
# Keep in sync with the module list in settings.gradle.kts.
|
||||
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
|
||||
case "${{ matrix.host }}" in
|
||||
macos) SUFFIXES="-macosarm64" ;;
|
||||
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
||||
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
|
||||
esac
|
||||
host_packages() {
|
||||
for m in $MODULES; do
|
||||
for s in $SUFFIXES; do
|
||||
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
|
||||
done
|
||||
done
|
||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
|
||||
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
|
||||
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
|
||||
fi
|
||||
}
|
||||
cleanup_host_versions() {
|
||||
echo "Deleting this host's partially-published $VER package versions"
|
||||
host_packages | while read -r pkg; do
|
||||
vid=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions?per_page=100" \
|
||||
| jq -r --arg v "$VER" '[.[]? | select(.name==$v) | .id] | first // empty')
|
||||
[ -z "$vid" ] && continue
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/users/$OWNER/packages/maven/$pkg/versions/$vid")
|
||||
if [ "$code" != "204" ]; then
|
||||
# Deleting the LAST version of a package is refused (400) —
|
||||
# a package new in this release: drop the whole package.
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
||||
-H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/users/$OWNER/packages/maven/$pkg")
|
||||
fi
|
||||
echo " $pkg@$VER -> HTTP $code"
|
||||
done
|
||||
}
|
||||
for attempt in 1 2 3; do
|
||||
echo "::group::Publish attempt $attempt"
|
||||
if ./gradlew ${{ matrix.tasks }} --continue --no-daemon --stacktrace; then
|
||||
@@ -152,7 +205,11 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
[ $attempt -lt 3 ] && { echo "::warning::Publish attempt $attempt failed — sleeping 60s and retrying"; sleep 60; }
|
||||
if [ $attempt -lt 3 ]; then
|
||||
echo "::warning::Publish attempt $attempt failed — cleaning this host's $VER packages and retrying"
|
||||
cleanup_host_versions || true
|
||||
sleep 30
|
||||
fi
|
||||
done
|
||||
echo "::error::Publish failed after 3 attempts"
|
||||
exit 1
|
||||
|
||||
@@ -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
|
||||
@@ -42,7 +57,12 @@ compose/
|
||||
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
|
||||
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
|
||||
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
|
||||
│ └── ui-backhandler/ → :ui-backhandler — androidx.compose.ui.backhandler.*
|
||||
│ ├── ui-backhandler/ → :ui-backhandler — androidx.compose.ui.backhandler.*
|
||||
│ └── ui-tooling-preview/ → :ui-tooling-preview — androidx.compose.ui.tooling.preview.*
|
||||
│ (the common @Preview + PreviewParameterProvider,
|
||||
│ vendored verbatim; the Maven artifact ships no
|
||||
│ mingwX64/linux klibs). IDE-only metadata — previews
|
||||
│ render through the apps' jvm parity targets.
|
||||
├── animation/
|
||||
│ ├── animation-core/ → :animation-core — androidx.compose.animation.core.*
|
||||
│ ├── animation/ → :animation — androidx.compose.animation.* (non-core)
|
||||
@@ -67,9 +87,12 @@ utils/
|
||||
(Typeface.makeClone per axes — upstream's FontCache
|
||||
drops variationSettings from its key). Its commonMain
|
||||
declares official Maven compose coords — the root
|
||||
build's FULL-COMMONIZATION BRIDGE substitutes ui /
|
||||
foundation / material3 to project modules on native
|
||||
configs. Apps get one dep; the consumer Zip task
|
||||
build's FULL-COMMONIZATION BRIDGE substitutes the
|
||||
whole ui / foundation / animation / material3 /
|
||||
nav3-ui family to project modules on native configs
|
||||
(:demo and :apidemo commonMains rely on the same
|
||||
bridge; :compose-desktop-native-bridge ships it to
|
||||
consumers). Apps get one dep; the consumer Zip task
|
||||
bundles only the fonts used (native) and
|
||||
jvmProcessResources stages the same fonts (jvm).
|
||||
|
||||
@@ -103,7 +126,22 @@ demo/ → :demo — flagship showcase app (30+ screens) + th
|
||||
MULTIPLATFORM: also has a jvm() target running the SAME shared
|
||||
screens on stock JVM Compose Desktop (`./gradlew :demo:run`,
|
||||
MainJvmKt) — the parity reference; differences vs native = port bugs
|
||||
apidemo/ → :apidemo — Postman-style REST API manager
|
||||
apidemo/ → :apidemo — Postman-style REST API manager. MULTIPLATFORM like :demo
|
||||
(`./gradlew :apidemo:run`): the whole UI lives in commonMain
|
||||
against the official Maven coords; SDL-backed APIs go through
|
||||
expect/actual seams (compat/Compat.kt — native actuals delegate
|
||||
to com.compose.sdl, jvm actuals use AWT + upstream desktop).
|
||||
mTLS / TLS-chain inspection stays native-only (bundled libcurl).
|
||||
gradle-plugin/
|
||||
└── compose-desktop-native-bridge/ → :compose-desktop-native-bridge — the CONSUMER-side bridge as a
|
||||
published Gradle plugin (id com.bitsycore.compose-desktop-native
|
||||
.bridge, applies to Settings or Project): substitution rules
|
||||
can't ship inside Maven artifacts, so third-party apps apply the
|
||||
plugin, declare OFFICIAL CMP coords in commonMain, and native
|
||||
configurations swap in the published com.bitsycore.compose.sdl
|
||||
klibs. Substituted version defaults to the plugin's own
|
||||
(override: composeDesktopNative.version property). Published by
|
||||
the macOS publish job.
|
||||
scripts/ → vendor-sync + python helper scripts (compose-coverage = API
|
||||
coverage/fidelity vs upstream, material-symbols generate/subset)
|
||||
+ compose-fork/;
|
||||
@@ -205,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
|
||||
@@ -252,11 +303,15 @@ commonMain
|
||||
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
||||
```
|
||||
|
||||
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
|
||||
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
|
||||
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
|
||||
resolves because **only one of the two renderer source sets is attached to a
|
||||
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
||||
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
|
||||
`:ui`'s nativeMain with `actual`s in BOTH `skikoRendererMain` and
|
||||
`sdlRendererMain` — unambiguous because **only one of the two renderer source
|
||||
sets is attached to a given target**. (They used to be plain duplicate
|
||||
declarations with no expect; that compiled per-target but shared nativeMain
|
||||
METADATA couldn't see them on a host whose targets span both renderers, which
|
||||
blocked the WINDOWS host from producing :window's KotlinMultiplatform
|
||||
publication — and Windows must publish the root modules, see the publish
|
||||
workflow.) Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
||||
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
||||
pulled in.
|
||||
|
||||
@@ -318,27 +373,19 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||
./gradlew :demo:runDebugExecutableMacosArm64 -Prenderer=sdl3
|
||||
|
||||
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
|
||||
# demo screens on upstream Compose; differences vs the native build = port bugs.
|
||||
# screens on upstream Compose; differences vs the native build = port bugs.
|
||||
./gradlew :demo:run
|
||||
./gradlew :apidemo:run
|
||||
```
|
||||
|
||||
### System dependencies
|
||||
|
||||
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
|
||||
|
||||
@@ -364,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
|
||||
|
||||
@@ -430,6 +471,21 @@ build tell you what broke.
|
||||
`Dialog` / `DropdownMenu` / `DropdownMenuItem` / `TooltipBox` (m3 doesn't
|
||||
ship drop-in equivalents for our anchor / scrim patterns).
|
||||
|
||||
## Tooling
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
|
||||
See [RENDERER.md](RENDERER.md) for the renderer work these support.
|
||||
|
||||
## Conventions
|
||||
|
||||
Kotlin standard style — plain `camelCase` for parameters, local variables,
|
||||
@@ -481,6 +537,18 @@ that should surface in tooling.
|
||||
toggling the renderer property may not invalidate it. Delete
|
||||
`.gradle/configuration-cache/` between switches if you see weird
|
||||
"couldn't find sdl3_ttf" errors.
|
||||
- **Substituted Maven modules hide their transitives from common metadata** —
|
||||
when the bridge swaps an official coord for a project module on native
|
||||
configs, KGP's granular-metadata visibility check drops that Maven module's
|
||||
TRANSITIVES from the commonMain classpath (symptom: `Unresolved reference
|
||||
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
|
||||
while per-target compilation is fine). Declare EVERY artifact the common
|
||||
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
|
||||
own bridge rule. Note only the WINDOWS publish job compiles common metadata
|
||||
(it owns the root KotlinMultiplatform publications — the only host that
|
||||
declares every target, so only its .module files carry the full variant
|
||||
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
|
||||
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
||||
- **`Path()` in commonMain returns different actuals per renderer** —
|
||||
the Skia renderer produces a `SkiaBackedPath` (wraps
|
||||
`org.jetbrains.skia.Path`), the SDL renderer produces a project
|
||||
|
||||
@@ -1,227 +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
|
||||
├── 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)
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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 }) { /* ... */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### Building in this repo
|
||||
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
implementation(project(":window")) // window shell + main loop
|
||||
implementation(project(":material3")) // Material 3 widgets
|
||||
implementation(project(":material-symbols")) // icon-font composables (optional)
|
||||
}
|
||||
```
|
||||
|
||||
### 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.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).
|
||||
@@ -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.
|
||||
+101
-13
@@ -16,12 +16,38 @@ plugins {
|
||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||
val vHostSupportsMingw: Boolean by rootProject.extra
|
||||
|
||||
// JVM parity target's Compose version — the dev build matching the pinned
|
||||
// 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 = 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,
|
||||
"org.jetbrains.compose.foundation" to vComposeJvmVersion,
|
||||
"org.jetbrains.compose.animation" to vComposeJvmVersion,
|
||||
"org.jetbrains.compose.material" to vComposeJvmVersion,
|
||||
"org.jetbrains.compose.material3" to vComposeM3JvmVersion,
|
||||
)
|
||||
configurations.configureEach {
|
||||
if (name.startsWith("jvm")) {
|
||||
resolutionStrategy.eachDependency {
|
||||
vComposeJvmForced[requested.group]?.let { useVersion(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
linuxArm64()
|
||||
linuxX64()
|
||||
macosArm64()
|
||||
if (vHostSupportsMingw) mingwX64()
|
||||
|
||||
applyDefaultHierarchyTemplate()
|
||||
|
||||
targets.withType<KotlinNativeTarget>().all {
|
||||
val isMingw = name == "mingwX64"
|
||||
val isLinux = name.startsWith("linux")
|
||||
@@ -68,9 +94,31 @@ kotlin {
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation(project(":window"))
|
||||
implementation(project(":material3"))
|
||||
// Common API on both stacks — usable from shared code.
|
||||
implementation(project(":material-symbols"))
|
||||
|
||||
// Official Maven coords for everything the shared UI touches,
|
||||
// so commonMain metadata (and the IDE's common analysis)
|
||||
// resolve. Metadata + jvm resolve them from Maven; the NATIVE
|
||||
// configurations substitute each for its port module (root
|
||||
// build's FULL-COMMONIZATION BRIDGE). Every artifact must be
|
||||
// declared DIRECTLY: transitives of a substituted module are
|
||||
// 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:${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")
|
||||
implementation("org.jetbrains.compose.ui:ui-unit:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.ui:ui-geometry:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.ui:ui-util:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.foundation:foundation:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.foundation:foundation-layout:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.animation:animation:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.animation:animation-core:$vComposeJvmVersion")
|
||||
implementation("org.jetbrains.compose.material3:material3:$vComposeM3JvmVersion")
|
||||
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
@@ -80,17 +128,32 @@ kotlin {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
}
|
||||
}
|
||||
// Single HTTP engine on every desktop target: Ktor's Curl engine. It
|
||||
// bundles a static libcurl per target (Schannel on Windows, OpenSSL on
|
||||
// macOS/Linux) embedded in its cinterop klib — no runtime DLL, one copy.
|
||||
// We use Curl everywhere (not WinHttp/Darwin) so that an upcoming
|
||||
// client-certificate (mTLS) path — which calls the same bundled libcurl
|
||||
// directly, since Ktor's engines expose no client-cert API — shares the
|
||||
// exact same TLS stack as ordinary requests.
|
||||
if (vHostSupportsMingw) getByName("mingwX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
getByName("macosArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
getByName("linuxX64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
getByName("linuxArm64Main").dependencies { implementation(libs.ktor.client.curl) }
|
||||
nativeMain {
|
||||
dependencies {
|
||||
// The SDL window shell + main loop (native-only).
|
||||
implementation(project(":window"))
|
||||
// Single HTTP engine on every native target: Ktor's Curl
|
||||
// engine. It bundles a static libcurl per target (Schannel on
|
||||
// Windows, OpenSSL on macOS/Linux) embedded in its cinterop
|
||||
// klib — no runtime DLL, one copy. Curl everywhere (not
|
||||
// WinHttp/Darwin) so the client-certificate (mTLS) path —
|
||||
// which calls the same bundled libcurl directly, since Ktor's
|
||||
// engines expose no client-cert API — shares the exact same
|
||||
// TLS stack as ordinary requests.
|
||||
implementation(libs.ktor.client.curl)
|
||||
}
|
||||
}
|
||||
jvmMain {
|
||||
dependencies {
|
||||
// Upstream Compose Desktop — the parity stack.
|
||||
implementation(compose.desktop.currentOs)
|
||||
// Coroutine-based engine on JVM; native targets keep Curl.
|
||||
implementation(libs.ktor.client.cio)
|
||||
// Dispatchers.Main provider on desktop JVM (must match
|
||||
// kotlinx-coroutines-core's version — the catalog pins both).
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compilerOptions {
|
||||
@@ -99,6 +162,12 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "apidemo.MainJvmKt"
|
||||
}
|
||||
}
|
||||
|
||||
// SDL3 / SDL3_ttf / SDL3_image / FreeType are linked statically into the
|
||||
// executable (see the mingw linkerOpts above), so there are no runtime DLLs to
|
||||
// bundle — the distributable is just <app>.exe + data.kres.
|
||||
@@ -277,3 +346,22 @@ for (variant in variants) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: Stage fonts onto the JVM classpath
|
||||
// ==================
|
||||
// The JVM analog of the data.kres font/ entries: :material-symbols' JVM actual
|
||||
// loads each style's font from the classpath at font/<Style>.ttf, and the
|
||||
// jvm Fonts actual loads the mono body font at font/NotoSansMono.ttf.
|
||||
tasks.named<ProcessResources>("jvmProcessResources") {
|
||||
from(notoMonoFile) { into("font") }
|
||||
dependsOn(":ui:downloadNotoFonts")
|
||||
val vSymbolsProject = rootProject.project(":material-symbols")
|
||||
for (vStyle in vUsedStyles) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val vFontFile = vSymbolsProject.extra["iconFontFile$vStyle"] as org.gradle.api.provider.Provider<org.gradle.api.file.RegularFile>
|
||||
val vDownloadTask = vSymbolsProject.extra["iconFontDownloadTask$vStyle"] as TaskProvider<*>
|
||||
from(vFontFile) { into("font") }
|
||||
dependsOn(vDownloadTask)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
||||
// ==================
|
||||
// MARK: Monospace body font (seam)
|
||||
// ==================
|
||||
// Family name the body editor passes for monospace text. Resolves to the
|
||||
// bundled Noto Sans Mono once registered; null means the font isn't shipped
|
||||
// (data.kres on native, classpath font/ on jvm), in which case the body falls
|
||||
// back to the default proportional font.
|
||||
const val kMonoFamily = "noto-mono"
|
||||
|
||||
/* The raw family name string — used by wrappedRowCount, which measures via the
|
||||
platform text pipeline (accepts a name string). Null when not bundled. */
|
||||
expect val monoFontFamilyName: String?
|
||||
|
||||
/* The material3 Text / BasicTextField-shaped FontFamily for the mono family.
|
||||
Null when not bundled. */
|
||||
expect val monoFontFamily: FontFamily?
|
||||
@@ -1,7 +1,6 @@
|
||||
package apidemo
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.curl.Curl
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
@@ -23,7 +22,7 @@ import kotlin.time.TimeSource
|
||||
run() is a suspend fun — call it off the UI dispatcher. */
|
||||
class HttpRunner {
|
||||
|
||||
private val fClient = HttpClient(Curl)
|
||||
private val fClient = createApiHttpClient()
|
||||
|
||||
init {
|
||||
// Clear any temporary client certs a prior crash left in the Windows
|
||||
@@ -140,7 +139,7 @@ private fun isGzip(inEncoding: String?, inBytes: ByteArray): Boolean =
|
||||
|
||||
/* Read a file's raw bytes (used for a FILE request body). */
|
||||
internal fun readFileBytes(inPath: String): ByteArray =
|
||||
FileSystem.SYSTEM.read(inPath.trim().toPath()) { readByteArray() }
|
||||
systemFileSystem.read(inPath.trim().toPath()) { readByteArray() }
|
||||
|
||||
/* Inflate a gzip stream to its original bytes via okio's GzipSource. */
|
||||
private fun gunzip(inBytes: ByteArray): ByteArray {
|
||||
@@ -19,7 +19,7 @@ private val fJson = Json {
|
||||
/* Write the pack as pretty JSON to inPath. Returns null on success, else the
|
||||
error message. */
|
||||
fun exportPack(inPack: Pack, inPath: String): String? = try {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
||||
systemFileSystem.write(inPath.trim().toPath()) {
|
||||
writeUtf8(fJson.encodeToString(inPack))
|
||||
}
|
||||
null
|
||||
@@ -29,7 +29,7 @@ fun exportPack(inPack: Pack, inPath: String): String? = try {
|
||||
|
||||
/* Read a pack back from inPath. */
|
||||
fun importPack(inPath: String): Result<Pack> = try {
|
||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
||||
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(fJson.decodeFromString<Pack>(vText))
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
@@ -37,7 +37,7 @@ fun importPack(inPath: String): Result<Pack> = try {
|
||||
|
||||
/* Write a whole self-contained session to inPath. Returns null on success. */
|
||||
fun exportSession(inSession: Session, inPath: String): String? = try {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) {
|
||||
systemFileSystem.write(inPath.trim().toPath()) {
|
||||
writeUtf8(fJson.encodeToString(inSession))
|
||||
}
|
||||
null
|
||||
@@ -47,7 +47,7 @@ fun exportSession(inSession: Session, inPath: String): String? = try {
|
||||
|
||||
/* Read a session back from inPath. */
|
||||
fun importSession(inPath: String): Result<Session> = try {
|
||||
val vText = FileSystem.SYSTEM.read(inPath.trim().toPath()) { readUtf8() }
|
||||
val vText = systemFileSystem.read(inPath.trim().toPath()) { readUtf8() }
|
||||
Result.success(fJson.decodeFromString<Session>(vText))
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
@@ -59,7 +59,7 @@ fun renameFile(inPath: String, inNewName: String): Result<String> = try {
|
||||
val vSrc = inPath.trim().toPath()
|
||||
val vLeaf = inNewName.trim().let { if (it.contains('.')) it else "$it.json" }
|
||||
val vDst = vSrc.parent?.div(vLeaf) ?: vLeaf.toPath()
|
||||
FileSystem.SYSTEM.atomicMove(vSrc, vDst)
|
||||
systemFileSystem.atomicMove(vSrc, vDst)
|
||||
Result.success(vDst.toString())
|
||||
} catch (e: Throwable) {
|
||||
Result.failure(e)
|
||||
@@ -68,7 +68,7 @@ fun renameFile(inPath: String, inNewName: String): Result<String> = try {
|
||||
/* Write arbitrary text (a response body / headers dump) to inPath. Returns null
|
||||
on success, else the error message. */
|
||||
fun writeTextFile(inPath: String, inText: String): String? = try {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
||||
systemFileSystem.write(inPath.trim().toPath()) { writeUtf8(inText) }
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
e.message ?: e.toString()
|
||||
@@ -76,7 +76,7 @@ fun writeTextFile(inPath: String, inText: String): String? = try {
|
||||
|
||||
/* Write raw bytes (a binary response body, e.g. an image) to inPath. */
|
||||
fun writeBytesFile(inPath: String, inBytes: ByteArray): String? = try {
|
||||
FileSystem.SYSTEM.write(inPath.trim().toPath()) { write(inBytes) }
|
||||
systemFileSystem.write(inPath.trim().toPath()) { write(inBytes) }
|
||||
null
|
||||
} catch (e: Throwable) {
|
||||
e.message ?: e.toString()
|
||||
@@ -1,7 +1,5 @@
|
||||
package apidemo
|
||||
|
||||
import com.compose.sdl.appDataDir
|
||||
import com.compose.sdl.revealInFileManager
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okio.FileSystem
|
||||
@@ -86,7 +84,7 @@ private fun stateFilePath(): String? {
|
||||
fun loadAppState(): AppState {
|
||||
val vPath = stateFilePath() ?: return AppState()
|
||||
return try {
|
||||
val vText = FileSystem.SYSTEM.read(vPath.toPath()) { readUtf8() }
|
||||
val vText = systemFileSystem.read(vPath.toPath()) { readUtf8() }
|
||||
fStateJson.decodeFromString<AppState>(vText)
|
||||
} catch (e: Throwable) {
|
||||
AppState()
|
||||
@@ -98,7 +96,7 @@ fun loadAppState(): AppState {
|
||||
fun saveAppState(inState: AppState) {
|
||||
val vPath = stateFilePath() ?: return
|
||||
try {
|
||||
FileSystem.SYSTEM.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
|
||||
systemFileSystem.write(vPath.toPath()) { writeUtf8(fStateJson.encodeToString(inState)) }
|
||||
} catch (e: Throwable) {
|
||||
// ignore — nothing the user can act on, and packs can still be exported
|
||||
}
|
||||
+2
-4
@@ -17,10 +17,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.compose.sdl.TextLayoutConfig
|
||||
import com.compose.sdl.icons.MaterialSymbols
|
||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||
import com.compose.sdl.showOpenFileDialog
|
||||
|
||||
// ==================
|
||||
// MARK: Panel 3 — request builder (Query / Headers / Body)
|
||||
@@ -219,7 +217,7 @@ internal fun TabSizeSelector() {
|
||||
var vOpen by remember { mutableStateOf(false) }
|
||||
val vHoverSrc = remember { MutableInteractionSource() }
|
||||
val vHover by vHoverSrc.collectIsHoveredAsState()
|
||||
val vSize = TextLayoutConfig.tabWidth
|
||||
val vSize = editorTabWidth
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = { PlainTooltip { Text("Editor tab size") } },
|
||||
@@ -243,7 +241,7 @@ internal fun TabSizeSelector() {
|
||||
for (vN in listOf(2, 4, 8)) {
|
||||
DropdownMenuItem(text = {
|
||||
Text("$vN spaces", color = if (vN == vSize) c.accent else c.text)
|
||||
}, onClick = { TextLayoutConfig.tabWidth = vN; vOpen = false })
|
||||
}, onClick = { editorTabWidth = vN; vOpen = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -25,7 +25,6 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.compose.sdl.icons.MaterialSymbols
|
||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||
import com.compose.sdl.layout.x
|
||||
|
||||
// ==================
|
||||
// MARK: Request tab strip (open requests; drag a tab to reorder)
|
||||
@@ -31,11 +31,8 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.compose.sdl.fileManagerName
|
||||
import com.compose.sdl.icons.MaterialSymbols
|
||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||
import com.compose.sdl.layout.y
|
||||
import com.compose.sdl.revealInFileManager
|
||||
|
||||
// ==================
|
||||
// MARK: Pack colour (palette dot + picker)
|
||||
+1
-3
@@ -23,8 +23,6 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.Popup
|
||||
import com.compose.sdl.icons.MaterialSymbols
|
||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||
import com.compose.sdl.layout.x
|
||||
import com.compose.sdl.layout.y
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -141,7 +139,7 @@ internal fun CopyButton(inText: String, inLabel: String? = null) {
|
||||
.background(if (vHover) c.accent.copy(alpha = 0.18f) else Color.Transparent, RoundedCornerShape(7.dp))
|
||||
.hoverable(vHoverSrc)
|
||||
.clickable {
|
||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(inText)) }
|
||||
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(inText)) }
|
||||
vCopied = true
|
||||
}
|
||||
.padding(horizontal = if (inLabel != null) 10.dp else 5.dp, vertical = 5.dp),
|
||||
@@ -0,0 +1,29 @@
|
||||
package apidemo
|
||||
|
||||
// ==================
|
||||
// MARK: TLS chain / client-certificate seam
|
||||
// ==================
|
||||
// The real implementations drive the bundled libcurl directly (CurlMtls.kt,
|
||||
// nativeMain) — Ktor's engines expose no client-cert / CERTINFO API. The jvm
|
||||
// parity app has no bundled libcurl, so its actuals report the feature as
|
||||
// native-only instead.
|
||||
|
||||
/* One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
|
||||
Issuer / the PEM under "Cert" / dates …) and whether the server actually
|
||||
presented it. Derived certs — an issuer resolved from the OS store, or a
|
||||
name-only placeholder — have fromServer = false and are drawn dotted. */
|
||||
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
|
||||
|
||||
/* The server's TLS certificate chain. error is set instead when it couldn't be
|
||||
fetched; fields vary by TLS backend. */
|
||||
class TlsChain(val certs: List<ChainCert>, val error: String?)
|
||||
|
||||
/* Fetch the TLS certificate chain the request's server presents. */
|
||||
expect fun inspectTlsChain(inReq: ApiRequest): TlsChain
|
||||
|
||||
/* Send the request with its client certificate attached (mTLS). */
|
||||
expect fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
||||
|
||||
/* Remove temp client certs a previous crashed run left behind. Call once at
|
||||
startup; no-op where the OS store isn't used. */
|
||||
expect fun sweepTempClientCerts()
|
||||
+10
-18
@@ -39,14 +39,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.compose.sdl.icons.MaterialSymbols
|
||||
import com.compose.sdl.icons.material.symbols.MaterialSymbolsOutlined
|
||||
import com.compose.sdl.removeMemoryResource
|
||||
import com.compose.sdl.res.ResourceKind
|
||||
import com.compose.sdl.res.painterResource
|
||||
import com.compose.sdl.scrollbar.ScrollbarStyle
|
||||
import com.compose.sdl.scrollbar.VerticalScrollbar
|
||||
import com.compose.sdl.scrollbar.rememberScrollbarAdapter
|
||||
import com.compose.sdl.showSaveFileDialog
|
||||
import com.compose.sdl.text.currentTextMeasurer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// ==================
|
||||
@@ -169,8 +161,8 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
||||
inBody = if (vRespImage) null else vBody,
|
||||
inIsImage = vRespImage && !vLoading,
|
||||
inImagePainter = if (vRespImage && !vLoading) {
|
||||
val vKind = if (vResp?.contentType?.contains("svg", ignoreCase = true) == true) ResourceKind.Svg else ResourceKind.Raster
|
||||
painterResource(inRs.imageKey!!, vKind)
|
||||
val vSvg = vResp?.contentType?.contains("svg", ignoreCase = true) == true
|
||||
memoryImagePainter(inRs.imageKey!!, vSvg)
|
||||
} else null,
|
||||
inHeadersCollapsed = vHeadersCollapsed,
|
||||
inOnToggleCollapse = { vHeadersCollapsed = !vHeadersCollapsed },
|
||||
@@ -247,7 +239,7 @@ internal fun ViewerPanel(inRs: ReqState, inResolved: ApiRequest) {
|
||||
inResponseBody = vRespBody,
|
||||
inOnMessage = { vMsg = it },
|
||||
inOnClear = {
|
||||
inRs.imageKey?.let { removeMemoryResource(it) }
|
||||
inRs.imageKey?.let { removeMemoryImage(it) }
|
||||
inRs.imageKey = null
|
||||
inRs.response = null
|
||||
inRs.sentReq = null
|
||||
@@ -508,7 +500,7 @@ internal fun HttpFlowView(
|
||||
val vS = vSel
|
||||
if (vS != null && !vS.isEmpty) {
|
||||
val vText = vBody.substring(vS.start, vS.end)
|
||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vText)) }
|
||||
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vText)) }
|
||||
true
|
||||
} else false
|
||||
}
|
||||
@@ -974,18 +966,18 @@ internal fun BodyView(
|
||||
// also use it for the first frame, before the body reports its width).
|
||||
val vNumColor = c.dim.copy(alpha = 0.45f)
|
||||
val vNumbers = remember(inText, vBodyWidthPx, inSoftWrap, vFontPx) {
|
||||
val vM = if (inSoftWrap && vBodyWidthPx > 0) currentTextMeasurer else null
|
||||
val vWrapRows = inSoftWrap && vBodyWidthPx > 0
|
||||
buildString {
|
||||
for ((vIdx, vSrc) in vLines.withIndex()) {
|
||||
if (vIdx > 0) append('\n')
|
||||
append(vIdx + 1)
|
||||
if (vM != null) {
|
||||
if (vWrapRows) {
|
||||
// Pass the density-scaled font size so this wrap matches the body's
|
||||
// actual glyph pixel widths — passing 12 (sp) here counted each
|
||||
// char as half its rendered size, so long lines that visually
|
||||
// wrapped into 3 rows in the body were estimated as 1 or 2 rows
|
||||
// here and the gutter numbers drifted upward.
|
||||
val vRows = vM.wrap(vSrc, vFontPx, vBodyWidthPx, monoFontFamilyName).lines.size.coerceAtLeast(1)
|
||||
val vRows = wrappedRowCount(vSrc, vFontPx, vBodyWidthPx, monoFontFamilyName).coerceAtLeast(1)
|
||||
repeat(vRows - 1) { append('\n') }
|
||||
}
|
||||
}
|
||||
@@ -1309,18 +1301,18 @@ internal fun ViewerOverflowMenu(
|
||||
}
|
||||
DropdownMenu(expanded = vOpen, onDismissRequest = { vOpen = false }) {
|
||||
DropdownMenuItem(text = { Text("Copy all") }, onClick = {
|
||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(copyAll())) }
|
||||
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(copyAll())) }
|
||||
inOnMessage("Copied all.")
|
||||
vOpen = false
|
||||
})
|
||||
DropdownMenuItem(text = { Text("Copy headers") }, onClick = {
|
||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vHeadersText)) }
|
||||
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vHeadersText)) }
|
||||
inOnMessage("Copied headers.")
|
||||
vOpen = false
|
||||
})
|
||||
if (vCanCopyBody) {
|
||||
DropdownMenuItem(text = { Text("Copy body") }, onClick = {
|
||||
vScope.launch { vClipboard.setClipEntry(ClipEntry.withPlainText(vBodyText)) }
|
||||
vScope.launch { vClipboard.setClipEntry(clipEntryOfText(vBodyText)) }
|
||||
inOnMessage("Copied body.")
|
||||
vOpen = false
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import io.ktor.client.HttpClient
|
||||
import okio.FileSystem
|
||||
|
||||
// ==================
|
||||
// MARK: Platform seams (native = com.compose.sdl / SDL3, jvm = AWT + upstream desktop)
|
||||
// ==================
|
||||
// The port modules are native-only, so shared code can't call com.compose.sdl
|
||||
// directly — every SDL-backed API the app uses goes through these expects.
|
||||
// Signatures mirror the native originals so the native actuals are pure
|
||||
// delegation (same pattern as :demo's demo.shim package).
|
||||
|
||||
/* Per-user app-data dir (created on demand, ends with a separator) or null. */
|
||||
expect fun appDataDir(inOrg: String, inApp: String): String?
|
||||
|
||||
/* Open the OS file manager showing the given path. Fire-and-forget. */
|
||||
expect fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)? = null)
|
||||
|
||||
/* "Explorer" / "Finder" / "Files" — for menu labels. */
|
||||
expect fun fileManagerName(): String
|
||||
|
||||
/* Async save-file dialog; the callback gets the chosen absolute path or null. */
|
||||
expect fun showSaveFileDialog(inDefaultName: String? = null, inOnResult: (String?) -> Unit)
|
||||
|
||||
/* Async open-file dialog; the callback gets the chosen absolute path or null. */
|
||||
expect fun showOpenFileDialog(inOnResult: (String?) -> Unit)
|
||||
|
||||
/* A plain-text clipboard entry (ClipEntry construction differs per stack). */
|
||||
expect fun clipEntryOfText(inText: String): ClipEntry
|
||||
|
||||
// ============
|
||||
// In-memory response images
|
||||
|
||||
/* Register/replace decoded-image source bytes under a key (resp-image://N). */
|
||||
expect fun registerMemoryImage(inKey: String, inBytes: ByteArray)
|
||||
|
||||
/* Drop a registered image's bytes. */
|
||||
expect fun removeMemoryImage(inKey: String)
|
||||
|
||||
/* Painter for a registered image key (raster or SVG). */
|
||||
@Composable
|
||||
expect fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter
|
||||
|
||||
// ============
|
||||
// Text layout
|
||||
|
||||
/* Rows the given line occupies when soft-wrapped to inMaxWidthPx at
|
||||
inFontPx (already density-scaled). Used by the body gutter so its line
|
||||
numbers stay aligned with the wrapped body text. */
|
||||
expect fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int
|
||||
|
||||
/* Editor tab width in spaces — how wide a typed '\t' renders. On the native
|
||||
stack this drives the project text pipeline (TextLayoutConfig); the jvm
|
||||
parity app only stores the preference. */
|
||||
expect var editorTabWidth: Int
|
||||
|
||||
// ============
|
||||
// Infrastructure
|
||||
|
||||
/* okio's FileSystem.SYSTEM is declared per-platform, not in its common
|
||||
metadata — surface it through a seam. */
|
||||
internal expect val systemFileSystem: FileSystem
|
||||
|
||||
/* The app's Ktor client: Curl on native (bundled libcurl — same TLS stack as
|
||||
the mTLS path), CIO on the jvm parity target. */
|
||||
expect fun createApiHttpClient(): HttpClient
|
||||
|
||||
/* Window-shell integration: an intercepting close handler (return true to
|
||||
allow the close) and an app-wide key-shortcut handler; both cleared when
|
||||
the composition leaves. */
|
||||
@Composable
|
||||
expect fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean)
|
||||
@@ -0,0 +1,16 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
|
||||
// COPY of the port's com.compose.sdl.layout LayoutCoordinates sugar so the
|
||||
// same helpers resolve on the jvm parity target (the port modules are
|
||||
// native-only). Guards `isAttached`: a menu/tooltip anchor can be read while
|
||||
// its node is mid-recycle, and positionInWindow() throws on a detached
|
||||
// coordinator.
|
||||
|
||||
/* Logical-point X of this layout in the window. */
|
||||
val LayoutCoordinates.x: Int get() = if (isAttached) positionInWindow().x.toInt() else 0
|
||||
|
||||
/* Logical-point Y of this layout in the window. */
|
||||
val LayoutCoordinates.y: Int get() = if (isAttached) positionInWindow().y.toInt() else 0
|
||||
@@ -0,0 +1,333 @@
|
||||
// COPY of the port's Scrollbar (package com.compose.sdl.scrollbar) so the SAME
|
||||
// implementation runs on the jvm parity target — the port modules are
|
||||
// native-only, so apidemo's commonMain cannot reference them directly.
|
||||
// Keep in sync with the original when it changes.
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// ==================
|
||||
// MARK: Scrollbar (Compose Desktop-style)
|
||||
// ==================
|
||||
|
||||
/* A faithful subset of Compose Desktop's scrollbar (androidx.compose.foundation).
|
||||
You overlay a VerticalScrollbar / HorizontalScrollbar on a scrollable and feed
|
||||
it a ScrollbarAdapter built from the same ScrollState. This project's Box has
|
||||
no BoxScope (so no Modifier.align) — pin the bar with the Box's
|
||||
contentAlignment; the fillMaxSize content fills the rest:
|
||||
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
|
||||
val state = rememberScrollState()
|
||||
Column(Modifier.fillMaxSize().verticalScroll(state)) { ... }
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(state),
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
|
||||
Always visible (no auto-hide); the thumb tweens unhoverColor -> hoverColor on
|
||||
hover over hoverDurationMillis, matching upstream. Drag the thumb to scroll;
|
||||
click the track to page. */
|
||||
|
||||
@Immutable
|
||||
class ScrollbarStyle(
|
||||
val minimalHeight: Dp,
|
||||
val thickness: Dp,
|
||||
val shape: Shape,
|
||||
val hoverDurationMillis: Int,
|
||||
val unhoverColor: Color,
|
||||
val hoverColor: Color,
|
||||
)
|
||||
|
||||
/* Upstream default: an 8dp black thumb that's faint until hovered. Dark UIs
|
||||
provide a white-tinted style via LocalScrollbarStyle or the `style` param. */
|
||||
fun defaultScrollbarStyle(): ScrollbarStyle = ScrollbarStyle(
|
||||
minimalHeight = 16.dp,
|
||||
thickness = 8.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
hoverDurationMillis = 300,
|
||||
unhoverColor = Color.Black.copy(alpha = 0.12f),
|
||||
hoverColor = Color.Black.copy(alpha = 0.50f),
|
||||
)
|
||||
|
||||
val LocalScrollbarStyle = compositionLocalOf { defaultScrollbarStyle() }
|
||||
|
||||
// ==================
|
||||
// MARK: ScrollbarAdapter
|
||||
// ==================
|
||||
|
||||
/* Bridges a scrollable's state to the scrollbar in pixels. scrollOffset /
|
||||
contentSize / viewportSize are along the scroll axis; the thumb is sized by
|
||||
viewportSize / contentSize and positioned by scrollOffset / (content - viewport). */
|
||||
interface ScrollbarAdapter {
|
||||
val scrollOffset: Double
|
||||
val contentSize: Double
|
||||
val viewportSize: Double
|
||||
suspend fun scrollTo(scrollOffset: Double)
|
||||
}
|
||||
|
||||
// (The port also ships a ScrollState-based adapter; it reads foundation
|
||||
// internals (ScrollState.contentSize/viewportSize) that a copy outside
|
||||
// :foundation cannot access. apidemo only scrollbars a LazyColumn.)
|
||||
|
||||
/* Adapter for a virtualized LazyColumn / LazyRow. A lazy list only knows the
|
||||
size of its VISIBLE items, so exact pixel geometry of off-screen content is
|
||||
unknowable — we estimate it from the average visible item size × total item
|
||||
count (the same approach as Compose Desktop). Accurate enough for a thumb
|
||||
whose length/position track the scroll position; the estimate self-corrects
|
||||
as items of different sizes scroll through the viewport. */
|
||||
@Composable
|
||||
fun rememberScrollbarAdapter(scrollState: LazyListState): ScrollbarAdapter =
|
||||
remember(scrollState) { LazyListScrollbarAdapter(scrollState) }
|
||||
|
||||
private class LazyListScrollbarAdapter(private val state: LazyListState) : ScrollbarAdapter {
|
||||
// Mean main-axis size of the currently-visible items, in px (>= 1 to avoid /0).
|
||||
private fun averageItemSize(): Double {
|
||||
val vVisible = state.layoutInfo.visibleItemsInfo
|
||||
if (vVisible.isEmpty()) return 0.0
|
||||
val vSpan = (vVisible.last().offset + vVisible.last().size) - vVisible.first().offset
|
||||
return (vSpan.toDouble() / vVisible.size).coerceAtLeast(1.0)
|
||||
}
|
||||
|
||||
override val scrollOffset: Double
|
||||
get() = averageItemSize() * state.firstVisibleItemIndex + state.firstVisibleItemScrollOffset
|
||||
|
||||
override val contentSize: Double
|
||||
get() = averageItemSize() * state.layoutInfo.totalItemsCount
|
||||
|
||||
override val viewportSize: Double
|
||||
get() = (state.layoutInfo.viewportEndOffset - state.layoutInfo.viewportStartOffset).toDouble()
|
||||
|
||||
override suspend fun scrollTo(scrollOffset: Double) {
|
||||
val vDistance = scrollOffset - this.scrollOffset
|
||||
// Within a viewport, scroll by EXACT pixels so a drag stays smooth. A lazy
|
||||
// list's pixel geometry is only ESTIMATED (average visible item size), and
|
||||
// that estimate drifts as differently-sized items scroll through the
|
||||
// viewport — so index-snapping every frame makes the thumb jump. scrollBy
|
||||
// sidesteps the estimate entirely. This is what Compose Desktop's own
|
||||
// LazyListScrollbarAdapter does, and for the same reason.
|
||||
if (abs(vDistance) <= viewportSize) {
|
||||
state.scrollBy(vDistance.toFloat())
|
||||
return
|
||||
}
|
||||
// Far jump (track-click paging past a viewport): no exact geometry to lean
|
||||
// on, so estimate an index/offset from the average and snap to it.
|
||||
val vAvg = averageItemSize()
|
||||
if (vAvg <= 0.0) return
|
||||
val vTarget = scrollOffset.coerceAtLeast(0.0)
|
||||
val vTotal = state.layoutInfo.totalItemsCount
|
||||
val vIndex = (vTarget / vAvg).toInt().coerceIn(0, (vTotal - 1).coerceAtLeast(0))
|
||||
val vItemOffset = (vTarget - vIndex * vAvg).toInt().coerceAtLeast(0)
|
||||
state.scrollToItem(vIndex, vItemOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================
|
||||
// MARK: VerticalScrollbar / HorizontalScrollbar
|
||||
// ==================
|
||||
|
||||
@Composable
|
||||
fun VerticalScrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean = false,
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = true)
|
||||
|
||||
@Composable
|
||||
fun HorizontalScrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier = Modifier,
|
||||
reverseLayout: Boolean = false,
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = Scrollbar(adapter, modifier, reverseLayout, style, interactionSource, isVertical = false)
|
||||
|
||||
/* Shared impl. The root Box is the track (fixed thickness on the cross axis,
|
||||
filling the main axis from the caller's modifier); a child Box is the thumb,
|
||||
positioned with an offset. Drag + track-click are handled on the track in
|
||||
track-relative coordinates (a stable origin), so the grab point stays under
|
||||
the pointer with no feedback from the thumb moving. interactionSource is
|
||||
accepted for API parity; hover here is driven by Modifier.hoverable. */
|
||||
@Composable
|
||||
private fun Scrollbar(
|
||||
adapter: ScrollbarAdapter,
|
||||
modifier: Modifier,
|
||||
reverseLayout: Boolean,
|
||||
style: ScrollbarStyle,
|
||||
@Suppress("UNUSED_PARAMETER") interactionSource: MutableInteractionSource,
|
||||
isVertical: Boolean,
|
||||
) {
|
||||
val vScope = rememberCoroutineScope()
|
||||
var vTrackPx by remember { mutableStateOf(0) }
|
||||
val vHoverSource = remember { MutableInteractionSource() }
|
||||
val vHovered by vHoverSource.collectIsHoveredAsState()
|
||||
var vDraggingThumb by remember { mutableStateOf(false) }
|
||||
// onSizeChanged reports PHYSICAL pixels (Option-B density flow), so every
|
||||
// int/px carried through here — vTrackPx, vThumbLen, vThumbPos — is in
|
||||
// physical pixels. Passing them into `.dp` doubles again through
|
||||
// density.toPx() on Retina, which visibly bloated the thumb (filled the
|
||||
// whole track) and made drag/track-click positions land 2× too far down.
|
||||
// Route pixel-sized modifiers through the current density so `.dp` and the
|
||||
// pixel value stay in the same coord space.
|
||||
val vDensity = LocalDensity.current
|
||||
|
||||
val vContent = adapter.contentSize
|
||||
val vViewport = adapter.viewportSize
|
||||
val vOffset = adapter.scrollOffset
|
||||
val vMaxScroll = vContent - vViewport
|
||||
val vScrollable = vMaxScroll > 0.5 && vViewport > 0.0
|
||||
|
||||
val vColor by animateColorAsState(
|
||||
targetValue = if (vHovered || vDraggingThumb) style.hoverColor else style.unhoverColor,
|
||||
animationSpec = tween(style.hoverDurationMillis),
|
||||
)
|
||||
|
||||
val vRoot = (if (isVertical) modifier.width(style.thickness) else modifier.height(style.thickness))
|
||||
.onSizeChanged { vTrackPx = if (isVertical) it.height else it.width }
|
||||
.hoverable(vHoverSource)
|
||||
|
||||
Box(modifier = vRoot) {
|
||||
if (!vScrollable || vTrackPx <= 0) return@Box
|
||||
|
||||
// ============
|
||||
// Thumb metrics (px)
|
||||
val vMinLen = style.minimalHeight.value.toInt()
|
||||
val vThumbLen = (vTrackPx * (vViewport / vContent)).roundToInt()
|
||||
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||
val vTravel = vTrackPx - vThumbLen
|
||||
val vRawPos = if (vTravel <= 0) 0 else (vTravel * (vOffset / vMaxScroll)).roundToInt()
|
||||
val vThumbPos = (if (reverseLayout) vTravel - vRawPos else vRawPos).coerceIn(0, vTravel)
|
||||
|
||||
val vThumbLenDp = with(vDensity) { vThumbLen.toDp() }
|
||||
val vThumbMod = (
|
||||
if (isVertical) Modifier.offset { IntOffset(0, vThumbPos) }.width(style.thickness).height(vThumbLenDp)
|
||||
else Modifier.offset { IntOffset(vThumbPos, 0) }.height(style.thickness).width(vThumbLenDp)
|
||||
)
|
||||
.clip(style.shape)
|
||||
.background(vColor)
|
||||
|
||||
Box(modifier = vThumbMod)
|
||||
|
||||
// ============
|
||||
// Drag the thumb / page on track click — handled on the track so the
|
||||
// coordinates have a stable (non-moving) origin. The press is CONSUMED:
|
||||
// a scrollbar owns the pointer input it handles, so content sharing this
|
||||
// region (the scrollable beneath, or a parent that also reads pointer
|
||||
// input) never reacts to the same press. Geometry is read LIVE from the
|
||||
// adapter at press time — not from the captured render metrics above,
|
||||
// which go stale between pointerInput relaunches — so a grab after a
|
||||
// wheel-scroll still hit-tests against the thumb's current position.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchTrackSize(isVertical, with(vDensity) { vTrackPx.toDp() }, style.thickness)
|
||||
.pointerInput(isVertical, adapter, vTrackPx, reverseLayout) {
|
||||
awaitEachGesture {
|
||||
val vDown = awaitFirstDown(requireUnconsumed = false)
|
||||
vDown.consume()
|
||||
|
||||
// Live geometry — stable for the drag's duration (content /
|
||||
// viewport / track sizes don't change mid-drag).
|
||||
val vContentPx = adapter.contentSize
|
||||
val vViewportPx = adapter.viewportSize
|
||||
val vMaxScrollPx = vContentPx - vViewportPx
|
||||
if (vMaxScrollPx <= 0.0 || vTrackPx <= 0) return@awaitEachGesture
|
||||
val vMinLen = style.minimalHeight.value.toInt()
|
||||
val vLen = (vTrackPx * (vViewportPx / vContentPx)).roundToInt()
|
||||
.coerceIn(vMinLen.coerceAtMost(vTrackPx), vTrackPx)
|
||||
val vTravelPx = vTrackPx - vLen
|
||||
val vRaw = if (vTravelPx <= 0) 0
|
||||
else (vTravelPx * (adapter.scrollOffset / vMaxScrollPx)).roundToInt()
|
||||
val vPos = (if (reverseLayout) vTravelPx - vRaw else vRaw).coerceIn(0, vTravelPx)
|
||||
|
||||
// Map a track-relative thumb-top to a scroll offset. The
|
||||
// target uses the LIVE max scroll (read fresh each move),
|
||||
// not the press-time value — the adapter compares it against
|
||||
// its live scrollOffset, and mixing a stale scale with a live
|
||||
// one makes the delta run away (grab at the bottom, drag up,
|
||||
// and the list overshoots to the middle).
|
||||
fun scrollToTrackPos(inThumbTop: Int) {
|
||||
if (vTravelPx <= 0) return
|
||||
val vLiveMax = adapter.contentSize - adapter.viewportSize
|
||||
if (vLiveMax <= 0.0) return
|
||||
val vClamped = inThumbTop.coerceIn(0, vTravelPx)
|
||||
val vFrac = (if (reverseLayout) vTravelPx - vClamped else vClamped).toDouble() / vTravelPx
|
||||
vScope.launch { adapter.scrollTo(vFrac * vLiveMax) }
|
||||
}
|
||||
|
||||
val vPress = (if (isVertical) vDown.position.y else vDown.position.x).toInt()
|
||||
val vGrab: Int
|
||||
if (vPress in vPos..(vPos + vLen)) {
|
||||
vDraggingThumb = true
|
||||
vGrab = vPress - vPos
|
||||
} else {
|
||||
// Page toward the click by one viewport.
|
||||
vDraggingThumb = false
|
||||
vGrab = 0
|
||||
val vDir = if (vPress < vPos) -1.0 else 1.0
|
||||
vScope.launch { adapter.scrollTo(adapter.scrollOffset + vDir * vViewportPx) }
|
||||
}
|
||||
|
||||
// Follow the pressed pointer to release, consuming every
|
||||
// change so the interaction can't leak. Only a thumb grab
|
||||
// drags-to-scroll; a track press just consumes.
|
||||
do {
|
||||
val vEvent = awaitPointerEvent()
|
||||
val vChange = vEvent.changes.firstOrNull { it.id == vDown.id } ?: break
|
||||
if (vDraggingThumb) {
|
||||
val vCur = (if (isVertical) vChange.position.y else vChange.position.x).toInt()
|
||||
scrollToTrackPos(vCur - vGrab)
|
||||
}
|
||||
vChange.consume()
|
||||
} while (vEvent.changes.any { it.id == vDown.id && it.pressed })
|
||||
|
||||
vDraggingThumb = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* Sizes the invisible drag-capture overlay to the full track. `inTrackDp`
|
||||
comes from the caller pre-converted via LocalDensity (Option-B: layout
|
||||
coords are physical px, so px-to-dp goes through the current density). */
|
||||
private fun Modifier.matchTrackSize(inVertical: Boolean, inTrackDp: Dp, inThickness: Dp): Modifier =
|
||||
if (inVertical) this.width(inThickness).height(inTrackDp)
|
||||
else this.height(inThickness).width(inTrackDp)
|
||||
@@ -0,0 +1,172 @@
|
||||
// COPY of the port's SplitPane (package com.compose.sdl.widgets) so the SAME
|
||||
// implementation runs on the jvm parity target — the port modules are
|
||||
// native-only, so apidemo's commonMain cannot reference them directly.
|
||||
// Keep in sync with the original when it changes.
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
// ==================
|
||||
// MARK: SplitPane
|
||||
// ==================
|
||||
|
||||
/** Two-pane resizable split. `initialFirstSize` seeds the first pane's width
|
||||
* (horizontal split) or height (vertical split); after first layout it tracks
|
||||
* the user's drag. `minFirstSize` / `minSecondSize` clamp so neither pane can
|
||||
* shrink past its minimum.
|
||||
*
|
||||
* Internal sizing is in **pixels** to match this port's pixel-space layout
|
||||
* tree (Option-B density flow). `Modifier.width((px / density).dp)` converts
|
||||
* back at the modifier boundary. */
|
||||
@Composable
|
||||
fun HorizontalSplitPane(
|
||||
modifier: Modifier = Modifier,
|
||||
initialFirstSize: Dp = 200.dp,
|
||||
minFirstSize: Dp = 50.dp,
|
||||
minSecondSize: Dp = 50.dp,
|
||||
dividerColor: Color = Color(0x33FFFFFFL),
|
||||
dividerHoverColor: Color = Color(0x66FFFFFFL),
|
||||
first: @Composable () -> Unit,
|
||||
second: @Composable () -> Unit,
|
||||
) {
|
||||
val vDensity = LocalDensity.current
|
||||
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
|
||||
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
|
||||
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
|
||||
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
|
||||
|
||||
var vTotalWidthPx by remember { mutableStateOf(0) }
|
||||
var vFirstWidthPx by remember { mutableStateOf(vInitialFirstPx) }
|
||||
val vDividerSource = remember { MutableInteractionSource() }
|
||||
val vDividerHover by vDividerSource.collectIsHoveredAsState()
|
||||
var vDragging by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { vTotalWidthPx = it.width },
|
||||
) {
|
||||
val vClampedFirstPx = if (vTotalWidthPx > 0)
|
||||
vFirstWidthPx.coerceIn(vMinFirstPx, (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
|
||||
else vFirstWidthPx
|
||||
val vSecondWidthPx = (vTotalWidthPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
|
||||
|
||||
Box(modifier = Modifier.width(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxHeight()) { first() }
|
||||
|
||||
// Solid divider that fills its own slot — what you see is exactly what
|
||||
// you can grab. Hover or an in-progress drag just changes the colour (no
|
||||
// size change → no layout shift, and no flicker when the pointer briefly
|
||||
// leaves the slot mid-drag).
|
||||
val vActive = vDividerHover || vDragging
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(SplitPaneDefaults.DividerThickness)
|
||||
.fillMaxHeight()
|
||||
.background(if (vActive) dividerHoverColor else dividerColor)
|
||||
.hoverable(vDividerSource)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { vDragging = true },
|
||||
onDragEnd = { vDragging = false },
|
||||
onDragCancel = { vDragging = false },
|
||||
onDrag = { _, delta ->
|
||||
val vMax = (vTotalWidthPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
|
||||
vFirstWidthPx = (vFirstWidthPx + delta.x.toInt()).coerceIn(vMinFirstPx, vMax)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.width(with(vDensity) { vSecondWidthPx.toDp() }).fillMaxHeight()) { second() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalSplitPane(
|
||||
modifier: Modifier = Modifier,
|
||||
initialFirstSize: Dp = 150.dp,
|
||||
minFirstSize: Dp = 50.dp,
|
||||
minSecondSize: Dp = 50.dp,
|
||||
dividerColor: Color = Color(0x33FFFFFFL),
|
||||
dividerHoverColor: Color = Color(0x66FFFFFFL),
|
||||
first: @Composable () -> Unit,
|
||||
second: @Composable () -> Unit,
|
||||
) {
|
||||
val vDensity = LocalDensity.current
|
||||
val vDividerPx = with(vDensity) { SplitPaneDefaults.DividerThickness.toPx().toInt() }
|
||||
val vMinFirstPx = with(vDensity) { minFirstSize.toPx().toInt() }
|
||||
val vMinSecondPx = with(vDensity) { minSecondSize.toPx().toInt() }
|
||||
val vInitialFirstPx = with(vDensity) { initialFirstSize.toPx().toInt() }
|
||||
|
||||
var vTotalHeightPx by remember { mutableStateOf(0) }
|
||||
var vFirstHeightPx by remember { mutableStateOf(vInitialFirstPx) }
|
||||
val vDividerSource = remember { MutableInteractionSource() }
|
||||
val vDividerHover by vDividerSource.collectIsHoveredAsState()
|
||||
var vDragging by remember { mutableStateOf(false) }
|
||||
var vPressStartFirstPx by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { vTotalHeightPx = it.height },
|
||||
) {
|
||||
val vClampedFirstPx = if (vTotalHeightPx > 0)
|
||||
vFirstHeightPx.coerceIn(vMinFirstPx, (vTotalHeightPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx))
|
||||
else vFirstHeightPx
|
||||
val vSecondHeightPx = (vTotalHeightPx - vClampedFirstPx - vDividerPx).coerceAtLeast(0)
|
||||
|
||||
Box(modifier = Modifier.height(with(vDensity) { vClampedFirstPx.toDp() }).fillMaxWidth()) { first() }
|
||||
|
||||
// Solid divider that fills its slot; hover/drag only changes the colour
|
||||
// (no size change → no layout shift, no flicker mid-drag).
|
||||
val vActive = vDividerHover || vDragging
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(SplitPaneDefaults.DividerThickness)
|
||||
.fillMaxWidth()
|
||||
.background(if (vActive) dividerHoverColor else dividerColor)
|
||||
.hoverable(vDividerSource)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDragStart = { vDragging = true },
|
||||
onDragEnd = { vDragging = false },
|
||||
onDragCancel = { vDragging = false },
|
||||
onDrag = { _, delta ->
|
||||
val vMax = (vTotalHeightPx - vDividerPx - vMinSecondPx).coerceAtLeast(vMinFirstPx)
|
||||
vFirstHeightPx = (vFirstHeightPx + delta.y.toInt()).coerceIn(vMinFirstPx, vMax)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.height(with(vDensity) { vSecondHeightPx.toDp() }).fillMaxWidth()) { second() }
|
||||
}
|
||||
}
|
||||
|
||||
object SplitPaneDefaults {
|
||||
val DividerThickness: Dp = 4.dp // divider width = hit/drag area (solid; what you see is what you grab)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.loadSvgPainter
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.createFontFamilyResolver
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.awt.Desktop
|
||||
import java.awt.EventQueue
|
||||
import java.awt.FileDialog
|
||||
import java.awt.Frame
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.io.File
|
||||
import org.jetbrains.skia.Image
|
||||
|
||||
// ==================
|
||||
// MARK: JVM actuals — AWT + upstream Compose Desktop
|
||||
// ==================
|
||||
|
||||
private val kIsWindows = System.getProperty("os.name").startsWith("Windows")
|
||||
private val kIsMac = System.getProperty("os.name").startsWith("Mac")
|
||||
|
||||
internal actual val systemFileSystem: okio.FileSystem = okio.FileSystem.SYSTEM
|
||||
|
||||
actual fun createApiHttpClient(): io.ktor.client.HttpClient =
|
||||
io.ktor.client.HttpClient(io.ktor.client.engine.cio.CIO)
|
||||
|
||||
// ============
|
||||
// Window hooks — the shared App installs them; MainJvm wires them into the
|
||||
// upstream Window() (onCloseRequest / onPreviewKeyEvent).
|
||||
|
||||
internal var jvmOnCloseRequest: (() -> Boolean)? = null
|
||||
internal var jvmOnKeyShortcut: ((KeyEvent) -> Boolean)? = null
|
||||
|
||||
@Composable
|
||||
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
|
||||
androidx.compose.runtime.DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
|
||||
jvmOnCloseRequest = inOnCloseRequest
|
||||
jvmOnKeyShortcut = inOnKeyShortcut
|
||||
onDispose { jvmOnCloseRequest = null; jvmOnKeyShortcut = null }
|
||||
}
|
||||
}
|
||||
|
||||
/* Mirrors SDL_GetPrefPath's layout: <os user-data dir>/<org>/<app>/ with a
|
||||
trailing separator, created on demand. */
|
||||
actual fun appDataDir(inOrg: String, inApp: String): String? {
|
||||
val vHome = System.getProperty("user.home") ?: return null
|
||||
val vBase = when {
|
||||
kIsWindows -> System.getenv("APPDATA") ?: "$vHome/AppData/Roaming"
|
||||
kIsMac -> "$vHome/Library/Application Support"
|
||||
else -> System.getenv("XDG_DATA_HOME") ?: "$vHome/.local/share"
|
||||
}
|
||||
val vDir = File(File(vBase, inOrg), inApp)
|
||||
if (!vDir.exists() && !vDir.mkdirs()) return null
|
||||
return vDir.absolutePath + File.separator
|
||||
}
|
||||
|
||||
/* Opens the path's containing folder (AWT has no cross-platform "select in
|
||||
file manager"); fire-and-forget off the EDT. */
|
||||
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) {
|
||||
Thread {
|
||||
val vOk = runCatching {
|
||||
val vFile = File(inPath)
|
||||
Desktop.getDesktop().open(if (vFile.isDirectory) vFile else vFile.parentFile ?: vFile)
|
||||
}.isSuccess
|
||||
inOnResult?.invoke(vOk)
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
actual fun fileManagerName(): String = when {
|
||||
kIsWindows -> "Explorer"
|
||||
kIsMac -> "Finder"
|
||||
else -> "Files"
|
||||
}
|
||||
|
||||
/* AWT FileDialog runs its own nested event loop, so invoking from the EDT
|
||||
(where Compose Desktop also runs) keeps the UI serviced while modal. */
|
||||
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) {
|
||||
EventQueue.invokeLater {
|
||||
val vDlg = FileDialog(null as Frame?, "Save", FileDialog.SAVE)
|
||||
inDefaultName?.let { vDlg.file = it }
|
||||
vDlg.isVisible = true
|
||||
inOnResult(vDlg.file?.let { vDlg.directory + it })
|
||||
}
|
||||
}
|
||||
|
||||
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) {
|
||||
EventQueue.invokeLater {
|
||||
val vDlg = FileDialog(null as Frame?, "Open", FileDialog.LOAD)
|
||||
vDlg.isVisible = true
|
||||
inOnResult(vDlg.file?.let { vDlg.directory + it })
|
||||
}
|
||||
}
|
||||
|
||||
/* Upstream desktop's ClipEntry wraps an AWT Transferable. */
|
||||
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry(StringSelection(inText))
|
||||
|
||||
// ============
|
||||
// In-memory response images
|
||||
|
||||
private val kMemoryImages = HashMap<String, ByteArray>()
|
||||
|
||||
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) { kMemoryImages[inKey] = inBytes }
|
||||
|
||||
actual fun removeMemoryImage(inKey: String) { kMemoryImages.remove(inKey) }
|
||||
|
||||
@Composable
|
||||
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter {
|
||||
val vDensity = LocalDensity.current
|
||||
return remember(inKey, inSvg) {
|
||||
val vBytes = kMemoryImages[inKey]
|
||||
runCatching {
|
||||
if (vBytes == null) error("no bytes for $inKey")
|
||||
if (inSvg) loadSvgPainter(vBytes.inputStream(), vDensity)
|
||||
else BitmapPainter(Image.makeFromEncoded(vBytes).toComposeImageBitmap())
|
||||
}.getOrElse { BitmapPainter(ImageBitmap(1, 1)) }
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Text layout
|
||||
|
||||
/* Upstream TextMeasurer at density 1 so the px-valued font size maps 1:1
|
||||
(the caller passes an already density-scaled size). */
|
||||
private val kRowMeasurer: TextMeasurer by lazy {
|
||||
TextMeasurer(createFontFamilyResolver(), Density(1f), LayoutDirection.Ltr)
|
||||
}
|
||||
|
||||
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int =
|
||||
runCatching {
|
||||
kRowMeasurer.measure(
|
||||
AnnotatedString(inText),
|
||||
TextStyle(fontSize = inFontPx.sp, fontFamily = monoFontFamily ?: FontFamily.Monospace),
|
||||
constraints = Constraints(maxWidth = inMaxWidthPx),
|
||||
).lineCount
|
||||
}.getOrDefault(1)
|
||||
|
||||
/* The jvm parity app only stores the preference — upstream's text pipeline has
|
||||
no global tab-width knob. Backed by state so menu checkmarks recompose. */
|
||||
private var fTabWidth by mutableStateOf(4)
|
||||
actual var editorTabWidth: Int
|
||||
get() = fTabWidth
|
||||
set(value) { fTabWidth = value }
|
||||
@@ -0,0 +1,23 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.platform.Font
|
||||
|
||||
// ==================
|
||||
// MARK: Monospace body font — JVM actuals (classpath font/ staged by
|
||||
// jvmProcessResources)
|
||||
// ==================
|
||||
|
||||
actual val monoFontFamily: FontFamily? by lazy {
|
||||
val vBytes = object {}.javaClass.getResourceAsStream("/font/NotoSansMono.ttf")?.use { it.readBytes() }
|
||||
if (vBytes == null) {
|
||||
println("apidemo: NotoSansMono.ttf not on the classpath — body uses the default font")
|
||||
null
|
||||
} else {
|
||||
FontFamily(Font(identity = kMonoFamily, data = vBytes))
|
||||
}
|
||||
}
|
||||
|
||||
actual val monoFontFamilyName: String? by lazy {
|
||||
if (monoFontFamily != null) kMonoFamily else null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
|
||||
// The JVM comparison app's entry point. Runs the SAME shared App() as the
|
||||
// native :apidemo, but on Compose Desktop (JVM) against upstream
|
||||
// org.jetbrains.compose. Client-certificate (mTLS) features are native-only
|
||||
// (they drive the bundled libcurl); the jvm actuals report that instead.
|
||||
fun main() = application {
|
||||
Window(
|
||||
// The shared App installs a persist-then-close hook (InstallWindowHooks).
|
||||
onCloseRequest = { if (jvmOnCloseRequest?.invoke() != false) exitApplication() },
|
||||
onPreviewKeyEvent = { jvmOnKeyShortcut?.invoke(it) ?: false },
|
||||
title = "API Manager — JVM (upstream Compose)",
|
||||
state = rememberWindowState(width = 1240.dp, height = 820.dp),
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package apidemo
|
||||
|
||||
// ==================
|
||||
// MARK: TLS chain / client-certificate — JVM actuals (native-only feature)
|
||||
// ==================
|
||||
// The native builds drive the bundled libcurl (CURLOPT_SSLCERT / CERTINFO);
|
||||
// there is no libcurl on the jvm parity stack, so both entry points report
|
||||
// the limitation instead of half-implementing it over JSSE.
|
||||
|
||||
private const val kNativeOnly =
|
||||
"Client-certificate (mTLS) features need the native build — they drive the bundled libcurl directly."
|
||||
|
||||
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain = TlsChain(emptyList(), kNativeOnly)
|
||||
|
||||
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse = ApiResponse(
|
||||
ok = false,
|
||||
status = 0,
|
||||
statusText = "",
|
||||
timeMs = 0,
|
||||
sizeBytes = 0,
|
||||
headers = emptyList(),
|
||||
body = "",
|
||||
error = kNativeOnly,
|
||||
)
|
||||
|
||||
actual fun sweepTempClientCerts() {}
|
||||
@@ -0,0 +1,63 @@
|
||||
package apidemo
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import com.compose.sdl.LocalComposeNativeWindow
|
||||
import com.compose.sdl.TextLayoutConfig
|
||||
import com.compose.sdl.registerMemoryResource
|
||||
import com.compose.sdl.removeMemoryResource
|
||||
import com.compose.sdl.res.ResourceKind
|
||||
import com.compose.sdl.text.currentTextMeasurer
|
||||
|
||||
// ==================
|
||||
// MARK: Native actuals — pure delegation to the port's SDL-backed APIs
|
||||
// ==================
|
||||
|
||||
actual fun appDataDir(inOrg: String, inApp: String): String? =
|
||||
com.compose.sdl.appDataDir(inOrg, inApp)
|
||||
|
||||
actual fun revealInFileManager(inPath: String, inOnResult: ((Boolean) -> Unit)?) =
|
||||
com.compose.sdl.revealInFileManager(inPath, inOnResult)
|
||||
|
||||
actual fun fileManagerName(): String = com.compose.sdl.fileManagerName()
|
||||
|
||||
actual fun showSaveFileDialog(inDefaultName: String?, inOnResult: (String?) -> Unit) =
|
||||
com.compose.sdl.showSaveFileDialog(inDefaultName, inOnResult)
|
||||
|
||||
actual fun showOpenFileDialog(inOnResult: (String?) -> Unit) =
|
||||
com.compose.sdl.showOpenFileDialog(inOnResult)
|
||||
|
||||
actual fun clipEntryOfText(inText: String): ClipEntry = ClipEntry.withPlainText(inText)
|
||||
|
||||
actual fun registerMemoryImage(inKey: String, inBytes: ByteArray) = registerMemoryResource(inKey, inBytes)
|
||||
|
||||
actual fun removeMemoryImage(inKey: String) = removeMemoryResource(inKey)
|
||||
|
||||
@Composable
|
||||
actual fun memoryImagePainter(inKey: String, inSvg: Boolean): Painter =
|
||||
com.compose.sdl.res.painterResource(inKey, if (inSvg) ResourceKind.Svg else ResourceKind.Raster)
|
||||
|
||||
actual fun wrappedRowCount(inText: String, inFontPx: Int, inMaxWidthPx: Int, inFamilyName: String?): Int =
|
||||
currentTextMeasurer.wrap(inText, inFontPx, inMaxWidthPx, inFamilyName).lines.size
|
||||
|
||||
actual var editorTabWidth: Int
|
||||
get() = TextLayoutConfig.tabWidth
|
||||
set(value) { TextLayoutConfig.tabWidth = value }
|
||||
|
||||
internal actual val systemFileSystem: okio.FileSystem = okio.FileSystem.SYSTEM
|
||||
|
||||
actual fun createApiHttpClient(): io.ktor.client.HttpClient =
|
||||
io.ktor.client.HttpClient(io.ktor.client.engine.curl.Curl)
|
||||
|
||||
@Composable
|
||||
actual fun InstallWindowHooks(inOnCloseRequest: () -> Boolean, inOnKeyShortcut: (KeyEvent) -> Boolean) {
|
||||
val vWindow = LocalComposeNativeWindow.current
|
||||
DisposableEffect(inOnCloseRequest, inOnKeyShortcut) {
|
||||
vWindow.setOnCloseRequest(inOnCloseRequest)
|
||||
vWindow.setOnKeyShortcut(inOnKeyShortcut)
|
||||
onDispose { vWindow.setOnCloseRequest(null); vWindow.setOnKeyShortcut(null) }
|
||||
}
|
||||
}
|
||||
@@ -39,21 +39,10 @@ class PreparedCert(
|
||||
Throws with a user-facing message if the cert/key can't be loaded. */
|
||||
expect fun prepareClientCert(inReq: ApiRequest): PreparedCert
|
||||
|
||||
/* Remove any temporary client certs (and their key containers) a previous run
|
||||
left behind in the Windows store after a crash — identified by our prefix, so
|
||||
the user's own certificates are never touched. No-op off Windows. Call once
|
||||
at startup. */
|
||||
expect fun sweepTempClientCerts()
|
||||
|
||||
/* One certificate in the chain: its CURLINFO_CERTINFO-style fields (Subject /
|
||||
Issuer / the PEM under "Cert" / dates …) and whether the server actually
|
||||
presented it. Derived certs — an issuer resolved from the OS store, or a
|
||||
name-only placeholder — have fromServer = false and are drawn dotted. */
|
||||
class ChainCert(val fields: List<Pair<String, String>>, val fromServer: Boolean)
|
||||
|
||||
/* The server's TLS certificate chain. error is set instead when it couldn't be
|
||||
fetched; fields vary by TLS backend. */
|
||||
class TlsChain(val certs: List<ChainCert>, val error: String?)
|
||||
// ChainCert / TlsChain and the sweepTempClientCerts / inspectTlsChain /
|
||||
// curlSendWithClientCert expects live in commonMain (TlsCommon.kt) — the UI
|
||||
// consumes them from shared code; the per-OS ClientCert*.kt actuals below the
|
||||
// native tree actualize sweepTempClientCerts directly.
|
||||
|
||||
/* Continue a server-presented chain with its issuer(s). Where possible the
|
||||
issuer is resolved from the OS certificate store with full info; otherwise the
|
||||
@@ -120,7 +109,7 @@ private fun onCurlDiscard(inBuffer: CPointer<ByteVar>, inSize: size_t, inCount:
|
||||
request's client certificate if it has one (so mTLS endpoints work too).
|
||||
inReq is already var-resolved. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun inspectTlsChain(inReq: ApiRequest): TlsChain
|
||||
actual fun inspectTlsChain(inReq: ApiRequest): TlsChain
|
||||
{
|
||||
val vCert = try { if (inReq.hasClientCert) prepareClientCert(inReq) else null }
|
||||
catch (e: Throwable) { return TlsChain(emptyList(), e.message ?: "Client certificate error") }
|
||||
@@ -185,7 +174,7 @@ private fun readCertInfo(inCurl: COpaquePointer): List<List<Pair<String, String>
|
||||
/* Send a client-certificate request through libcurl and adapt the result into
|
||||
the same ApiResponse the Ktor path produces. inReq is already var-resolved. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
||||
actual fun curlSendWithClientCert(inReq: ApiRequest): ApiResponse
|
||||
{
|
||||
val vMark = TimeSource.Monotonic.markNow()
|
||||
val vCert = try { prepareClientCert(inReq) }
|
||||
|
||||
@@ -6,17 +6,10 @@ import com.compose.sdl.loadComposeResourceBytes
|
||||
import com.compose.sdl.text.namedFontFamily
|
||||
|
||||
// ==================
|
||||
// MARK: Monospace body font
|
||||
// MARK: Monospace body font — native actuals (data.kres + IconFont)
|
||||
// ==================
|
||||
|
||||
// Family name the body editor passes for monospace text. Resolves to the bundled
|
||||
// Noto Sans Mono once registered; null means the font isn't in data.kres, in
|
||||
// which case the body falls back to the default (proportional Noto Sans).
|
||||
const val kMonoFamily = "noto-mono"
|
||||
|
||||
// The raw project family name string — used by the SyntaxHighlight tokeniser's
|
||||
// wrap() call which measures via NativeTextMeasurer (accepts a name string).
|
||||
val monoFontFamilyName: String? by lazy {
|
||||
actual val monoFontFamilyName: String? by lazy {
|
||||
val vBytes = loadComposeResourceBytes("font/NotoSansMono.ttf")
|
||||
if (vBytes == null) {
|
||||
println("apidemo: NotoSansMono.ttf not bundled — body uses the default font")
|
||||
@@ -27,9 +20,6 @@ val monoFontFamilyName: String? by lazy {
|
||||
}
|
||||
}
|
||||
|
||||
// The material3 Text / BasicTextField-shaped FontFamily wrapper. Wraps the
|
||||
// registered project family in a `NamedFont`-backed FontFamily so the m3 Text
|
||||
// signature (which takes `FontFamily?` proper) can be given the mono family.
|
||||
val monoFontFamily: FontFamily? by lazy {
|
||||
actual val monoFontFamily: FontFamily? by lazy {
|
||||
monoFontFamilyName?.let { namedFontFamily(it) }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+18
-1
@@ -5,6 +5,7 @@
|
||||
// apply the multiplatform plugin. Each subproject still applies the ones it needs.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.plugin.compose) apply false
|
||||
alias(libs.plugins.kotlin.plugin.serialization) apply false
|
||||
alias(libs.plugins.compose.multiplatform) apply false
|
||||
@@ -17,7 +18,8 @@ plugins {
|
||||
// surface worth tracking, so skip them.
|
||||
apiValidation {
|
||||
klib { enabled = true }
|
||||
ignoredProjects.addAll(listOf("demo", "apidemo"))
|
||||
// compose-desktop-native-bridge is a plain JVM Gradle plugin — no klib ABI to track.
|
||||
ignoredProjects.addAll(listOf("demo", "apidemo", "compose-desktop-native-bridge"))
|
||||
}
|
||||
|
||||
// Project coordinates. `group` is baked into every module's Kotlin/Native klib
|
||||
@@ -47,6 +49,7 @@ allprojects {
|
||||
val kAppModules = setOf(":demo", ":apidemo")
|
||||
val kPublishedLibs = setOf(
|
||||
":ui", ":ui-util", ":ui-geometry", ":ui-unit", ":ui-backhandler",
|
||||
":ui-tooling-preview",
|
||||
":animation-core", ":animation", ":animation-graphics",
|
||||
":foundation", ":foundation-layout",
|
||||
":material3", ":material-ripple",
|
||||
@@ -121,8 +124,22 @@ allprojects {
|
||||
if (vNativeTargetTokens.any { name.contains(it, ignoreCase = true) }) {
|
||||
resolutionStrategy.dependencySubstitution {
|
||||
substitute(module("org.jetbrains.compose.ui:ui")).using(project(":ui"))
|
||||
// ui's split artifacts: ui-graphics + ui-text are merged INTO
|
||||
// :ui in the port; ui-unit / ui-geometry have their own modules.
|
||||
substitute(module("org.jetbrains.compose.ui:ui-graphics")).using(project(":ui"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-text")).using(project(":ui"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-unit")).using(project(":ui-unit"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-geometry")).using(project(":ui-geometry"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-util")).using(project(":ui-util"))
|
||||
substitute(module("org.jetbrains.compose.ui:ui-tooling-preview")).using(project(":ui-tooling-preview"))
|
||||
substitute(module("org.jetbrains.compose.foundation:foundation")).using(project(":foundation"))
|
||||
substitute(module("org.jetbrains.compose.foundation:foundation-layout")).using(project(":foundation-layout"))
|
||||
substitute(module("org.jetbrains.compose.animation:animation")).using(project(":animation"))
|
||||
substitute(module("org.jetbrains.compose.animation:animation-core")).using(project(":animation-core"))
|
||||
substitute(module("org.jetbrains.compose.material3:material3")).using(project(":material3"))
|
||||
// navigation3-ui: the JB Maven artifact has no K/N desktop
|
||||
// klibs — the port vendors it as :navigation3-ui.
|
||||
substitute(module("org.jetbrains.androidx.navigation3:navigation3-ui")).using(project(":navigation3-ui"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ import com.compose.sdl.graphics.decodeEncodedImageBitmap
|
||||
drawables ship at a single density, so no decode-time rescale applies. */
|
||||
internal actual fun ByteArray.toImageBitmap(resourceDensity: Int, targetDensity: Int): ImageBitmap =
|
||||
decodeEncodedImageBitmap(this)
|
||||
?: error("Image decode failed — is the SDL renderer initialised before painterResource ran?")
|
||||
?: error("Image decode failed — is the render backend initialised before painterResource ran?")
|
||||
|
||||
/* SVG on SDL: SDL3_image rasterises SVG at its intrinsic size (IMG_LoadSVG),
|
||||
so the "element" is just the raw bytes and the painter is the rasterised
|
||||
@@ -34,5 +34,5 @@ internal actual fun ByteArray.toSvgElement(): SvgElement = SvgElement(this)
|
||||
internal actual fun SvgElement.toSvgPainter(density: Density): Painter =
|
||||
BitmapPainter(
|
||||
decodeEncodedImageBitmap(bytes)
|
||||
?: error("SVG rasterisation failed — is the SDL renderer initialised before painterResource ran?")
|
||||
?: error("SVG rasterisation failed — is the render backend initialised before painterResource ran?")
|
||||
)
|
||||
|
||||
+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
|
||||
|
||||
@@ -32,9 +32,13 @@ import com.compose.sdl.window.createPopupHostState
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.cinterop.toKString
|
||||
import kotlinx.cinterop.reinterpret
|
||||
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
|
||||
@@ -141,12 +145,18 @@ 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()
|
||||
|
||||
// ============
|
||||
// Main loop
|
||||
var vGcLastTicks = SDL_GetTicks()
|
||||
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()
|
||||
|
||||
// ============
|
||||
@@ -174,11 +184,12 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
}
|
||||
|
||||
mainDispatcher.drainPending()
|
||||
FrameProfiler.phase("events")
|
||||
|
||||
// ============
|
||||
// App composition pump — Window()s may appear / disappear here.
|
||||
Snapshot.sendApplyNotifications()
|
||||
appClock.sendFrame()
|
||||
appClock.sendFrame(frameClockNanos())
|
||||
yield()
|
||||
runtime.reapDestroyed()
|
||||
|
||||
@@ -193,6 +204,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
// Exit when the last window is gone (after at least one existed).
|
||||
if (runtime.hadWindow && runtime.windows.isEmpty()) runtime.exitRequested = true
|
||||
if (runtime.exitRequested) break
|
||||
FrameProfiler.phase("app")
|
||||
|
||||
// ============
|
||||
// Per-window pump + render.
|
||||
@@ -201,18 +213,29 @@ 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()) {
|
||||
vW.renderFrame()
|
||||
vAnyRendered = true
|
||||
if (!vW.backend.vsyncEnabled) vAllVsync = false
|
||||
}
|
||||
FrameProfiler.phase("render")
|
||||
}
|
||||
runtime.reapDestroyed()
|
||||
FrameProfiler.frameDone(vAnyRendered)
|
||||
|
||||
// ============
|
||||
// Drain deferred native-resource disposals on the MAIN thread —
|
||||
// textures/surfaces whose owner closed them or whose Cleaner fired
|
||||
// on a GC worker enqueue here (SDL calls aren't thread-safe). This
|
||||
// is the ownership path that makes the GC nudge below a mere
|
||||
// backstop (ROADMAP.md item 1).
|
||||
com.compose.sdl.graphics.NativeReleaseQueue.drain()
|
||||
|
||||
// ============
|
||||
// Pace / idle-skip.
|
||||
@@ -224,6 +247,23 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
SDL_WaitEventTimeout(null, 10)
|
||||
for (vW in runtime.windows) vW.resetFpsWindow()
|
||||
}
|
||||
|
||||
// ============
|
||||
// Native-memory nudge. Renderer resources (Skia surfaces / images /
|
||||
// fonts, SDL textures) are freed by Cleaners that only run when the
|
||||
// Kotlin/Native GC collects — and a Compose app's Kotlin heap is
|
||||
// small enough that the allocation-driven scheduler can starve them
|
||||
// for minutes while the NATIVE heap balloons (issue #2: memory
|
||||
// "never released" while navigating). Collect periodically, only
|
||||
// after rendering activity, between frames; costs ~ms at these
|
||||
// heap sizes and keeps RSS tracking real usage.
|
||||
if (vAnyRendered) vRenderedSinceGc = true
|
||||
val vNowTicks = SDL_GetTicks()
|
||||
if (vRenderedSinceGc && vNowTicks - vGcLastTicks >= 10_000uL) {
|
||||
vGcLastTicks = vNowTicks
|
||||
vRenderedSinceGc = false
|
||||
collectNativeGarbage()
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
@@ -231,6 +271,7 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
snapshotHandle.dispose()
|
||||
for (vW in runtime.windows.toList()) runtime.scheduleDestroy(vW)
|
||||
runtime.reapDestroyed()
|
||||
com.compose.sdl.graphics.NativeReleaseQueue.drain()
|
||||
appComposition.dispose()
|
||||
appRecomposer.cancel()
|
||||
appRecomposeJob.cancelAndJoin()
|
||||
@@ -243,6 +284,132 @@ fun nativeComposeApp(content: @Composable ApplicationScope.() -> Unit) {
|
||||
SDL_Quit()
|
||||
}
|
||||
|
||||
/* CDN_PROFILE=1 — per-phase timings, printed every ~2s of rendered frames.
|
||||
A named-phase SINGLETON so both the main loop (events / app / pump / render)
|
||||
AND renderFrame's sub-steps (render.layout / render.draw / render.present)
|
||||
report into one line. `mark()` resets the stopwatch; `phase(name)` charges
|
||||
the elapsed since the last mark/phase to that name. Measure first, optimize
|
||||
second — see ROADMAP.md. */
|
||||
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
internal object FrameProfiler {
|
||||
// null until first checked; then true/false for the run's lifetime.
|
||||
private var fEnabled: Boolean? = null
|
||||
// Output file — resolved once from CDN_PROFILE. Writing to a file (not
|
||||
// stdout) lets GUI-subsystem apps (the demo links --subsystem,windows, so
|
||||
// it has no console) be profiled too. CDN_PROFILE=1 → "cdn_profile.log" in
|
||||
// the cwd; CDN_PROFILE=<path> → that path.
|
||||
private var fPath: String = "cdn_profile.log"
|
||||
val enabled: Boolean
|
||||
get() = fEnabled ?: run {
|
||||
val vEnv = platform.posix.getenv("CDN_PROFILE")?.toKString()
|
||||
if (vEnv != null && vEnv != "1" && vEnv.isNotEmpty()) fPath = vEnv
|
||||
(vEnv != null).also { fEnabled = it }
|
||||
}
|
||||
|
||||
private val fFreq = SDL_GetPerformanceFrequency().toDouble()
|
||||
// Insertion-ordered so the printed line follows the call order.
|
||||
private val fSum = LinkedHashMap<String, Double>()
|
||||
private val fMax = LinkedHashMap<String, Double>()
|
||||
private var fFrames = 0
|
||||
private var fLastPrintMs = SDL_GetTicks()
|
||||
private var fMark = 0uL
|
||||
|
||||
fun mark() { if (enabled) fMark = SDL_GetPerformanceCounter() }
|
||||
|
||||
fun phase(inName: String) {
|
||||
if (!enabled) return
|
||||
val vNow = SDL_GetPerformanceCounter()
|
||||
val vMs = (vNow - fMark).toDouble() * 1000.0 / fFreq
|
||||
fSum[inName] = (fSum[inName] ?: 0.0) + vMs
|
||||
if (vMs > (fMax[inName] ?: 0.0)) fMax[inName] = vMs
|
||||
fMark = vNow
|
||||
}
|
||||
|
||||
fun frameDone(inRendered: Boolean) {
|
||||
if (!enabled) return
|
||||
if (inRendered) fFrames++
|
||||
val vNowMs = SDL_GetTicks()
|
||||
if (vNowMs - fLastPrintMs >= 2000u && fFrames > 0) {
|
||||
val vParts = fSum.keys.map { vName ->
|
||||
val vAvg = (fSum[vName] ?: 0.0) / fFrames
|
||||
"$vName=${(vAvg * 100).toInt() / 100.0}/${((fMax[vName] ?: 0.0) * 100).toInt() / 100.0}ms"
|
||||
}
|
||||
// Per-frame draw-work averages (see DrawStats): what's inside `draw`.
|
||||
val vStats = com.compose.sdl.graphics.DrawStats
|
||||
val vDraw = "geo=${vStats.geometrySubmits / fFrames} verts=${vStats.vertices / fFrames} " +
|
||||
"masks=${vStats.maskRealizations / fFrames} text=${vStats.textDraws / fFrames} img=${vStats.imageBlits / fFrames}"
|
||||
val vLine = "[profile] frames=$fFrames avg/max " + vParts.joinToString(" ") + " | per-frame " + vDraw + "\n"
|
||||
val vFile = platform.posix.fopen(fPath, "a")
|
||||
if (vFile != null) {
|
||||
platform.posix.fputs(vLine, vFile)
|
||||
platform.posix.fclose(vFile)
|
||||
}
|
||||
vStats.reset()
|
||||
fSum.clear(); fMax.clear()
|
||||
fFrames = 0
|
||||
fLastPrintMs = vNowMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Trigger a Kotlin/Native GC so Cleaner-managed renderer resources release
|
||||
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()
|
||||
|
||||
/* Single-window compatibility wrapper — the pre-multi-window entry point.
|
||||
Closing the window exits the app, exactly as before. */
|
||||
fun nativeComposeWindow(
|
||||
@@ -378,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.
|
||||
@@ -433,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)
|
||||
|
||||
@@ -441,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)
|
||||
@@ -640,7 +816,18 @@ internal class WindowInstance(
|
||||
// Frame pump
|
||||
|
||||
fun shouldRender(): Boolean =
|
||||
needsFrame || (recomposer?.hasPendingWork == true) || onFrame != null
|
||||
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.
|
||||
|
||||
fun renderFrame() {
|
||||
val vRender = renderBackend ?: return
|
||||
@@ -651,6 +838,7 @@ internal class WindowInstance(
|
||||
vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)
|
||||
host.setConstraints(backend.pixelWidth, backend.pixelHeight)
|
||||
host.measureAndLayout()
|
||||
FrameProfiler.phase(" layout")
|
||||
|
||||
// Hover refresh after layout (upstream skiko: SyntheticEventSender).
|
||||
if (hasMousePos) {
|
||||
@@ -658,30 +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")
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// Klib ABI Dump
|
||||
// Targets: [linuxArm64, linuxX64, macosArm64, mingwX64]
|
||||
// Rendering settings:
|
||||
// - Signature version: 2
|
||||
// - Show manifest properties: true
|
||||
// - Show declarations: true
|
||||
|
||||
// Library unique name: <com.bitsycore.compose.sdl:ui-tooling-preview>
|
||||
open annotation class androidx.compose.ui.tooling.preview/AndroidUiMode : kotlin/Annotation { // androidx.compose.ui.tooling.preview/AndroidUiMode|null[0]
|
||||
constructor <init>() // androidx.compose.ui.tooling.preview/AndroidUiMode.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
open annotation class androidx.compose.ui.tooling.preview/Preview : kotlin/Annotation { // androidx.compose.ui.tooling.preview/Preview|null[0]
|
||||
constructor <init>(kotlin/String = ..., kotlin/String = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/String = ..., kotlin/Float = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Long = ..., kotlin/Int = ..., kotlin/String = ..., kotlin/Int = ...) // androidx.compose.ui.tooling.preview/Preview.<init>|<init>(kotlin.String;kotlin.String;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.String;kotlin.Float;kotlin.Boolean;kotlin.Boolean;kotlin.Long;kotlin.Int;kotlin.String;kotlin.Int){}[0]
|
||||
|
||||
final val apiLevel // androidx.compose.ui.tooling.preview/Preview.apiLevel|{}apiLevel[0]
|
||||
final fun <get-apiLevel>(): kotlin/Int // androidx.compose.ui.tooling.preview/Preview.apiLevel.<get-apiLevel>|<get-apiLevel>(){}[0]
|
||||
final val backgroundColor // androidx.compose.ui.tooling.preview/Preview.backgroundColor|{}backgroundColor[0]
|
||||
final fun <get-backgroundColor>(): kotlin/Long // androidx.compose.ui.tooling.preview/Preview.backgroundColor.<get-backgroundColor>|<get-backgroundColor>(){}[0]
|
||||
final val device // androidx.compose.ui.tooling.preview/Preview.device|{}device[0]
|
||||
final fun <get-device>(): kotlin/String // androidx.compose.ui.tooling.preview/Preview.device.<get-device>|<get-device>(){}[0]
|
||||
final val fontScale // androidx.compose.ui.tooling.preview/Preview.fontScale|{}fontScale[0]
|
||||
final fun <get-fontScale>(): kotlin/Float // androidx.compose.ui.tooling.preview/Preview.fontScale.<get-fontScale>|<get-fontScale>(){}[0]
|
||||
final val group // androidx.compose.ui.tooling.preview/Preview.group|{}group[0]
|
||||
final fun <get-group>(): kotlin/String // androidx.compose.ui.tooling.preview/Preview.group.<get-group>|<get-group>(){}[0]
|
||||
final val heightDp // androidx.compose.ui.tooling.preview/Preview.heightDp|{}heightDp[0]
|
||||
final fun <get-heightDp>(): kotlin/Int // androidx.compose.ui.tooling.preview/Preview.heightDp.<get-heightDp>|<get-heightDp>(){}[0]
|
||||
final val locale // androidx.compose.ui.tooling.preview/Preview.locale|{}locale[0]
|
||||
final fun <get-locale>(): kotlin/String // androidx.compose.ui.tooling.preview/Preview.locale.<get-locale>|<get-locale>(){}[0]
|
||||
final val name // androidx.compose.ui.tooling.preview/Preview.name|{}name[0]
|
||||
final fun <get-name>(): kotlin/String // androidx.compose.ui.tooling.preview/Preview.name.<get-name>|<get-name>(){}[0]
|
||||
final val showBackground // androidx.compose.ui.tooling.preview/Preview.showBackground|{}showBackground[0]
|
||||
final fun <get-showBackground>(): kotlin/Boolean // androidx.compose.ui.tooling.preview/Preview.showBackground.<get-showBackground>|<get-showBackground>(){}[0]
|
||||
final val showSystemUi // androidx.compose.ui.tooling.preview/Preview.showSystemUi|{}showSystemUi[0]
|
||||
final fun <get-showSystemUi>(): kotlin/Boolean // androidx.compose.ui.tooling.preview/Preview.showSystemUi.<get-showSystemUi>|<get-showSystemUi>(){}[0]
|
||||
final val uiMode // androidx.compose.ui.tooling.preview/Preview.uiMode|{}uiMode[0]
|
||||
final fun <get-uiMode>(): kotlin/Int // androidx.compose.ui.tooling.preview/Preview.uiMode.<get-uiMode>|<get-uiMode>(){}[0]
|
||||
final val wallpaper // androidx.compose.ui.tooling.preview/Preview.wallpaper|{}wallpaper[0]
|
||||
final fun <get-wallpaper>(): kotlin/Int // androidx.compose.ui.tooling.preview/Preview.wallpaper.<get-wallpaper>|<get-wallpaper>(){}[0]
|
||||
final val widthDp // androidx.compose.ui.tooling.preview/Preview.widthDp|{}widthDp[0]
|
||||
final fun <get-widthDp>(): kotlin/Int // androidx.compose.ui.tooling.preview/Preview.widthDp.<get-widthDp>|<get-widthDp>(){}[0]
|
||||
}
|
||||
|
||||
open annotation class androidx.compose.ui.tooling.preview/PreviewDynamicColors : kotlin/Annotation { // androidx.compose.ui.tooling.preview/PreviewDynamicColors|null[0]
|
||||
constructor <init>() // androidx.compose.ui.tooling.preview/PreviewDynamicColors.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
open annotation class androidx.compose.ui.tooling.preview/PreviewFontScale : kotlin/Annotation { // androidx.compose.ui.tooling.preview/PreviewFontScale|null[0]
|
||||
constructor <init>() // androidx.compose.ui.tooling.preview/PreviewFontScale.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
open annotation class androidx.compose.ui.tooling.preview/PreviewLightDark : kotlin/Annotation { // androidx.compose.ui.tooling.preview/PreviewLightDark|null[0]
|
||||
constructor <init>() // androidx.compose.ui.tooling.preview/PreviewLightDark.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
open annotation class androidx.compose.ui.tooling.preview/PreviewParameter : kotlin/Annotation { // androidx.compose.ui.tooling.preview/PreviewParameter|null[0]
|
||||
constructor <init>(kotlin.reflect/KClass<out androidx.compose.ui.tooling.preview/PreviewParameterProvider<*>>, kotlin/Int = ...) // androidx.compose.ui.tooling.preview/PreviewParameter.<init>|<init>(kotlin.reflect.KClass<out|androidx.compose.ui.tooling.preview.PreviewParameterProvider<*>>;kotlin.Int){}[0]
|
||||
|
||||
final val limit // androidx.compose.ui.tooling.preview/PreviewParameter.limit|{}limit[0]
|
||||
final fun <get-limit>(): kotlin/Int // androidx.compose.ui.tooling.preview/PreviewParameter.limit.<get-limit>|<get-limit>(){}[0]
|
||||
final val provider // androidx.compose.ui.tooling.preview/PreviewParameter.provider|{}provider[0]
|
||||
final fun <get-provider>(): kotlin.reflect/KClass<out androidx.compose.ui.tooling.preview/PreviewParameterProvider<*>> // androidx.compose.ui.tooling.preview/PreviewParameter.provider.<get-provider>|<get-provider>(){}[0]
|
||||
}
|
||||
|
||||
open annotation class androidx.compose.ui.tooling.preview/PreviewScreenSizes : kotlin/Annotation { // androidx.compose.ui.tooling.preview/PreviewScreenSizes|null[0]
|
||||
constructor <init>() // androidx.compose.ui.tooling.preview/PreviewScreenSizes.<init>|<init>(){}[0]
|
||||
}
|
||||
|
||||
open annotation class androidx.compose.ui.tooling.preview/PreviewWrapper : kotlin/Annotation { // androidx.compose.ui.tooling.preview/PreviewWrapper|null[0]
|
||||
constructor <init>(kotlin.reflect/KClass<out androidx.compose.ui.tooling.preview/PreviewWrapperProvider>) // androidx.compose.ui.tooling.preview/PreviewWrapper.<init>|<init>(kotlin.reflect.KClass<out|androidx.compose.ui.tooling.preview.PreviewWrapperProvider>){}[0]
|
||||
|
||||
final val wrapper // androidx.compose.ui.tooling.preview/PreviewWrapper.wrapper|{}wrapper[0]
|
||||
final fun <get-wrapper>(): kotlin.reflect/KClass<out androidx.compose.ui.tooling.preview/PreviewWrapperProvider> // androidx.compose.ui.tooling.preview/PreviewWrapper.wrapper.<get-wrapper>|<get-wrapper>(){}[0]
|
||||
}
|
||||
|
||||
abstract interface <#A: kotlin/Any?> androidx.compose.ui.tooling.preview/PreviewParameterProvider { // androidx.compose.ui.tooling.preview/PreviewParameterProvider|null[0]
|
||||
abstract val values // androidx.compose.ui.tooling.preview/PreviewParameterProvider.values|{}values[0]
|
||||
abstract fun <get-values>(): kotlin.sequences/Sequence<#A> // androidx.compose.ui.tooling.preview/PreviewParameterProvider.values.<get-values>|<get-values>(){}[0]
|
||||
open val count // androidx.compose.ui.tooling.preview/PreviewParameterProvider.count|{}count[0]
|
||||
open fun <get-count>(): kotlin/Int // androidx.compose.ui.tooling.preview/PreviewParameterProvider.count.<get-count>|<get-count>(){}[0]
|
||||
|
||||
open fun getDisplayName(kotlin/Int): kotlin/String? // androidx.compose.ui.tooling.preview/PreviewParameterProvider.getDisplayName|getDisplayName(kotlin.Int){}[0]
|
||||
}
|
||||
|
||||
abstract interface androidx.compose.ui.tooling.preview/PreviewWrapperProvider { // androidx.compose.ui.tooling.preview/PreviewWrapperProvider|null[0]
|
||||
abstract fun Wrap(kotlin/Function2<androidx.compose.runtime/Composer, kotlin/Int, kotlin/Unit>, androidx.compose.runtime/Composer?, kotlin/Int) // androidx.compose.ui.tooling.preview/PreviewWrapperProvider.Wrap|Wrap(kotlin.Function2<androidx.compose.runtime.Composer,kotlin.Int,kotlin.Unit>;androidx.compose.runtime.Composer?;kotlin.Int){}[0]
|
||||
}
|
||||
|
||||
open class <#A: kotlin/Any?> androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider : androidx.compose.ui.tooling.preview/PreviewParameterProvider<#A> { // androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider|null[0]
|
||||
constructor <init>(kotlin.collections/Collection<#A>) // androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider.<init>|<init>(kotlin.collections.Collection<1:0>){}[0]
|
||||
|
||||
open val values // androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider.values|{}values[0]
|
||||
open fun <get-values>(): kotlin.sequences/Sequence<#A> // androidx.compose.ui.tooling.preview.datasource/CollectionPreviewParameterProvider.values.<get-values>|<get-values>(){}[0]
|
||||
}
|
||||
|
||||
open class androidx.compose.ui.tooling.preview.datasource/LoremIpsum : androidx.compose.ui.tooling.preview/PreviewParameterProvider<kotlin/String> { // androidx.compose.ui.tooling.preview.datasource/LoremIpsum|null[0]
|
||||
constructor <init>() // androidx.compose.ui.tooling.preview.datasource/LoremIpsum.<init>|<init>(){}[0]
|
||||
constructor <init>(kotlin/Int) // androidx.compose.ui.tooling.preview.datasource/LoremIpsum.<init>|<init>(kotlin.Int){}[0]
|
||||
|
||||
open val values // androidx.compose.ui.tooling.preview.datasource/LoremIpsum.values|{}values[0]
|
||||
open fun <get-values>(): kotlin.sequences/Sequence<kotlin/String> // androidx.compose.ui.tooling.preview.datasource/LoremIpsum.values.<get-values>|<get-values>(){}[0]
|
||||
}
|
||||
|
||||
final object androidx.compose.ui.tooling.preview/AndroidUiModes { // androidx.compose.ui.tooling.preview/AndroidUiModes|null[0]
|
||||
final const val UI_MODE_NIGHT_MASK // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_MASK|{}UI_MODE_NIGHT_MASK[0]
|
||||
final fun <get-UI_MODE_NIGHT_MASK>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_MASK.<get-UI_MODE_NIGHT_MASK>|<get-UI_MODE_NIGHT_MASK>(){}[0]
|
||||
final const val UI_MODE_NIGHT_NO // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_NO|{}UI_MODE_NIGHT_NO[0]
|
||||
final fun <get-UI_MODE_NIGHT_NO>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_NO.<get-UI_MODE_NIGHT_NO>|<get-UI_MODE_NIGHT_NO>(){}[0]
|
||||
final const val UI_MODE_NIGHT_UNDEFINED // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_UNDEFINED|{}UI_MODE_NIGHT_UNDEFINED[0]
|
||||
final fun <get-UI_MODE_NIGHT_UNDEFINED>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_UNDEFINED.<get-UI_MODE_NIGHT_UNDEFINED>|<get-UI_MODE_NIGHT_UNDEFINED>(){}[0]
|
||||
final const val UI_MODE_NIGHT_YES // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_YES|{}UI_MODE_NIGHT_YES[0]
|
||||
final fun <get-UI_MODE_NIGHT_YES>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_NIGHT_YES.<get-UI_MODE_NIGHT_YES>|<get-UI_MODE_NIGHT_YES>(){}[0]
|
||||
final const val UI_MODE_TYPE_APPLIANCE // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_APPLIANCE|{}UI_MODE_TYPE_APPLIANCE[0]
|
||||
final fun <get-UI_MODE_TYPE_APPLIANCE>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_APPLIANCE.<get-UI_MODE_TYPE_APPLIANCE>|<get-UI_MODE_TYPE_APPLIANCE>(){}[0]
|
||||
final const val UI_MODE_TYPE_CAR // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_CAR|{}UI_MODE_TYPE_CAR[0]
|
||||
final fun <get-UI_MODE_TYPE_CAR>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_CAR.<get-UI_MODE_TYPE_CAR>|<get-UI_MODE_TYPE_CAR>(){}[0]
|
||||
final const val UI_MODE_TYPE_DESK // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_DESK|{}UI_MODE_TYPE_DESK[0]
|
||||
final fun <get-UI_MODE_TYPE_DESK>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_DESK.<get-UI_MODE_TYPE_DESK>|<get-UI_MODE_TYPE_DESK>(){}[0]
|
||||
final const val UI_MODE_TYPE_MASK // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_MASK|{}UI_MODE_TYPE_MASK[0]
|
||||
final fun <get-UI_MODE_TYPE_MASK>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_MASK.<get-UI_MODE_TYPE_MASK>|<get-UI_MODE_TYPE_MASK>(){}[0]
|
||||
final const val UI_MODE_TYPE_NORMAL // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_NORMAL|{}UI_MODE_TYPE_NORMAL[0]
|
||||
final fun <get-UI_MODE_TYPE_NORMAL>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_NORMAL.<get-UI_MODE_TYPE_NORMAL>|<get-UI_MODE_TYPE_NORMAL>(){}[0]
|
||||
final const val UI_MODE_TYPE_TELEVISION // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_TELEVISION|{}UI_MODE_TYPE_TELEVISION[0]
|
||||
final fun <get-UI_MODE_TYPE_TELEVISION>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_TELEVISION.<get-UI_MODE_TYPE_TELEVISION>|<get-UI_MODE_TYPE_TELEVISION>(){}[0]
|
||||
final const val UI_MODE_TYPE_UNDEFINED // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_UNDEFINED|{}UI_MODE_TYPE_UNDEFINED[0]
|
||||
final fun <get-UI_MODE_TYPE_UNDEFINED>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_UNDEFINED.<get-UI_MODE_TYPE_UNDEFINED>|<get-UI_MODE_TYPE_UNDEFINED>(){}[0]
|
||||
final const val UI_MODE_TYPE_VR_HEADSET // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_VR_HEADSET|{}UI_MODE_TYPE_VR_HEADSET[0]
|
||||
final fun <get-UI_MODE_TYPE_VR_HEADSET>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_VR_HEADSET.<get-UI_MODE_TYPE_VR_HEADSET>|<get-UI_MODE_TYPE_VR_HEADSET>(){}[0]
|
||||
final const val UI_MODE_TYPE_WATCH // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_WATCH|{}UI_MODE_TYPE_WATCH[0]
|
||||
final fun <get-UI_MODE_TYPE_WATCH>(): kotlin/Int // androidx.compose.ui.tooling.preview/AndroidUiModes.UI_MODE_TYPE_WATCH.<get-UI_MODE_TYPE_WATCH>|<get-UI_MODE_TYPE_WATCH>(){}[0]
|
||||
}
|
||||
|
||||
final object androidx.compose.ui.tooling.preview/Devices { // androidx.compose.ui.tooling.preview/Devices|null[0]
|
||||
final const val AUTOMOTIVE_1024p // androidx.compose.ui.tooling.preview/Devices.AUTOMOTIVE_1024p|{}AUTOMOTIVE_1024p[0]
|
||||
final fun <get-AUTOMOTIVE_1024p>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.AUTOMOTIVE_1024p.<get-AUTOMOTIVE_1024p>|<get-AUTOMOTIVE_1024p>(){}[0]
|
||||
final const val DEFAULT // androidx.compose.ui.tooling.preview/Devices.DEFAULT|{}DEFAULT[0]
|
||||
final fun <get-DEFAULT>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.DEFAULT.<get-DEFAULT>|<get-DEFAULT>(){}[0]
|
||||
final const val DESKTOP // androidx.compose.ui.tooling.preview/Devices.DESKTOP|{}DESKTOP[0]
|
||||
final fun <get-DESKTOP>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.DESKTOP.<get-DESKTOP>|<get-DESKTOP>(){}[0]
|
||||
final const val FOLDABLE // androidx.compose.ui.tooling.preview/Devices.FOLDABLE|{}FOLDABLE[0]
|
||||
final fun <get-FOLDABLE>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.FOLDABLE.<get-FOLDABLE>|<get-FOLDABLE>(){}[0]
|
||||
final const val NEXUS_10 // androidx.compose.ui.tooling.preview/Devices.NEXUS_10|{}NEXUS_10[0]
|
||||
final fun <get-NEXUS_10>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_10.<get-NEXUS_10>|<get-NEXUS_10>(){}[0]
|
||||
final const val NEXUS_5 // androidx.compose.ui.tooling.preview/Devices.NEXUS_5|{}NEXUS_5[0]
|
||||
final fun <get-NEXUS_5>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_5.<get-NEXUS_5>|<get-NEXUS_5>(){}[0]
|
||||
final const val NEXUS_5X // androidx.compose.ui.tooling.preview/Devices.NEXUS_5X|{}NEXUS_5X[0]
|
||||
final fun <get-NEXUS_5X>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_5X.<get-NEXUS_5X>|<get-NEXUS_5X>(){}[0]
|
||||
final const val NEXUS_6 // androidx.compose.ui.tooling.preview/Devices.NEXUS_6|{}NEXUS_6[0]
|
||||
final fun <get-NEXUS_6>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_6.<get-NEXUS_6>|<get-NEXUS_6>(){}[0]
|
||||
final const val NEXUS_6P // androidx.compose.ui.tooling.preview/Devices.NEXUS_6P|{}NEXUS_6P[0]
|
||||
final fun <get-NEXUS_6P>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_6P.<get-NEXUS_6P>|<get-NEXUS_6P>(){}[0]
|
||||
final const val NEXUS_7 // androidx.compose.ui.tooling.preview/Devices.NEXUS_7|{}NEXUS_7[0]
|
||||
final fun <get-NEXUS_7>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_7.<get-NEXUS_7>|<get-NEXUS_7>(){}[0]
|
||||
final const val NEXUS_7_2013 // androidx.compose.ui.tooling.preview/Devices.NEXUS_7_2013|{}NEXUS_7_2013[0]
|
||||
final fun <get-NEXUS_7_2013>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_7_2013.<get-NEXUS_7_2013>|<get-NEXUS_7_2013>(){}[0]
|
||||
final const val NEXUS_9 // androidx.compose.ui.tooling.preview/Devices.NEXUS_9|{}NEXUS_9[0]
|
||||
final fun <get-NEXUS_9>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.NEXUS_9.<get-NEXUS_9>|<get-NEXUS_9>(){}[0]
|
||||
final const val PHONE // androidx.compose.ui.tooling.preview/Devices.PHONE|{}PHONE[0]
|
||||
final fun <get-PHONE>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PHONE.<get-PHONE>|<get-PHONE>(){}[0]
|
||||
final const val PIXEL // androidx.compose.ui.tooling.preview/Devices.PIXEL|{}PIXEL[0]
|
||||
final fun <get-PIXEL>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL.<get-PIXEL>|<get-PIXEL>(){}[0]
|
||||
final const val PIXEL_2 // androidx.compose.ui.tooling.preview/Devices.PIXEL_2|{}PIXEL_2[0]
|
||||
final fun <get-PIXEL_2>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_2.<get-PIXEL_2>|<get-PIXEL_2>(){}[0]
|
||||
final const val PIXEL_2_XL // androidx.compose.ui.tooling.preview/Devices.PIXEL_2_XL|{}PIXEL_2_XL[0]
|
||||
final fun <get-PIXEL_2_XL>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_2_XL.<get-PIXEL_2_XL>|<get-PIXEL_2_XL>(){}[0]
|
||||
final const val PIXEL_3 // androidx.compose.ui.tooling.preview/Devices.PIXEL_3|{}PIXEL_3[0]
|
||||
final fun <get-PIXEL_3>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_3.<get-PIXEL_3>|<get-PIXEL_3>(){}[0]
|
||||
final const val PIXEL_3A // androidx.compose.ui.tooling.preview/Devices.PIXEL_3A|{}PIXEL_3A[0]
|
||||
final fun <get-PIXEL_3A>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_3A.<get-PIXEL_3A>|<get-PIXEL_3A>(){}[0]
|
||||
final const val PIXEL_3A_XL // androidx.compose.ui.tooling.preview/Devices.PIXEL_3A_XL|{}PIXEL_3A_XL[0]
|
||||
final fun <get-PIXEL_3A_XL>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_3A_XL.<get-PIXEL_3A_XL>|<get-PIXEL_3A_XL>(){}[0]
|
||||
final const val PIXEL_3_XL // androidx.compose.ui.tooling.preview/Devices.PIXEL_3_XL|{}PIXEL_3_XL[0]
|
||||
final fun <get-PIXEL_3_XL>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_3_XL.<get-PIXEL_3_XL>|<get-PIXEL_3_XL>(){}[0]
|
||||
final const val PIXEL_4 // androidx.compose.ui.tooling.preview/Devices.PIXEL_4|{}PIXEL_4[0]
|
||||
final fun <get-PIXEL_4>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_4.<get-PIXEL_4>|<get-PIXEL_4>(){}[0]
|
||||
final const val PIXEL_4A // androidx.compose.ui.tooling.preview/Devices.PIXEL_4A|{}PIXEL_4A[0]
|
||||
final fun <get-PIXEL_4A>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_4A.<get-PIXEL_4A>|<get-PIXEL_4A>(){}[0]
|
||||
final const val PIXEL_4_XL // androidx.compose.ui.tooling.preview/Devices.PIXEL_4_XL|{}PIXEL_4_XL[0]
|
||||
final fun <get-PIXEL_4_XL>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_4_XL.<get-PIXEL_4_XL>|<get-PIXEL_4_XL>(){}[0]
|
||||
final const val PIXEL_5 // androidx.compose.ui.tooling.preview/Devices.PIXEL_5|{}PIXEL_5[0]
|
||||
final fun <get-PIXEL_5>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_5.<get-PIXEL_5>|<get-PIXEL_5>(){}[0]
|
||||
final const val PIXEL_6 // androidx.compose.ui.tooling.preview/Devices.PIXEL_6|{}PIXEL_6[0]
|
||||
final fun <get-PIXEL_6>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_6.<get-PIXEL_6>|<get-PIXEL_6>(){}[0]
|
||||
final const val PIXEL_6A // androidx.compose.ui.tooling.preview/Devices.PIXEL_6A|{}PIXEL_6A[0]
|
||||
final fun <get-PIXEL_6A>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_6A.<get-PIXEL_6A>|<get-PIXEL_6A>(){}[0]
|
||||
final const val PIXEL_6_PRO // androidx.compose.ui.tooling.preview/Devices.PIXEL_6_PRO|{}PIXEL_6_PRO[0]
|
||||
final fun <get-PIXEL_6_PRO>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_6_PRO.<get-PIXEL_6_PRO>|<get-PIXEL_6_PRO>(){}[0]
|
||||
final const val PIXEL_7 // androidx.compose.ui.tooling.preview/Devices.PIXEL_7|{}PIXEL_7[0]
|
||||
final fun <get-PIXEL_7>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_7.<get-PIXEL_7>|<get-PIXEL_7>(){}[0]
|
||||
final const val PIXEL_7A // androidx.compose.ui.tooling.preview/Devices.PIXEL_7A|{}PIXEL_7A[0]
|
||||
final fun <get-PIXEL_7A>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_7A.<get-PIXEL_7A>|<get-PIXEL_7A>(){}[0]
|
||||
final const val PIXEL_7_PRO // androidx.compose.ui.tooling.preview/Devices.PIXEL_7_PRO|{}PIXEL_7_PRO[0]
|
||||
final fun <get-PIXEL_7_PRO>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_7_PRO.<get-PIXEL_7_PRO>|<get-PIXEL_7_PRO>(){}[0]
|
||||
final const val PIXEL_8 // androidx.compose.ui.tooling.preview/Devices.PIXEL_8|{}PIXEL_8[0]
|
||||
final fun <get-PIXEL_8>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_8.<get-PIXEL_8>|<get-PIXEL_8>(){}[0]
|
||||
final const val PIXEL_8A // androidx.compose.ui.tooling.preview/Devices.PIXEL_8A|{}PIXEL_8A[0]
|
||||
final fun <get-PIXEL_8A>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_8A.<get-PIXEL_8A>|<get-PIXEL_8A>(){}[0]
|
||||
final const val PIXEL_8_PRO // androidx.compose.ui.tooling.preview/Devices.PIXEL_8_PRO|{}PIXEL_8_PRO[0]
|
||||
final fun <get-PIXEL_8_PRO>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_8_PRO.<get-PIXEL_8_PRO>|<get-PIXEL_8_PRO>(){}[0]
|
||||
final const val PIXEL_9 // androidx.compose.ui.tooling.preview/Devices.PIXEL_9|{}PIXEL_9[0]
|
||||
final fun <get-PIXEL_9>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_9.<get-PIXEL_9>|<get-PIXEL_9>(){}[0]
|
||||
final const val PIXEL_9_PRO // androidx.compose.ui.tooling.preview/Devices.PIXEL_9_PRO|{}PIXEL_9_PRO[0]
|
||||
final fun <get-PIXEL_9_PRO>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_9_PRO.<get-PIXEL_9_PRO>|<get-PIXEL_9_PRO>(){}[0]
|
||||
final const val PIXEL_9_PRO_FOLD // androidx.compose.ui.tooling.preview/Devices.PIXEL_9_PRO_FOLD|{}PIXEL_9_PRO_FOLD[0]
|
||||
final fun <get-PIXEL_9_PRO_FOLD>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_9_PRO_FOLD.<get-PIXEL_9_PRO_FOLD>|<get-PIXEL_9_PRO_FOLD>(){}[0]
|
||||
final const val PIXEL_9_PRO_XL // androidx.compose.ui.tooling.preview/Devices.PIXEL_9_PRO_XL|{}PIXEL_9_PRO_XL[0]
|
||||
final fun <get-PIXEL_9_PRO_XL>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_9_PRO_XL.<get-PIXEL_9_PRO_XL>|<get-PIXEL_9_PRO_XL>(){}[0]
|
||||
final const val PIXEL_C // androidx.compose.ui.tooling.preview/Devices.PIXEL_C|{}PIXEL_C[0]
|
||||
final fun <get-PIXEL_C>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_C.<get-PIXEL_C>|<get-PIXEL_C>(){}[0]
|
||||
final const val PIXEL_FOLD // androidx.compose.ui.tooling.preview/Devices.PIXEL_FOLD|{}PIXEL_FOLD[0]
|
||||
final fun <get-PIXEL_FOLD>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_FOLD.<get-PIXEL_FOLD>|<get-PIXEL_FOLD>(){}[0]
|
||||
final const val PIXEL_TABLET // androidx.compose.ui.tooling.preview/Devices.PIXEL_TABLET|{}PIXEL_TABLET[0]
|
||||
final fun <get-PIXEL_TABLET>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_TABLET.<get-PIXEL_TABLET>|<get-PIXEL_TABLET>(){}[0]
|
||||
final const val PIXEL_XL // androidx.compose.ui.tooling.preview/Devices.PIXEL_XL|{}PIXEL_XL[0]
|
||||
final fun <get-PIXEL_XL>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.PIXEL_XL.<get-PIXEL_XL>|<get-PIXEL_XL>(){}[0]
|
||||
final const val TABLET // androidx.compose.ui.tooling.preview/Devices.TABLET|{}TABLET[0]
|
||||
final fun <get-TABLET>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.TABLET.<get-TABLET>|<get-TABLET>(){}[0]
|
||||
final const val TV_1080p // androidx.compose.ui.tooling.preview/Devices.TV_1080p|{}TV_1080p[0]
|
||||
final fun <get-TV_1080p>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.TV_1080p.<get-TV_1080p>|<get-TV_1080p>(){}[0]
|
||||
final const val TV_720p // androidx.compose.ui.tooling.preview/Devices.TV_720p|{}TV_720p[0]
|
||||
final fun <get-TV_720p>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.TV_720p.<get-TV_720p>|<get-TV_720p>(){}[0]
|
||||
final const val WEAR_OS_LARGE_ROUND // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_LARGE_ROUND|{}WEAR_OS_LARGE_ROUND[0]
|
||||
final fun <get-WEAR_OS_LARGE_ROUND>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_LARGE_ROUND.<get-WEAR_OS_LARGE_ROUND>|<get-WEAR_OS_LARGE_ROUND>(){}[0]
|
||||
final const val WEAR_OS_RECT // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_RECT|{}WEAR_OS_RECT[0]
|
||||
final fun <get-WEAR_OS_RECT>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_RECT.<get-WEAR_OS_RECT>|<get-WEAR_OS_RECT>(){}[0]
|
||||
final const val WEAR_OS_SMALL_ROUND // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_SMALL_ROUND|{}WEAR_OS_SMALL_ROUND[0]
|
||||
final fun <get-WEAR_OS_SMALL_ROUND>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_SMALL_ROUND.<get-WEAR_OS_SMALL_ROUND>|<get-WEAR_OS_SMALL_ROUND>(){}[0]
|
||||
final const val WEAR_OS_SQUARE // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_SQUARE|{}WEAR_OS_SQUARE[0]
|
||||
final fun <get-WEAR_OS_SQUARE>(): kotlin/String // androidx.compose.ui.tooling.preview/Devices.WEAR_OS_SQUARE.<get-WEAR_OS_SQUARE>|<get-WEAR_OS_SQUARE>(){}[0]
|
||||
}
|
||||
|
||||
final object androidx.compose.ui.tooling.preview/Wallpapers { // androidx.compose.ui.tooling.preview/Wallpapers|null[0]
|
||||
final const val BLUE_DOMINATED_EXAMPLE // androidx.compose.ui.tooling.preview/Wallpapers.BLUE_DOMINATED_EXAMPLE|{}BLUE_DOMINATED_EXAMPLE[0]
|
||||
final fun <get-BLUE_DOMINATED_EXAMPLE>(): kotlin/Int // androidx.compose.ui.tooling.preview/Wallpapers.BLUE_DOMINATED_EXAMPLE.<get-BLUE_DOMINATED_EXAMPLE>|<get-BLUE_DOMINATED_EXAMPLE>(){}[0]
|
||||
final const val GREEN_DOMINATED_EXAMPLE // androidx.compose.ui.tooling.preview/Wallpapers.GREEN_DOMINATED_EXAMPLE|{}GREEN_DOMINATED_EXAMPLE[0]
|
||||
final fun <get-GREEN_DOMINATED_EXAMPLE>(): kotlin/Int // androidx.compose.ui.tooling.preview/Wallpapers.GREEN_DOMINATED_EXAMPLE.<get-GREEN_DOMINATED_EXAMPLE>|<get-GREEN_DOMINATED_EXAMPLE>(){}[0]
|
||||
final const val NONE // androidx.compose.ui.tooling.preview/Wallpapers.NONE|{}NONE[0]
|
||||
final fun <get-NONE>(): kotlin/Int // androidx.compose.ui.tooling.preview/Wallpapers.NONE.<get-NONE>|<get-NONE>(){}[0]
|
||||
final const val RED_DOMINATED_EXAMPLE // androidx.compose.ui.tooling.preview/Wallpapers.RED_DOMINATED_EXAMPLE|{}RED_DOMINATED_EXAMPLE[0]
|
||||
final fun <get-RED_DOMINATED_EXAMPLE>(): kotlin/Int // androidx.compose.ui.tooling.preview/Wallpapers.RED_DOMINATED_EXAMPLE.<get-RED_DOMINATED_EXAMPLE>|<get-RED_DOMINATED_EXAMPLE>(){}[0]
|
||||
final const val YELLOW_DOMINATED_EXAMPLE // androidx.compose.ui.tooling.preview/Wallpapers.YELLOW_DOMINATED_EXAMPLE|{}YELLOW_DOMINATED_EXAMPLE[0]
|
||||
final fun <get-YELLOW_DOMINATED_EXAMPLE>(): kotlin/Int // androidx.compose.ui.tooling.preview/Wallpapers.YELLOW_DOMINATED_EXAMPLE.<get-YELLOW_DOMINATED_EXAMPLE>|<get-YELLOW_DOMINATED_EXAMPLE>(){}[0]
|
||||
}
|
||||
|
||||
final val androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_CollectionPreviewParameterProvider$stableprop // androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_CollectionPreviewParameterProvider$stableprop|#static{}androidx_compose_ui_tooling_preview_datasource_CollectionPreviewParameterProvider$stableprop[0]
|
||||
final val androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_LoremIpsum$stableprop // androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_LoremIpsum$stableprop|#static{}androidx_compose_ui_tooling_preview_datasource_LoremIpsum$stableprop[0]
|
||||
final val androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_AndroidUiModes$stableprop // androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_AndroidUiModes$stableprop|#static{}androidx_compose_ui_tooling_preview_AndroidUiModes$stableprop[0]
|
||||
final val androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Devices$stableprop // androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Devices$stableprop|#static{}androidx_compose_ui_tooling_preview_Devices$stableprop[0]
|
||||
final val androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Wallpapers$stableprop // androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Wallpapers$stableprop|#static{}androidx_compose_ui_tooling_preview_Wallpapers$stableprop[0]
|
||||
|
||||
final fun androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_CollectionPreviewParameterProvider$stableprop_getter(): kotlin/Int // androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_CollectionPreviewParameterProvider$stableprop_getter|androidx_compose_ui_tooling_preview_datasource_CollectionPreviewParameterProvider$stableprop_getter(){}[0]
|
||||
final fun androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_LoremIpsum$stableprop_getter(): kotlin/Int // androidx.compose.ui.tooling.preview.datasource/androidx_compose_ui_tooling_preview_datasource_LoremIpsum$stableprop_getter|androidx_compose_ui_tooling_preview_datasource_LoremIpsum$stableprop_getter(){}[0]
|
||||
final fun androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_AndroidUiModes$stableprop_getter(): kotlin/Int // androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_AndroidUiModes$stableprop_getter|androidx_compose_ui_tooling_preview_AndroidUiModes$stableprop_getter(){}[0]
|
||||
final fun androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Devices$stableprop_getter(): kotlin/Int // androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Devices$stableprop_getter|androidx_compose_ui_tooling_preview_Devices$stableprop_getter(){}[0]
|
||||
final fun androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Wallpapers$stableprop_getter(): kotlin/Int // androidx.compose.ui.tooling.preview/androidx_compose_ui_tooling_preview_Wallpapers$stableprop_getter|androidx_compose_ui_tooling_preview_Wallpapers$stableprop_getter(){}[0]
|
||||
@@ -0,0 +1,34 @@
|
||||
// :ui-tooling-preview — androidx.compose.ui.tooling.preview.* (the common
|
||||
// @Preview annotation + PreviewParameterProvider), vendored VERBATIM from
|
||||
// upstream (compose/ui/ui-tooling-preview). The Maven artifact ships no
|
||||
// mingwX64/linux klibs — apps declare the OFFICIAL coords in commonMain and
|
||||
// the root bridge substitutes this module on native configurations. The
|
||||
// annotations are IDE-only metadata: previews render through the app's JVM
|
||||
// target against upstream Compose Desktop.
|
||||
// Never hand-edit src/vendor/ — change the manifest + `python scripts/compose-fork/sync.py compose/ui/ui-tooling-preview`.
|
||||
// Publication artifactId: ui-tooling-preview.
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.plugin.compose)
|
||||
}
|
||||
|
||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||
|
||||
kotlin {
|
||||
linuxArm64(); linuxX64(); macosArm64(); if (vHostSupportsMingw) mingwX64()
|
||||
applyDefaultHierarchyTemplate()
|
||||
sourceSets {
|
||||
commonMain {
|
||||
dependencies {
|
||||
api("org.jetbrains.compose.runtime:runtime:${libs.versions.composeRuntime.get()}")
|
||||
}
|
||||
kotlin.srcDir("src/vendor/common/kotlin")
|
||||
}
|
||||
nativeMain { kotlin.srcDir("src/vendor/native/kotlin") }
|
||||
}
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
SET_REPO=https://github.com/JetBrains/compose-multiplatform-core@<COMPOSE_CORE_REF>
|
||||
SET_FOLDER=compose/ui/ui-tooling-preview/src
|
||||
|
||||
commonMain/kotlin/ -> src/vendor/common/kotlin/
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/AndroidUiMode.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/AndroidUiMode.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/Device.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/Device.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/MultiPreviews.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/MultiPreviews.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/Preview.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/Preview.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/PreviewParameter.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/PreviewParameter.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/PreviewWrapper.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/PreviewWrapper.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/Wallpaper.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/Wallpaper.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/datasource/CollectionPreviewParameterProvider.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/datasource/CollectionPreviewParameterProvider.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/datasource/LoremIpsum.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/datasource/LoremIpsum.kt
|
||||
# | commonMain/kotlin/androidx/compose/ui/tooling/preview/internal/JvmDefaultWithCompatibility.kt -> src/vendor/common/kotlin/androidx/compose/ui/tooling/preview/internal/JvmDefaultWithCompatibility.kt
|
||||
|
||||
nonJvmMain/kotlin/androidx/compose/ui/tooling/preview/internal/JvmDefaultWithCompatibility.nonJvm.kt -> src/vendor/native/kotlin/androidx/compose/ui/tooling/preview/internal/JvmDefaultWithCompatibility.native.kt
|
||||
|
||||
# >>> ---- DIAGNOSTIC GAPS ---- <<<<
|
||||
# desktopMain (1)
|
||||
# | desktopMain/kotlin/androidx/compose/desktop/ui/tooling/preview/Preview.kt -> src/vendor/desktop/kotlin/androidx/compose/desktop/ui/tooling/preview/Preview.kt
|
||||
# jvmAndAndroidMain (1)
|
||||
# | jvmAndAndroidMain/kotlin/androidx/compose/ui/tooling/preview/internal/JvmDefaultWithCompatibility.jvmAndAndroid.kt -> src/vendor/jvmAndAndroid/kotlin/androidx/compose/ui/tooling/preview/internal/JvmDefaultWithCompatibility.jvmAndAndroid.kt
|
||||
# <<< ---- DIAGNOSTIC GAPS ---- >>>>
|
||||
@@ -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,97 +0,0 @@
|
||||
package com.compose.sdl.element
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
|
||||
// ==================
|
||||
// MARK: Project modifier elements
|
||||
// ==================
|
||||
// Small set of project-only modifier elements — each pairs a
|
||||
// `ModifierNodeElement<XxxNode>` with a `XxxNode : Modifier.Node`. Upstream
|
||||
// LayoutNode reads them through the chain (ModifierNodeElement IS-A
|
||||
// Modifier.Element). Everything gesture-related has been removed — pointer
|
||||
// input, text input, right/middle click, drag: all go through the standard
|
||||
// `Modifier.pointerInput` + `detectTapGestures` / `detectDragGestures` /
|
||||
// `awaitPointerEventScope` APIs upstream now supplies. What's left is the
|
||||
// two elements the renderer still owns directly:
|
||||
// * ClipModifier — GraphicsLayer.kt lowers `clip = true` to it.
|
||||
// * GraphicsLayerModifier — the transform / alpha / cache pipeline.
|
||||
|
||||
class ClipModifier(val shape: Shape) : ModifierNodeElement<ClipNode>() {
|
||||
override fun create() = ClipNode(shape)
|
||||
override fun update(node: ClipNode) { node.shape = shape }
|
||||
override fun hashCode(): Int = shape.hashCode()
|
||||
override fun equals(other: Any?): Boolean = other is ClipModifier && other.shape == shape
|
||||
}
|
||||
class ClipNode(var shape: Shape) : Modifier.Node()
|
||||
|
||||
// ==================
|
||||
// MARK: GraphicsLayerModifier
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* A "graphics layer" element: alpha + 2D transform (scale / rotation /
|
||||
* translation), with an optional cacheKey that opts the subtree into
|
||||
* render-to-texture caching across frames. See `Modifier.graphicsLayer`
|
||||
* (in `androidx.compose.ui.graphics`) for the caching semantics.
|
||||
*
|
||||
* The renderer reads this element directly via the `LayoutNode.graphicsLayer`
|
||||
* `foldIn` over the chain; the paired [GraphicsLayerNode] lifecycle stays
|
||||
* dormant until the renderer rewrite drives it.
|
||||
*/
|
||||
class GraphicsLayerModifier(
|
||||
val alpha: Float = 1f,
|
||||
val scaleX: Float = 1f,
|
||||
val scaleY: Float = 1f,
|
||||
val rotationZ: Float = 0f,
|
||||
val translationX: Float = 0f,
|
||||
val translationY: Float = 0f,
|
||||
val transformOrigin: TransformOrigin = TransformOrigin.Center,
|
||||
val cacheKey: Any? = null,
|
||||
) : ModifierNodeElement<GraphicsLayerNode>() {
|
||||
|
||||
val needsLayer: Boolean
|
||||
get() = alpha < 1f || cacheKey != null
|
||||
|
||||
val needsTransform: Boolean
|
||||
get() = scaleX != 1f || scaleY != 1f || rotationZ != 0f ||
|
||||
translationX != 0f || translationY != 0f
|
||||
|
||||
val isIdentity: Boolean
|
||||
get() = !needsLayer && !needsTransform
|
||||
|
||||
override fun create() =
|
||||
GraphicsLayerNode(alpha, scaleX, scaleY, rotationZ, translationX, translationY, transformOrigin, cacheKey)
|
||||
override fun update(node: GraphicsLayerNode) {
|
||||
node.alpha = alpha; node.scaleX = scaleX; node.scaleY = scaleY
|
||||
node.rotationZ = rotationZ; node.translationX = translationX; node.translationY = translationY
|
||||
node.transformOrigin = transformOrigin; node.cacheKey = cacheKey
|
||||
}
|
||||
override fun hashCode(): Int {
|
||||
var v = alpha.hashCode()
|
||||
v = 31 * v + scaleX.hashCode(); v = 31 * v + scaleY.hashCode()
|
||||
v = 31 * v + rotationZ.hashCode()
|
||||
v = 31 * v + translationX.hashCode(); v = 31 * v + translationY.hashCode()
|
||||
v = 31 * v + transformOrigin.hashCode(); v = 31 * v + (cacheKey?.hashCode() ?: 0)
|
||||
return v
|
||||
}
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is GraphicsLayerModifier &&
|
||||
other.alpha == alpha && other.scaleX == scaleX && other.scaleY == scaleY &&
|
||||
other.rotationZ == rotationZ &&
|
||||
other.translationX == translationX && other.translationY == translationY &&
|
||||
other.transformOrigin == transformOrigin && other.cacheKey == cacheKey
|
||||
}
|
||||
|
||||
class GraphicsLayerNode(
|
||||
var alpha: Float,
|
||||
var scaleX: Float,
|
||||
var scaleY: Float,
|
||||
var rotationZ: Float,
|
||||
var translationX: Float,
|
||||
var translationY: Float,
|
||||
var transformOrigin: TransformOrigin,
|
||||
var cacheKey: Any?,
|
||||
) : Modifier.Node()
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,13 @@ linkerOpts.osx = \
|
||||
-framework QuartzCore -weak_framework CoreHaptics \
|
||||
-lpthread -lm
|
||||
|
||||
linkerOpts.linux_x64 = -lpthread -ldl -lm -lrt
|
||||
linkerOpts.linux_arm64 = -lpthread -ldl -lm -lrt
|
||||
# The -L multi-arch dirs + fontconfig/GL/X11 serve the SKIA renderer (font
|
||||
# matching, GL backend, windowing) — K/N's LLD sysroot doesn't include the
|
||||
# host's multi-arch dir, and carrying them here means apps (including bridge
|
||||
# consumers) need NO linker flags of their own. Unused under -Prenderer=sdl3;
|
||||
# the extra -l is harmless, and LLD skips missing -L dirs.
|
||||
linkerOpts.linux_x64 = -L/usr/lib/x86_64-linux-gnu -lpthread -ldl -lm -lrt -lfontconfig -lGL -lX11
|
||||
linkerOpts.linux_arm64 = -L/usr/lib/aarch64-linux-gnu -lpthread -ldl -lm -lrt -lfontconfig -lGL -lX11
|
||||
|
||||
linkerOpts.mingw_x64 = \
|
||||
-lm -lkernel32 -luser32 -lgdi32 -lwinmm -limm32 \
|
||||
|
||||
-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()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.compose.sdl
|
||||
|
||||
// ==================
|
||||
// MARK: Renderer entry points (expect)
|
||||
// ==================
|
||||
|
||||
/* The renderer seam :window builds against. Each renderer source set
|
||||
(skikoRendererMain / sdlRendererMain) supplies the actuals; exactly one of
|
||||
the two is attached to any given target, so resolution stays unambiguous.
|
||||
|
||||
These were originally declared identically in both renderer source sets
|
||||
with NO expect — fine per-target, but shared nativeMain METADATA (=: what
|
||||
:window's KotlinMultiplatform publication compiles against) could not see
|
||||
them on a host where the attached targets span BOTH renderers (Windows:
|
||||
skikoRenderer for macos/linux + sdlRenderer for mingw). The expect makes
|
||||
nativeMain metadata self-contained, which lets the WINDOWS publish job —
|
||||
the only host that declares every target — produce the root modules. */
|
||||
|
||||
/* Create the render backend for the selected [GpuMode]; null when the
|
||||
backend can't initialise (caller falls back / reports). */
|
||||
expect fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend?
|
||||
|
||||
/* The renderer module's per-OS default GpuMode (used for GpuMode.Auto). */
|
||||
expect fun rendererPreferredGpuMode(): GpuMode
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
// ==================
|
||||
// MARK: DrawStats — per-frame renderer work counters (diagnostic)
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* Cheap per-frame counters the SDL draw path bumps and the frame profiler
|
||||
* reads, to answer "what inside `draw` costs the time?" — geometry submissions
|
||||
* (SDL_RenderGeometry calls), total vertices tessellated, rounded-clip mask
|
||||
* realizations (offscreen render-target passes — the expensive ones), text
|
||||
* blits and image blits. Reset each frame by the profiler after reading.
|
||||
*
|
||||
* Single-threaded (all draw is on the main thread), so plain vars are fine.
|
||||
* Zero overhead when the profiler isn't reading — just integer increments.
|
||||
*/
|
||||
object DrawStats {
|
||||
var geometrySubmits: Int = 0
|
||||
var vertices: Int = 0
|
||||
var maskRealizations: Int = 0
|
||||
var textDraws: Int = 0
|
||||
var imageBlits: Int = 0
|
||||
|
||||
fun reset() {
|
||||
geometrySubmits = 0
|
||||
vertices = 0
|
||||
maskRealizations = 0
|
||||
textDraws = 0
|
||||
imageBlits = 0
|
||||
}
|
||||
|
||||
fun summary(): String =
|
||||
"geo=$geometrySubmits verts=$vertices masks=$maskRealizations text=$textDraws img=$imageBlits"
|
||||
}
|
||||
+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
|
||||
}
|
||||
@@ -7,18 +7,22 @@ import androidx.compose.ui.graphics.ImageBitmap
|
||||
// ==================
|
||||
|
||||
/* Decodes an ENCODED image (png / jpg / webp / bmp / gif / svg bytes) into a
|
||||
drawable [ImageBitmap]. The active renderer registers its implementation —
|
||||
the SDL backend goes through SDL3_image (see Sdl3EncodedImageDecoder); the
|
||||
Skia renderer never registers because its resources pipeline decodes through
|
||||
Skia directly (vendored skikoRenderer actuals in :components-resources).
|
||||
drawable [ImageBitmap]. The active renderer registers its implementation at
|
||||
backend construction — the SDL backend goes through SDL3_image
|
||||
(Sdl3EncodedImageDecoder), the Skia backend through Image.makeFromEncoded /
|
||||
SVGDOM (SkiaEncodedImageDecoder). :components-resources' actuals share this
|
||||
hook under BOTH renderers (its skikoRendererMain reuses the SDL actuals).
|
||||
|
||||
Consumed by :components-resources' SDL actuals (`ByteArray.toImageBitmap`,
|
||||
`SvgElement.toSvgPainter`) so painterResource(Res.drawable.x) works without
|
||||
that module reaching into renderer internals. */
|
||||
Consumed by `ByteArray.toImageBitmap` / `SvgElement.toSvgPainter` so
|
||||
painterResource(Res.drawable.x) works without that module reaching into
|
||||
renderer internals. */
|
||||
interface EncodedImageDecoder {
|
||||
fun decode(inBytes: ByteArray): ImageBitmap?
|
||||
}
|
||||
|
||||
/* Volatile: written by the render backend on the main thread, read by the
|
||||
resources pipeline on Dispatchers.Default workers. */
|
||||
@kotlin.concurrent.Volatile
|
||||
var encodedImageDecoder: EncodedImageDecoder? = null
|
||||
internal set
|
||||
|
||||
|
||||
+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
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.compose.sdl.graphics
|
||||
|
||||
import kotlinx.atomicfu.locks.SynchronizedObject
|
||||
import kotlinx.atomicfu.locks.synchronized
|
||||
|
||||
// ==================
|
||||
// MARK: NativeReleaseQueue — main-thread-deferred native resource disposal
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* A queue of native-resource release actions drained on the MAIN THREAD once
|
||||
* per frame by the window loop (see ComposeWindow). It exists because the two
|
||||
* ways a renderer resource dies are both off the main thread, while the calls
|
||||
* that free them (SDL_DestroyTexture, and any renderer API) are main-thread-only:
|
||||
*
|
||||
* - a Kotlin/Native `Cleaner` runs on a GC worker thread, and
|
||||
* - explicit `close()` can be reached from composition/effect code on other
|
||||
* dispatchers.
|
||||
*
|
||||
* Both simply ENQUEUE here; the main loop runs the actions between frames. This
|
||||
* turns the GC (issue #2: Cleaner-managed native memory starving because the
|
||||
* quiet Kotlin heap rarely triggers a collection) from the disposal MECHANISM
|
||||
* into a mere safeguard — ownership closes resources promptly, the Cleaner is
|
||||
* the backstop for whatever leaks a `close()`.
|
||||
*
|
||||
* Actions must be idempotent-safe at the source (guard against a resource being
|
||||
* both explicitly closed and later GC-cleaned — see SdlImageBitmap's holder).
|
||||
*/
|
||||
object NativeReleaseQueue {
|
||||
|
||||
private val fLock = SynchronizedObject()
|
||||
private var fPending = ArrayList<() -> Unit>()
|
||||
|
||||
/** Enqueue a release action. Safe to call from any thread. */
|
||||
fun enqueue(action: () -> Unit) {
|
||||
synchronized(fLock) { fPending.add(action) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run and clear every queued action. MAIN THREAD ONLY — the actions call
|
||||
* renderer APIs that aren't thread-safe. Returns the number drained.
|
||||
*/
|
||||
fun drain(): Int {
|
||||
val vBatch = synchronized(fLock) {
|
||||
if (fPending.isEmpty()) return 0
|
||||
val vTaken = fPending
|
||||
fPending = ArrayList()
|
||||
vTaken
|
||||
}
|
||||
for (vAction in vBatch) {
|
||||
runCatching { vAction() }
|
||||
}
|
||||
return vBatch.size
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import kotlin.experimental.ExperimentalNativeApi
|
||||
// MARK: SDL3 renderer per-OS default
|
||||
// ==================
|
||||
|
||||
fun rendererPreferredGpuMode(): GpuMode {
|
||||
actual fun rendererPreferredGpuMode(): GpuMode {
|
||||
@OptIn(ExperimentalNativeApi::class)
|
||||
return when (Platform.osFamily) {
|
||||
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.sdl.Sdl3RenderBackend
|
||||
renderer modules expose createRenderBackend / rendererPreferredGpuMode
|
||||
with identical signatures in this package, and the build includes exactly
|
||||
one of them per target. Rejects Skia.* since this module has no Skiko. */
|
||||
fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
||||
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
||||
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||
if (vResolved is GpuMode.Skia) {
|
||||
error("$vResolved isn't available in this build — Skiko isn't linked. " +
|
||||
|
||||
+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)
|
||||
}
|
||||
+443
-37
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -19,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
|
||||
@@ -26,6 +28,7 @@ import com.compose.sdl.icons.IconFont
|
||||
import kotlinx.cinterop.*
|
||||
import sdl3.*
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -69,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.
|
||||
@@ -126,7 +199,7 @@ internal class Sdl3Canvas(
|
||||
// offscreen clip opened inside this save frame (see fClipLayers below).
|
||||
private data class State(
|
||||
val a: Float, val b: Float, val c: Float, val d: Float, val e: Float, val f: Float,
|
||||
val clip: IntArray?, val alpha: Float, val clipLayers: Int,
|
||||
val clip: IntArray?, val alpha: Float, val clipLayers: Int, val pendingClips: Int,
|
||||
)
|
||||
private val fStack = ArrayDeque<State>()
|
||||
|
||||
@@ -146,9 +219,139 @@ internal class Sdl3Canvas(
|
||||
)
|
||||
private val fClipLayers = ArrayDeque<OffscreenClip>()
|
||||
|
||||
// ============
|
||||
// Lazy rounded clips. Opening an offscreen mask per clip is the SINGLE
|
||||
// most expensive thing this canvas does (two render-target switches +
|
||||
// clear + corner cut + feather + composite) - and the dominant Compose
|
||||
// idiom `Modifier.clip(CircleShape).background(...)` never needs one:
|
||||
// the background is a full-cover rect drawable AS the rounded shape, and
|
||||
// content wholly inside the rounded region needs no mask at all. So
|
||||
// clipRoundRect only RECORDS the clip; each draw either proves itself
|
||||
// safe (containment / cover) or realizes the offscreen on demand.
|
||||
|
||||
private class PendingRoundClip(
|
||||
val deviceRound: RoundRect, // device-space outline (clamped radii)
|
||||
val localRound: RoundRect, // as passed in (for cover -> shape-fill emission)
|
||||
val region: IntArray, // fClip intersect bbox at push time
|
||||
val bbox: IntArray, // device bbox of the outline
|
||||
val prevClip: IntArray?, // fClip before this clip was pushed
|
||||
// Affine snapshot at push - cover -> fill re-emits in LOCAL space and
|
||||
// must bail if the transform moved since.
|
||||
val ma: Float, val mb: Float, val mc: Float, val md: Float, val me: Float, val mf: Float,
|
||||
// fStack.size when the clip was pushed: a lazily-realized mask must be
|
||||
// composited by the save-frame that OWNS the clip, not by whatever
|
||||
// inner frame happened to trigger realization (a press draws the state
|
||||
// layer and the ripple in separate inner clipRect{} frames - the first
|
||||
// realize must not let the inner restore close the mask, or the second
|
||||
// draw runs unmasked: the "square on click" artifact).
|
||||
val saveDepth: Int,
|
||||
)
|
||||
private val fPendingClips = ArrayDeque<PendingRoundClip>()
|
||||
|
||||
/* Point-in-round-rect (device space) - inside the rect and, within a
|
||||
corner cut square, inside its ellipse. */
|
||||
private fun roundRectContains(inRR: RoundRect, inX: Float, inY: Float): Boolean {
|
||||
if (inX < inRR.left || inX > inRR.right || inY < inRR.top || inY > inRR.bottom) return false
|
||||
fun corner(inRx: Float, inRy: Float, inDx: Float, inDy: Float): Boolean {
|
||||
if (inRx <= 0f || inRy <= 0f) return true
|
||||
val vNx = inDx / inRx; val vNy = inDy / inRy
|
||||
return vNx * vNx + vNy * vNy <= 1f
|
||||
}
|
||||
val vTl = inRR.topLeftCornerRadius; val vTr = inRR.topRightCornerRadius
|
||||
val vBr = inRR.bottomRightCornerRadius; val vBl = inRR.bottomLeftCornerRadius
|
||||
if (inX < inRR.left + vTl.x && inY < inRR.top + vTl.y)
|
||||
return corner(vTl.x, vTl.y, inX - (inRR.left + vTl.x), inY - (inRR.top + vTl.y))
|
||||
if (inX > inRR.right - vTr.x && inY < inRR.top + vTr.y)
|
||||
return corner(vTr.x, vTr.y, inX - (inRR.right - vTr.x), inY - (inRR.top + vTr.y))
|
||||
if (inX > inRR.right - vBr.x && inY > inRR.bottom - vBr.y)
|
||||
return corner(vBr.x, vBr.y, inX - (inRR.right - vBr.x), inY - (inRR.bottom - vBr.y))
|
||||
if (inX < inRR.left + vBl.x && inY > inRR.bottom - vBl.y)
|
||||
return corner(vBl.x, vBl.y, inX - (inRR.left + vBl.x), inY - (inRR.bottom - vBl.y))
|
||||
return true
|
||||
}
|
||||
|
||||
/* A device-space aabb is inside the round rect iff its four corners are. */
|
||||
private fun roundRectContainsAabb(inRR: RoundRect, inL: Float, inT: Float, inR: Float, inB: Float): Boolean =
|
||||
roundRectContains(inRR, inL, inT) && roundRectContains(inRR, inR, inT) &&
|
||||
roundRectContains(inRR, inR, inB) && roundRectContains(inRR, inL, inB)
|
||||
|
||||
/* Device aabb of a LOCAL rect under the current affine. */
|
||||
private fun deviceAabb(inL: Float, inT: Float, inR: Float, inB: Float): FloatArray {
|
||||
val vX0 = mapX(inL, inT); val vX1 = mapX(inR, inT); val vX2 = mapX(inR, inB); val vX3 = mapX(inL, inB)
|
||||
val vY0 = mapY(inL, inT); val vY1 = mapY(inR, inT); val vY2 = mapY(inR, inB); val vY3 = mapY(inL, inB)
|
||||
return floatArrayOf(
|
||||
minOf(vX0, vX1, vX2, vX3), minOf(vY0, vY1, vY2, vY3),
|
||||
maxOf(vX0, vX1, vX2, vX3), maxOf(vY0, vY1, vY2, vY3),
|
||||
)
|
||||
}
|
||||
|
||||
/* If the LOCAL-space aabb stays inside every pending rounded clip, drawing
|
||||
it under the already-active rect clip is correct - no mask needed.
|
||||
Otherwise realize the pending masks before the draw proceeds. */
|
||||
private fun admitDraw(inL: Float, inT: Float, inR: Float, inB: Float) {
|
||||
if (fPendingClips.isEmpty()) return
|
||||
val vBox = deviceAabb(inL, inT, inR, inB)
|
||||
for (vPending in fPendingClips) {
|
||||
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
|
||||
realizePendingClips()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Convert pending clips into real offscreen mask layers (outermost first).
|
||||
|
||||
Each clip realizes with its OWN push-time region (pending.region =
|
||||
enclosing-clip-at-push ∩ bbox), NOT the current fClip: a lazily-realized
|
||||
clip may fire arbitrarily deep inside descendants whose own clips have
|
||||
since narrowed fClip. Re-intersecting with that narrow clip shrank an
|
||||
outer clip's offscreen to the innermost descendant's rect, so its
|
||||
composite blitted back only that sliver and every sibling drawn into the
|
||||
same offscreen was discarded (the TLS-chain regression: only the first
|
||||
card + nothing below it, until a scroll shifted which card triggered
|
||||
realization). The real (already-narrowed) drawing clip is preserved
|
||||
across the loop and restored at the end, so subsequent draws into the
|
||||
innermost target stay correctly clipped. */
|
||||
private fun realizePendingClips() {
|
||||
if (fPendingClips.isEmpty()) return
|
||||
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
||||
val vDrawClip = fClip
|
||||
while (fPendingClips.isNotEmpty()) {
|
||||
val vPending = fPendingClips.removeFirst()
|
||||
fScope.flush()
|
||||
val vRegion = vPending.region
|
||||
val vTarget = fClipTargets?.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
|
||||
if (vTarget == null || vRegion[2] <= vRegion[0] || vRegion[3] <= vRegion[1]) {
|
||||
// Degrade to the rect clip that is already active.
|
||||
continue
|
||||
}
|
||||
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
|
||||
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
|
||||
fClip = vRegion
|
||||
applyClip()
|
||||
clearRegion(vRegion)
|
||||
com.compose.sdl.graphics.DrawStats.maskRealizations++
|
||||
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
|
||||
// Attribute the realized mask to the save-frame that pushed the
|
||||
// clip: inner frames entered since must NOT composite it on their
|
||||
// restore - bump their thresholds past the new layer.
|
||||
for (vI in vPending.saveDepth until fStack.size) {
|
||||
val vState = fStack[vI]
|
||||
if (vState.clipLayers < fClipLayers.size) {
|
||||
fStack[vI] = vState.copy(clipLayers = fClipLayers.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Restore the real drawing clip (already narrowed by inner clipRects /
|
||||
// scroll bounds) for draws into the now-active innermost target.
|
||||
fClip = vDrawClip
|
||||
applyClip()
|
||||
}
|
||||
|
||||
// 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
|
||||
// at a scratch target when the frame is presented.
|
||||
@@ -166,11 +369,14 @@ internal class Sdl3Canvas(
|
||||
// Offscreen canvas: the outermost save (CanvasDrawScope brackets its draw with
|
||||
// save/restore) switches the SDL render target to the ImageBitmap's texture.
|
||||
if (fOffscreenTexture != null && fStack.isEmpty()) beginOffscreen()
|
||||
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size))
|
||||
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size, fPendingClips.size))
|
||||
}
|
||||
|
||||
override fun restore() {
|
||||
val vPrev = fStack.removeLastOrNull() ?: return
|
||||
// Pending (never-realized) rounded clips opened inside this save frame:
|
||||
// nothing was drawn through an offscreen for them - just drop them.
|
||||
while (fPendingClips.size > vPrev.pendingClips) fPendingClips.removeLast()
|
||||
// Composite + pop any offscreen rounded clips opened inside this save frame
|
||||
// before restoring the plain state (each composite restores render target
|
||||
// and SDL clip to what it was when the clip was opened).
|
||||
@@ -208,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)
|
||||
}
|
||||
@@ -220,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
|
||||
@@ -251,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
|
||||
@@ -267,6 +483,7 @@ internal class Sdl3Canvas(
|
||||
// treating Difference as intersect clipped the border TO the notch instead
|
||||
// (a floating line under the label, no outline anywhere else).
|
||||
private fun clipRectDifference(inL: Float, inT: Float, inR: Float, inB: Float) {
|
||||
realizePendingClips()
|
||||
val vDiff = mapRectAABB(inL, inT, inR, inB)
|
||||
val vRegion = fClip ?: intArrayOf(0, 0, fSize.width.toInt(), fSize.height.toInt())
|
||||
if (fClipTargets == null || fSize.width < 1f || fSize.height < 1f ||
|
||||
@@ -290,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
|
||||
@@ -330,11 +548,20 @@ 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
|
||||
// with oversized corners and degenerates toward a lozenge (seen as
|
||||
// diamond-shaped "popped" bubbles under scale 0.55). Column norms
|
||||
// approximate per-axis scale; rotation already degrades to AABBs here.
|
||||
val vScaleX = sqrt(fMa * fMa + fMb * fMb)
|
||||
val vScaleY = sqrt(fMc * fMc + fMd * fMd)
|
||||
val vMaxRadius = maxOf(
|
||||
inRoundRect.topLeftCornerRadius.x, inRoundRect.topLeftCornerRadius.y,
|
||||
inRoundRect.topRightCornerRadius.x, inRoundRect.topRightCornerRadius.y,
|
||||
inRoundRect.bottomRightCornerRadius.x, inRoundRect.bottomRightCornerRadius.y,
|
||||
inRoundRect.bottomLeftCornerRadius.x, inRoundRect.bottomLeftCornerRadius.y,
|
||||
inRoundRect.topLeftCornerRadius.x * vScaleX, inRoundRect.topLeftCornerRadius.y * vScaleY,
|
||||
inRoundRect.topRightCornerRadius.x * vScaleX, inRoundRect.topRightCornerRadius.y * vScaleY,
|
||||
inRoundRect.bottomRightCornerRadius.x * vScaleX, inRoundRect.bottomRightCornerRadius.y * vScaleY,
|
||||
inRoundRect.bottomLeftCornerRadius.x * vScaleX, inRoundRect.bottomLeftCornerRadius.y * vScaleY,
|
||||
)
|
||||
// Effectively-square corners or no offscreen pool → plain rectangular clip.
|
||||
if (vMaxRadius < 0.5f || fClipTargets == null || fSize.width < 1f || fSize.height < 1f) {
|
||||
@@ -343,6 +570,20 @@ internal class Sdl3Canvas(
|
||||
}
|
||||
fScope.flush()
|
||||
val vBbox = mapRectAABB(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
|
||||
// Clamp to the DEVICE half-box: the bbox is int-truncated, so a scaled
|
||||
// radius can exceed it by a fraction — opposite corner cuts then overlap
|
||||
// and leave flat spots at N/E/S/W (a circle read as an octagon).
|
||||
val vHalfW = (vBbox[2] - vBbox[0]) / 2f
|
||||
val vHalfH = (vBbox[3] - vBbox[1]) / 2f
|
||||
fun deviceRadius(inR: CornerRadius) =
|
||||
CornerRadius(minOf(inR.x * vScaleX, vHalfW), minOf(inR.y * vScaleY, vHalfH))
|
||||
val vDeviceRound = RoundRect(
|
||||
vBbox[0].toFloat(), vBbox[1].toFloat(), vBbox[2].toFloat(), vBbox[3].toFloat(),
|
||||
topLeftCornerRadius = deviceRadius(inRoundRect.topLeftCornerRadius),
|
||||
topRightCornerRadius = deviceRadius(inRoundRect.topRightCornerRadius),
|
||||
bottomRightCornerRadius = deviceRadius(inRoundRect.bottomRightCornerRadius),
|
||||
bottomLeftCornerRadius = deviceRadius(inRoundRect.bottomLeftCornerRadius),
|
||||
)
|
||||
val vRegion = intersect(fClip, vBbox)
|
||||
// Nothing visible: keep behaviour of a normal clip (cull) without an
|
||||
// offscreen pass. The enclosing save/restore restores the clip afterwards.
|
||||
@@ -351,20 +592,18 @@ internal class Sdl3Canvas(
|
||||
applyClip()
|
||||
return
|
||||
}
|
||||
val vTarget = fClipTargets.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
|
||||
if (vTarget == null) {
|
||||
// Couldn't allocate a scratch target — degrade to a rectangular clip.
|
||||
clipRect(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
|
||||
return
|
||||
}
|
||||
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
||||
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
|
||||
// LAZY: record only — the rect part of the clip applies immediately,
|
||||
// the offscreen mask is opened by the first draw that actually needs
|
||||
// it (admitDraw / realizePendingClips). The common clip+background
|
||||
// idiom never does.
|
||||
val vPrevClip = fClip
|
||||
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
|
||||
fClip = vRegion
|
||||
applyClip()
|
||||
clearRegion(vRegion)
|
||||
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPrevClip, vRegion, vBbox, inRoundRect))
|
||||
fPendingClips.addLast(PendingRoundClip(
|
||||
vDeviceRound, inRoundRect, vRegion, vBbox, vPrevClip,
|
||||
fMa, fMb, fMc, fMd, fMe, fMf,
|
||||
fStack.size,
|
||||
))
|
||||
}
|
||||
|
||||
// Pops the top offscreen clip: flush its subtree, cut the rounded corners out
|
||||
@@ -427,22 +666,24 @@ internal class Sdl3Canvas(
|
||||
private fun zeroRoundRectCorners(inBbox: IntArray, inRoundRect: RoundRect) {
|
||||
val vLeft = inBbox[0].toFloat(); val vTop = inBbox[1].toFloat()
|
||||
val vRight = inBbox[2].toFloat(); val vBottom = inBbox[3].toFloat()
|
||||
val vSeg = 12
|
||||
val vTl = inRoundRect.topLeftCornerRadius
|
||||
val vTr = inRoundRect.topRightCornerRadius
|
||||
val vBr = inRoundRect.bottomRightCornerRadius
|
||||
val vBl = inRoundRect.bottomLeftCornerRadius
|
||||
// Upper bound: 4 corners × vSeg triangles × 3 vertices. Written straight
|
||||
// into the native SDL_Vertex buffer — the old ArrayList<Float> staging
|
||||
// boxed ~300 floats per clip layer per frame.
|
||||
fun cornerCount(inR: androidx.compose.ui.geometry.CornerRadius): Int =
|
||||
if (inR.x <= 0f || inR.y <= 0f) 0 else vSeg * 3
|
||||
val vMax = cornerCount(vTl) + cornerCount(vTr) + cornerCount(vBr) + cornerCount(vBl)
|
||||
if (vMax == 0) return
|
||||
// Segments per quarter arc ~ every 4 device px along the arc, matching
|
||||
// Sdl3DrawScope's density (~64/full circle at typical radii).
|
||||
val vMaxR = maxOf(vTl.x, vTl.y, vTr.x, vTr.y, vBr.x, vBr.y, vBl.x, vBl.y)
|
||||
val vSeg = ((vMaxR * 1.5708f) / 4f).toInt().coerceIn(8, 32)
|
||||
fun cornerCount(inR: CornerRadius): Int = if (inR.x <= 0f || inR.y <= 0f) 0 else vSeg * 3
|
||||
val vFanMax = cornerCount(vTl) + cornerCount(vTr) + cornerCount(vBr) + cornerCount(vBl)
|
||||
if (vFanMax == 0) return
|
||||
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
||||
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_NONE)
|
||||
memScoped {
|
||||
val vBuf = allocArray<SDL_Vertex>(vMax)
|
||||
// ============
|
||||
// Pass 1 — hard cut: zero everything outside each corner arc
|
||||
// (triangle fan from the corner apex, blend NONE writes RGBA 0).
|
||||
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_NONE)
|
||||
val vBuf = allocArray<SDL_Vertex>(vFanMax)
|
||||
var vIdx = 0
|
||||
fun put(inX: Float, inY: Float) {
|
||||
vBuf[vIdx].position.x = inX; vBuf[vIdx].position.y = inY
|
||||
@@ -470,6 +711,51 @@ internal class Sdl3Canvas(
|
||||
cutout(vRight, vBottom, vRight - vBr.x, vBottom - vBr.y, vBr.x, vBr.y, 0f)
|
||||
cutout(vLeft, vBottom, vLeft + vBl.x, vBottom - vBl.y, vBl.x, vBl.y, 90f)
|
||||
if (vIdx > 0) SDL_RenderGeometry(vRenderer, null, vBuf, vIdx, null, 0)
|
||||
|
||||
// ============
|
||||
// Pass 2 — feathered edge: a ~1.2px ring band straddling each arc
|
||||
// whose vertex alpha ramps 1 → 0 outward, drawn with a custom
|
||||
// "dst *= srcAlpha" blend. SDL_RenderGeometry has no antialiasing;
|
||||
// without this the mask boundary is a hard polygon edge while the
|
||||
// drawn shapes get Sdl3DrawScope's fringe AA (visibly rougher —
|
||||
// scaled-down circle clips read as low-poly).
|
||||
val vMul = SDL_ComposeCustomBlendMode(
|
||||
SDL_BLENDFACTOR_ZERO, SDL_BLENDFACTOR_SRC_ALPHA, SDL_BLENDOPERATION_ADD,
|
||||
SDL_BLENDFACTOR_ZERO, SDL_BLENDFACTOR_SRC_ALPHA, SDL_BLENDOPERATION_ADD,
|
||||
)
|
||||
SDL_SetRenderDrawBlendMode(vRenderer, vMul)
|
||||
val vFeather = 1.2f
|
||||
val vBandMax = 4 * vSeg * 6
|
||||
val vBand = allocArray<SDL_Vertex>(vBandMax)
|
||||
var vBIdx = 0
|
||||
fun putBand(inX: Float, inY: Float, inKeep: Float) {
|
||||
vBand[vBIdx].position.x = inX; vBand[vBIdx].position.y = inY
|
||||
vBand[vBIdx].color.r = 1f; vBand[vBIdx].color.g = 1f; vBand[vBIdx].color.b = 1f; vBand[vBIdx].color.a = inKeep
|
||||
vBand[vBIdx].tex_coord.x = 0f; vBand[vBIdx].tex_coord.y = 0f
|
||||
vBIdx++
|
||||
}
|
||||
fun feather(inCx: Float, inCy: Float, inRx: Float, inRy: Float, inStartDeg: Float) {
|
||||
if (inRx <= 1f || inRy <= 1f) return
|
||||
var vPrevInX = 0f; var vPrevInY = 0f; var vPrevOutX = 0f; var vPrevOutY = 0f
|
||||
for (vI in 0..vSeg) {
|
||||
val vAngle = ((inStartDeg + 90f * vI / vSeg) * (PI / 180.0)).toFloat()
|
||||
val vCos = cos(vAngle); val vSin = sin(vAngle)
|
||||
val vInX = inCx + (inRx - vFeather) * vCos
|
||||
val vInY = inCy + (inRy - vFeather) * vSin
|
||||
val vOutX = inCx + inRx * vCos
|
||||
val vOutY = inCy + inRy * vSin
|
||||
if (vI > 0) {
|
||||
putBand(vPrevInX, vPrevInY, 1f); putBand(vPrevOutX, vPrevOutY, 0f); putBand(vInX, vInY, 1f)
|
||||
putBand(vInX, vInY, 1f); putBand(vPrevOutX, vPrevOutY, 0f); putBand(vOutX, vOutY, 0f)
|
||||
}
|
||||
vPrevInX = vInX; vPrevInY = vInY; vPrevOutX = vOutX; vPrevOutY = vOutY
|
||||
}
|
||||
}
|
||||
feather(vLeft + vTl.x, vTop + vTl.y, vTl.x, vTl.y, 180f)
|
||||
feather(vRight - vTr.x, vTop + vTr.y, vTr.x, vTr.y, 270f)
|
||||
feather(vRight - vBr.x, vBottom - vBr.y, vBr.x, vBr.y, 0f)
|
||||
feather(vLeft + vBl.x, vBottom - vBl.y, vBl.x, vBl.y, 90f)
|
||||
if (vBIdx > 0) SDL_RenderGeometry(vRenderer, null, vBand, vBIdx, null, 0)
|
||||
}
|
||||
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_BLEND)
|
||||
}
|
||||
@@ -502,6 +788,8 @@ internal class Sdl3Canvas(
|
||||
inAmbientColor: androidx.compose.ui.graphics.Color,
|
||||
inSpotColor: androidx.compose.ui.graphics.Color,
|
||||
) {
|
||||
if (captureUnsupported()) return
|
||||
realizePendingClips()
|
||||
if (inElevationPx <= 0f) return
|
||||
|
||||
// Generic paths (CutCornerShape, GenericShape) don't 9-slice — blur the
|
||||
@@ -636,6 +924,7 @@ internal class Sdl3Canvas(
|
||||
// before re-rasterising a vector). Write transparent directly instead of the
|
||||
// paint colour.
|
||||
if (paint.blendMode == BlendMode.Clear) {
|
||||
realizePendingClips()
|
||||
fScope.flush()
|
||||
clearRegion(intArrayOf(
|
||||
mapX(left, top).toInt(), mapY(left, top).toInt(),
|
||||
@@ -643,21 +932,75 @@ internal class Sdl3Canvas(
|
||||
))
|
||||
return
|
||||
}
|
||||
if (fPendingClips.isNotEmpty() && tryDrawRectUnderPendingClips(left, top, right, bottom, paint)) return
|
||||
prep().rectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||
}
|
||||
|
||||
/* The `clip(shape).background(color)` idiom: a FILL rect that fully covers
|
||||
the innermost pending clip becomes a direct rounded-shape fill (with the
|
||||
scope's fringe AA — no offscreen mask at all). A rect fully INSIDE every
|
||||
pending clip draws as-is. Returns true when the draw has been emitted. */
|
||||
private fun tryDrawRectUnderPendingClips(inL: Float, inT: Float, inR: Float, inB: Float, inPaint: Paint): Boolean {
|
||||
val vBox = deviceAabb(inL, inT, inR, inB)
|
||||
val vInner = fPendingClips.last()
|
||||
// Cover case: fill paint, no stroke, rect covers the whole clip outline,
|
||||
// affine unchanged since the clip was pushed (local-space re-emission),
|
||||
// uniform corner radii (the scope's roundRectCore takes one radius), and
|
||||
// the clip outline itself contained in every OUTER pending clip.
|
||||
val vLr = vInner.localRound
|
||||
val vUniform = vLr.topLeftCornerRadius.x == vLr.topLeftCornerRadius.y &&
|
||||
vLr.topLeftCornerRadius == vLr.topRightCornerRadius &&
|
||||
vLr.topLeftCornerRadius == vLr.bottomRightCornerRadius &&
|
||||
vLr.topLeftCornerRadius == vLr.bottomLeftCornerRadius
|
||||
val vAffineUnchanged = vInner.ma == fMa && vInner.mb == fMb && vInner.mc == fMc &&
|
||||
vInner.md == fMd && vInner.me == fMe && vInner.mf == fMf
|
||||
if (inPaint.style == PaintingStyle.Fill && inPaint.blendMode == BlendMode.SrcOver &&
|
||||
vUniform && vAffineUnchanged &&
|
||||
vBox[0] <= vInner.bbox[0] && vBox[1] <= vInner.bbox[1] &&
|
||||
vBox[2] >= vInner.bbox[2] && vBox[3] >= vInner.bbox[3]
|
||||
) {
|
||||
var vOutersContain = true
|
||||
for (vPending in fPendingClips) {
|
||||
if (vPending === vInner) continue
|
||||
if (!roundRectContainsAabb(vPending.deviceRound, vInner.deviceRound.left, vInner.deviceRound.top, vInner.deviceRound.right, vInner.deviceRound.bottom)) {
|
||||
vOutersContain = false; break
|
||||
}
|
||||
}
|
||||
if (vOutersContain) {
|
||||
prep().roundRectCore(
|
||||
brushFor(inPaint),
|
||||
Offset(vLr.left, vLr.top), Size(vLr.width, vLr.height),
|
||||
vLr.topLeftCornerRadius.x,
|
||||
(inPaint.alpha * fAlpha), Fill,
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Containment: inside every pending outline → the active rect clip is enough.
|
||||
for (vPending in fPendingClips) {
|
||||
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
|
||||
realizePendingClips()
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun drawRoundRect(
|
||||
left: Float, top: Float, right: Float, bottom: Float,
|
||||
radiusX: Float, radiusY: Float, paint: Paint,
|
||||
) {
|
||||
admitDraw(left, top, right, bottom)
|
||||
prep().roundRectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), radiusX, (paint.alpha * fAlpha), styleFor(paint))
|
||||
}
|
||||
|
||||
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
||||
admitDraw(left, top, right, bottom)
|
||||
prep().ovalCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||
}
|
||||
|
||||
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
|
||||
admitDraw(center.x - radius, center.y - radius, center.x + radius, center.y + radius)
|
||||
prep().circleCore(brushFor(paint), radius, center, (paint.alpha * fAlpha), styleFor(paint))
|
||||
}
|
||||
|
||||
@@ -665,14 +1008,17 @@ internal class Sdl3Canvas(
|
||||
left: Float, top: Float, right: Float, bottom: Float,
|
||||
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
|
||||
) {
|
||||
realizePendingClips()
|
||||
prep().arcCore(brushFor(paint), startAngle, sweepAngle, useCenter, Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||
}
|
||||
|
||||
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
|
||||
realizePendingClips()
|
||||
prep().lineCore(brushFor(paint), p1, p2, paint.strokeWidth, paint.strokeCap, (paint.alpha * fAlpha))
|
||||
}
|
||||
|
||||
override fun drawPath(path: ComposePath, paint: Paint) {
|
||||
realizePendingClips()
|
||||
prep().pathCore(path, brushFor(paint), (paint.alpha * fAlpha), styleFor(paint))
|
||||
}
|
||||
|
||||
@@ -696,7 +1042,32 @@ 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
|
||||
// mask pass per text run.
|
||||
admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f)
|
||||
com.compose.sdl.graphics.DrawStats.textDraws++
|
||||
fScope.flush()
|
||||
// Paragraph-level decoration bits forwarded to every wrapped line.
|
||||
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
|
||||
@@ -712,6 +1083,16 @@ internal class Sdl3Canvas(
|
||||
// node's real box. The per-line path below centres within a lineHeight band
|
||||
// (1.2 em for Material Symbols), taller than the size-clamped icon node,
|
||||
// which pushed every icon ~0.1 em below centre.
|
||||
// Text is rasterised at its LOGICAL font size (stable glyph-texture cache
|
||||
// + stable hinting) and blit 1:1 at the affine-mapped pen position; the
|
||||
// layer's scale reaches position but NOT glyph size. Re-rasterising at a
|
||||
// per-frame device size (an earlier attempt) made text under an animated
|
||||
// layer scale — every Material popup/dropdown enter transition
|
||||
// (Menu.kt scaleX/scaleY) — step through integer sizes with visible
|
||||
// size/weight jumps, since SDL3_ttf re-hints at each size. Smoothly
|
||||
// scaling glyphs would need blit-scaling a logical-size rasterisation
|
||||
// (JVM/Skia GPU-scales the layer) — a future text-renderer enhancement;
|
||||
// until then logical-size + no glyph scale is the stable choice.
|
||||
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
|
||||
vTr.drawText(
|
||||
inText = inText,
|
||||
@@ -738,29 +1119,46 @@ 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
|
||||
vTr.drawText(
|
||||
inText = vLine,
|
||||
// Position maps through the affine (translate/scale reach the origin);
|
||||
// glyph scaling/rotation isn't wired, so text in a rotated/scaled layer
|
||||
// repositions but doesn't itself scale or rotate.
|
||||
// Position maps through the affine; size/boxes switch to DEVICE
|
||||
// units here (scaled by the affine's column norms) so glyphs
|
||||
// scale with the layer. Rotation still only repositions.
|
||||
inX = mapX(inX, vLineY).toInt(),
|
||||
inY = mapY(inX, vLineY).toInt(),
|
||||
inBoxWidth = inBoxWidth.toInt(),
|
||||
@@ -778,6 +1176,7 @@ internal class Sdl3Canvas(
|
||||
)
|
||||
vLineY += vLineH
|
||||
}
|
||||
fTextRenderer?.runSink = null
|
||||
}
|
||||
|
||||
// ============
|
||||
@@ -794,6 +1193,8 @@ 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
|
||||
// magnitudes (rotation contributes 1, so it only repositions the blit — the
|
||||
@@ -811,7 +1212,9 @@ 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(
|
||||
image,
|
||||
androidx.compose.ui.unit.IntOffset.Zero,
|
||||
@@ -834,7 +1237,10 @@ 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++
|
||||
// Commit pending frame geometry and re-assert this canvas's target + clip
|
||||
// (an offscreen render just borrowed the render target).
|
||||
fScope.flush()
|
||||
|
||||
+104
-16
@@ -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,
|
||||
@@ -141,6 +156,8 @@ internal class Sdl3DrawScope(
|
||||
)
|
||||
}
|
||||
SDL_RenderGeometry(fRenderer.reinterpret(), null, fBatch, fBatchCount, null, 0)
|
||||
com.compose.sdl.graphics.DrawStats.geometrySubmits++
|
||||
com.compose.sdl.graphics.DrawStats.vertices += fBatchCount
|
||||
fBatchCount = 0
|
||||
}
|
||||
|
||||
@@ -243,7 +260,10 @@ internal class Sdl3DrawScope(
|
||||
val vB = vT + size.height
|
||||
when (style) {
|
||||
Fill -> {
|
||||
emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
|
||||
// Gradient fills need interior samples (see emitRectMesh); solid
|
||||
// fills are a single flat quad.
|
||||
if (brush is SolidColor) emitQuad(vL, vT, vR, vT, vR, vB, vL, vB, vSampler)
|
||||
else emitRectMesh(vL, vT, vR, vB, vSampler)
|
||||
// Axis-aligned rects are pixel-crisp and need no AA. When the affine
|
||||
// rotates/shears them, the edges become diagonal — feather each edge
|
||||
// outward (normals in local space; the matrix orients them on screen).
|
||||
@@ -524,20 +544,30 @@ internal class Sdl3DrawScope(
|
||||
val vY = fOriginY + topLeft.y
|
||||
val vW = size.width
|
||||
val vH = size.height
|
||||
// Gradient fills sample per-vertex, so the interior must be meshed
|
||||
// (corner-only sampling renders radial/sweep flat — see emitRectMesh);
|
||||
// solid fills stay single quads.
|
||||
val vGrad = brush !is SolidColor
|
||||
if (vR <= 0f) {
|
||||
// Trivial: just two triangles.
|
||||
emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
|
||||
if (vGrad) emitRectMesh(vX, vY, vX + vW, vY + vH, vSampler)
|
||||
else emitQuad(vX, vY, vX + vW, vY, vX + vW, vY + vH, vX, vY + vH, vSampler)
|
||||
return
|
||||
}
|
||||
// Body in 3 strips: middle (full width × inner height), top edge,
|
||||
// bottom edge — plus the 4 corner arcs.
|
||||
if (style == Fill) {
|
||||
// Middle strip
|
||||
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler)
|
||||
// Top edge (between left+right corners)
|
||||
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler)
|
||||
// Bottom edge
|
||||
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler)
|
||||
if (vGrad) {
|
||||
emitRectMesh(vX, vY + vR, vX + vW, vY + vH - vR, vSampler) // middle
|
||||
emitRectMesh(vX + vR, vY, vX + vW - vR, vY + vR, vSampler) // top edge
|
||||
emitRectMesh(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH, vSampler) // bottom edge
|
||||
} else {
|
||||
// Middle strip
|
||||
emitQuad(vX, vY + vR, vX + vW, vY + vR, vX + vW, vY + vH - vR, vX, vY + vH - vR, vSampler)
|
||||
// Top edge (between left+right corners)
|
||||
emitQuad(vX + vR, vY, vX + vW - vR, vY, vX + vW - vR, vY + vR, vX + vR, vY + vR, vSampler)
|
||||
// Bottom edge
|
||||
emitQuad(vX + vR, vY + vH - vR, vX + vW - vR, vY + vH - vR, vX + vW - vR, vY + vH, vX + vR, vY + vH, vSampler)
|
||||
}
|
||||
// 4 corner fills — segment count adapts to the corner radius.
|
||||
val vSeg = arcSegments(90f, vR)
|
||||
emitFilledArc(vX + vR, vY + vR, vR, vR, 180f, 90f, false, vSeg, vSampler)
|
||||
@@ -854,6 +884,34 @@ internal class Sdl3DrawScope(
|
||||
emitTri(ax, ay, cx, cy, dx, dy, inSampler)
|
||||
}
|
||||
|
||||
// Axis-aligned rect subdivided into a grid, so a per-vertex sampler is
|
||||
// evaluated across the INTERIOR, not just the 4 corners. Corner-only
|
||||
// sampling renders radial/sweep gradients flat (a square's corners are all
|
||||
// equidistant from its centre → identical t → solid fill) and clips
|
||||
// non-uniform multi-stop linear ramps. SDL interpolates linearly within
|
||||
// each cell, so ~16 cells/axis tracks the gradient curve closely. Solid
|
||||
// fills never come here (they use emitQuad — one quad).
|
||||
private fun emitRectMesh(
|
||||
inL: Float, inT: Float, inR: Float, inB: Float, inSampler: Sampler,
|
||||
) {
|
||||
// ~one cell per 8 device px per axis (1..24), so thin edge strips stay
|
||||
// cheap while large fills get enough interior samples for the gradient.
|
||||
val vCols = (((inR - inL) / 8f).toInt()).coerceIn(1, 24)
|
||||
val vRows = (((inB - inT) / 8f).toInt()).coerceIn(1, 24)
|
||||
val vW = (inR - inL) / vCols
|
||||
val vH = (inB - inT) / vRows
|
||||
for (vRow in 0 until vRows) {
|
||||
val vY0 = inT + vRow * vH
|
||||
val vY1 = if (vRow == vRows - 1) inB else vY0 + vH
|
||||
for (vCol in 0 until vCols) {
|
||||
val vX0 = inL + vCol * vW
|
||||
val vX1 = if (vCol == vCols - 1) inR else vX0 + vW
|
||||
emitTri(vX0, vY0, vX1, vY0, vX1, vY1, inSampler)
|
||||
emitTri(vX0, vY0, vX1, vY1, vX0, vY1, inSampler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitTri(
|
||||
ax: Float, ay: Float, bx: Float, by: Float, cx: Float, cy: Float,
|
||||
inSampler: Sampler,
|
||||
@@ -889,11 +947,39 @@ 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.
|
||||
private fun writeVertex(inX: Float, inY: Float, inColor: ComposeColor, inAlphaScale: Float = 1f) {
|
||||
if (fBatchCount >= kBatchCapacity) flush()
|
||||
// Auto-flush ONLY at a triangle boundary: SDL_RenderGeometry renders
|
||||
// floor(count/3) triangles and silently drops the tail, so flushing
|
||||
// mid-triangle tears it AND misaligns every following triangle in the
|
||||
// new batch (seen as ~350-degree pac-man circles and glitch shapes
|
||||
// once a frame's geometry outgrew one batch - e.g. 84 unclipped
|
||||
// drawBehind bubbles). kBatchCapacity is a multiple of 3.
|
||||
if (fBatchCount >= kBatchCapacity && fBatchCount % 3 == 0) flush()
|
||||
val vBase = fBatchCount * kFloatsPerVertex
|
||||
fVertexData[vBase + 0] = fMa * inX + fMc * inY + fMe
|
||||
fVertexData[vBase + 1] = fMb * inX + fMd * inY + fMf
|
||||
@@ -914,15 +1000,17 @@ private const val kAaFeather: Float = 1.0f
|
||||
private const val kAaHalf: Float = kAaFeather * 0.5f
|
||||
|
||||
// ============
|
||||
// Batch capacity — 8192 vertices = ~2730 triangles per submission. At
|
||||
// 64 segments per full circle that's room for ~21 full-circle filled
|
||||
// shapes per Canvas{} before any flush. Bigger gives fewer GPU
|
||||
// submissions; smaller saves RAM. ~128 KB at 16 bytes per SDL_Vertex.
|
||||
private const val kBatchCapacity: Int = 8192
|
||||
// Batch capacity — 8190 vertices = 2730 triangles per submission (kept a
|
||||
// MULTIPLE OF 3: the auto-flush in writeVertex only fires on triangle
|
||||
// boundaries, so the cap must land on one). At 64 segments per full circle
|
||||
// that's room for ~21 full-circle filled shapes per Canvas{} before any
|
||||
// flush. Bigger gives fewer GPU submissions; smaller saves RAM. ~128 KB at
|
||||
// 16 bytes per SDL_Vertex.
|
||||
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
|
||||
|
||||
+84
-20
@@ -29,30 +29,90 @@ internal var currentMainCanvas: Sdl3Canvas? = null
|
||||
// An ImageBitmap backed by an SDL render-target texture. The vector rasterises
|
||||
// into the texture (via a Canvas returned by the factory) and Sdl3Canvas.drawImageRect
|
||||
// blits it back — with the Icon tint applied through SDL_SetTextureColorMod.
|
||||
// Mutable native handles held BY REFERENCE so a Cleaner can free them without
|
||||
// capturing the (collectable) SdlImageBitmap. Both the explicit close() and the
|
||||
// GC Cleaner enqueue release of whatever is still here — the first to run nulls
|
||||
// the fields, so the other is a no-op (they can't overlap: close() only runs
|
||||
// while the bitmap is reachable, the Cleaner only after it isn't).
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private class SdlBitmapHandles(
|
||||
var texture: COpaquePointer?,
|
||||
var surface: CPointer<SDL_Surface>?,
|
||||
)
|
||||
|
||||
/* Enqueue destruction of the handles' current contents on the main thread
|
||||
(SDL calls aren't thread-safe; the Cleaner runs on a GC worker). Idempotent:
|
||||
clears the fields so a second call frees nothing. Top-level so the Cleaner
|
||||
block never captures the bitmap. */
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun enqueueRelease(inHandles: SdlBitmapHandles) {
|
||||
val vTex = inHandles.texture
|
||||
val vSurf = inHandles.surface
|
||||
if (vTex == null && vSurf == null) return
|
||||
inHandles.texture = null
|
||||
inHandles.surface = null
|
||||
com.compose.sdl.graphics.NativeReleaseQueue.enqueue {
|
||||
if (vSurf != null) SDL_DestroySurface(vSurf)
|
||||
if (vTex != null) SDL_DestroyTexture(vTex.reinterpret())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal class SdlImageBitmap(
|
||||
inRenderer: COpaquePointer,
|
||||
private val fRenderer: COpaquePointer,
|
||||
override val width: Int,
|
||||
override val height: Int,
|
||||
override val config: ImageBitmapConfig,
|
||||
override val hasAlpha: Boolean,
|
||||
override val colorSpace: ColorSpace,
|
||||
// A pre-made texture (the encoded-image decode path — see
|
||||
// Sdl3EncodedImageDecoder); null → create a render TARGET for the
|
||||
// vector-rasterisation path below.
|
||||
inDecodedTexture: COpaquePointer? = null,
|
||||
// A surface decoded OFF the main thread (the encoded-image path — see
|
||||
// Sdl3EncodedImageDecoder); converted to a texture on the first draw.
|
||||
// null → create a render TARGET for the vector-rasterisation path below
|
||||
// (that path always constructs on the main thread).
|
||||
fDecodedSurface: CPointer<SDL_Surface>? = null,
|
||||
) : ImageBitmap {
|
||||
|
||||
// RGBA render-target texture, premultiplied blend for compositing back (content
|
||||
// is drawn over a transparent clear with ordinary BLEND, leaving premultiplied
|
||||
// colours — see Sdl3ClipTargets for the same reasoning).
|
||||
val texture: COpaquePointer? = inDecodedTexture ?: SDL_CreateTexture(
|
||||
inRenderer.reinterpret(),
|
||||
SDL_PIXELFORMAT_RGBA32,
|
||||
SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET,
|
||||
maxOf(1, width),
|
||||
maxOf(1, height),
|
||||
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) }
|
||||
private val fHandles = SdlBitmapHandles(
|
||||
texture = if (fDecodedSurface != null) null else SDL_CreateTexture(
|
||||
fRenderer.reinterpret(),
|
||||
SDL_PIXELFORMAT_RGBA32,
|
||||
SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET,
|
||||
maxOf(1, width),
|
||||
maxOf(1, height),
|
||||
)?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED) },
|
||||
surface = fDecodedSurface,
|
||||
)
|
||||
|
||||
// GC backstop: if nothing calls close(), free the native handles when the
|
||||
// bitmap is collected (deferred to the main thread via the queue). Ownership
|
||||
// via close() is the primary path; this catches leaks.
|
||||
@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
|
||||
private val fCleaner = kotlin.native.ref.createCleaner(fHandles) { enqueueRelease(it) }
|
||||
|
||||
// SDL renderer calls are NOT thread-safe, and the resources pipeline
|
||||
// decodes on Dispatchers.Default workers — so the decode path hands over a
|
||||
// plain SDL_Surface and the texture is created here, lazily, on the first
|
||||
// access (createCanvas / the draw paths — main thread only). Decoded
|
||||
// surfaces carry STRAIGHT alpha → ordinary BLEND, unlike the premultiplied
|
||||
// render-target path above.
|
||||
val texture: COpaquePointer?
|
||||
get() {
|
||||
fHandles.surface?.let { vSurface ->
|
||||
fHandles.texture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
|
||||
?.also { SDL_SetTextureBlendMode(it.reinterpret(), SDL_BLENDMODE_BLEND) }
|
||||
SDL_DestroySurface(vSurface)
|
||||
fHandles.surface = null
|
||||
}
|
||||
return fHandles.texture
|
||||
}
|
||||
|
||||
/* Free the texture/surface now (deferred to the main thread). Call on
|
||||
cache eviction / removeMemoryResource so native memory releases promptly
|
||||
instead of waiting for a GC. The Cleaner remains as a backstop. */
|
||||
fun close() = enqueueRelease(fHandles)
|
||||
|
||||
override fun readPixels(
|
||||
buffer: IntArray,
|
||||
@@ -97,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)
|
||||
}
|
||||
|
||||
// ==================
|
||||
@@ -116,24 +182,22 @@ internal class Sdl3EncodedImageDecoder(
|
||||
|
||||
override fun decode(inBytes: ByteArray): ImageBitmap? {
|
||||
if (inBytes.isEmpty()) return null
|
||||
// Pure decode only — IMG_Load_IO touches no renderer state, so it is
|
||||
// safe on the resources pipeline's Dispatchers.Default workers. The
|
||||
// texture is created from this surface on the first MAIN-THREAD draw
|
||||
// (SdlImageBitmap.texture) — SDL renderer calls are not thread-safe.
|
||||
val vSurface = inBytes.usePinned { vPinned ->
|
||||
val vIo = SDL_IOFromConstMem(vPinned.addressOf(0), inBytes.size.convert())
|
||||
?: return@usePinned null
|
||||
sdl3_image.IMG_Load_IO(vIo.reinterpret(), true)
|
||||
} ?: return null
|
||||
val vSdlSurface = vSurface.reinterpret<SDL_Surface>()
|
||||
val vW = vSdlSurface.pointed.w
|
||||
val vH = vSdlSurface.pointed.h
|
||||
val vTex = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSdlSurface)
|
||||
SDL_DestroySurface(vSdlSurface)
|
||||
if (vTex == null) return null
|
||||
SDL_SetTextureBlendMode(vTex.reinterpret(), SDL_BLENDMODE_BLEND)
|
||||
return SdlImageBitmap(
|
||||
fRenderer, vW, vH,
|
||||
fRenderer, vSdlSurface.pointed.w, vSdlSurface.pointed.h,
|
||||
config = ImageBitmapConfig.Argb8888,
|
||||
hasAlpha = true,
|
||||
colorSpace = androidx.compose.ui.graphics.colorspace.ColorSpaces.Srgb,
|
||||
inDecodedTexture = vTex,
|
||||
fDecodedSurface = vSdlSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user