mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-06 19:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83c1902b08 | ||
|
|
076d60460c | ||
|
|
93b7ff28e1 | ||
|
|
08968fe820 | ||
|
|
3a57e96c80 | ||
|
|
332ba757fe | ||
|
|
09fe92fc95 | ||
|
|
ec2451aad1 | ||
|
|
ff32d68a72 | ||
|
|
1a56b7dcaf | ||
|
|
7c5abc2705 |
@@ -24,11 +24,16 @@ context — read it first, then look at the files it points to.
|
|||||||
on SDL3, no JVM. Compiles to native binaries for macOS (arm64), Linux
|
on SDL3, no JVM. Compiles to native binaries for macOS (arm64), Linux
|
||||||
(x64/arm64), Windows (mingwX64).
|
(x64/arm64), Windows (mingwX64).
|
||||||
|
|
||||||
Rendering is pluggable behind one `RenderBackend`:
|
Rendering is **Skia everywhere** behind one `RenderBackend` — Metal / OpenGL
|
||||||
|
/ CPU raster:
|
||||||
|
|
||||||
- **Skia** (via Skiko klibs) on macOS + Linux — Metal / OpenGL / CPU raster.
|
- **macOS + Linux** link the OFFICIAL Skiko klibs from Maven.
|
||||||
- **SDL3** (`SDL3_ttf` + `SDL_RenderGeometry`) on Windows, and on macOS/Linux
|
- **Windows (mingwX64)** links the bitsycore skiko FORK — skiko+Skia compiled
|
||||||
when `-Prenderer=sdl3` is passed.
|
into `skiko-windows-x64.dll` with a flat extern-C surface, bound from K/N via
|
||||||
|
an embedded GNU import lib, published to GitHub Packages as
|
||||||
|
`org.jetbrains.skiko:skiko:0.150.1-mingw.1` (override with
|
||||||
|
`-PskikoMingwVersion`). The runtime DLL is auto-provisioned next to the exe by
|
||||||
|
the bridge plugin (`installWindowsSkiaDll`). See SKIKO-MINGW-FEASIBILITY.md.
|
||||||
|
|
||||||
Windowing, input, audio, filesystem access, and the OS-integration surface
|
Windowing, input, audio, filesystem access, and the OS-integration surface
|
||||||
(file dialogs, clipboard, "open in Finder/Explorer"…) all go through
|
(file dialogs, clipboard, "open in Finder/Explorer"…) all go through
|
||||||
@@ -51,9 +56,9 @@ One Gradle module per upstream artifact; the directory mirrors the upstream
|
|||||||
compose/
|
compose/
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── ui/ → :ui — androidx.compose.ui.* (ui + ui-graphics + ui-text) +
|
│ ├── ui/ → :ui — androidx.compose.ui.* (ui + ui-graphics + ui-text) +
|
||||||
│ │ com.compose.sdl.* — cinterops + BOTH renderer pipelines live
|
│ │ com.compose.sdl.* — the sdl3 cinterop + the Skia renderer
|
||||||
│ │ here. ui-graphics/ui-text can't split off: their Canvas /
|
│ │ pipeline live here. ui-graphics/ui-text can't split off: their
|
||||||
│ │ Paragraph `expect`s are the renderers' `actual`s (same-module).
|
│ │ Canvas / Paragraph `expect`s are the renderer's `actual`s (same-module).
|
||||||
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
|
│ ├── ui-util/ → :ui-util — androidx.compose.ui.util.* (+ Experimental/InternalComposeUiApi)
|
||||||
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
|
│ ├── ui-geometry/ → :ui-geometry — androidx.compose.ui.geometry.*
|
||||||
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
|
│ ├── ui-unit/ → :ui-unit — androidx.compose.ui.unit.*
|
||||||
@@ -82,8 +87,7 @@ utils/
|
|||||||
└── material-symbols/ → :material-symbols — codepoints + all three style objects
|
└── material-symbols/ → :material-symbols — codepoints + all three style objects
|
||||||
(Outlined / Rounded / Sharp). COMMON API (usable from
|
(Outlined / Rounded / Sharp). COMMON API (usable from
|
||||||
shared app code) + per-stack actuals: native renders
|
shared app code) + per-stack actuals: native renders
|
||||||
via :foundation IconFontIcon (IconFont handles the
|
via :foundation IconFontIcon (Skia), jvm() via Skiko directly
|
||||||
SDL3/Skia split), jvm() via Skiko directly
|
|
||||||
(Typeface.makeClone per axes — upstream's FontCache
|
(Typeface.makeClone per axes — upstream's FontCache
|
||||||
drops variationSettings from its key). Its commonMain
|
drops variationSettings from its key). Its commonMain
|
||||||
declares official Maven compose coords — the root
|
declares official Maven compose coords — the root
|
||||||
@@ -106,8 +110,8 @@ components/
|
|||||||
no mingwX64/linux klibs. Platform layer is project
|
no mingwX64/linux klibs. Platform layer is project
|
||||||
code: data.kres ResourceReader, pure-Kotlin
|
code: data.kres ResourceReader, pure-Kotlin
|
||||||
DomXmlParser (upstream's is Darwin NSXMLParser),
|
DomXmlParser (upstream's is Darwin NSXMLParser),
|
||||||
SDL3_image decode via the :ui EncodedImageDecoder
|
image decode via the :ui EncodedImageDecoder hook
|
||||||
hook, NamedFont registration, SDL locale/theme env.
|
(Skia), NamedFont registration, SDL locale/theme env.
|
||||||
Apps' JVM targets keep the Maven artifact — the
|
Apps' JVM targets keep the Maven artifact — the
|
||||||
generated Res accessors work against BOTH.
|
generated Res accessors work against BOTH.
|
||||||
|
|
||||||
@@ -173,8 +177,8 @@ scripts/ → vendor-sync + python helper scripts (compose-coverage =
|
|||||||
coverage/fidelity vs upstream, material-symbols generate/subset)
|
coverage/fidelity vs upstream, material-symbols generate/subset)
|
||||||
+ compose-fork/;
|
+ compose-fork/;
|
||||||
scripts/build-sdl/ = static-lib build script (python)
|
scripts/build-sdl/ = static-lib build script (python)
|
||||||
libs/ → gitignored per-host static SDL3 / SDL3_ttf / SDL3_image / FreeType
|
libs/ → gitignored per-host static SDL3 output of
|
||||||
output of scripts/build-sdl/build-all.py on Windows
|
scripts/build-sdl/build-all.py on Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
Module PATHS stay short (`:ui`, `:foundation`, `:window`, …) —
|
Module PATHS stay short (`:ui`, `:foundation`, `:window`, …) —
|
||||||
@@ -202,7 +206,7 @@ sees the split modules. Full DAG: `:ui-util → collection`; `:ui-geometry → :
|
|||||||
`:material3 → :foundation, :material-ripple, :animation-core, :foundation-layout`.
|
`:material3 → :foundation, :material-ripple, :animation-core, :foundation-layout`.
|
||||||
|
|
||||||
`:ui` is the renderer/cinterop hub (the ui-graphics + ui-text `expect`s resolve to
|
`:ui` is the renderer/cinterop hub (the ui-graphics + ui-text `expect`s resolve to
|
||||||
its SDL/Skia renderer `actual`s). The pure lower artifacts (`:ui-util`,
|
its Skia renderer `actual`s). The pure lower artifacts (`:ui-util`,
|
||||||
`:ui-geometry`, `:ui-unit`, `:ui-backhandler`) are split out below it. Everything
|
`:ui-geometry`, `:ui-unit`, `:ui-backhandler`) are split out below it. Everything
|
||||||
above `:ui` can only touch renderer / cinterop internals via its public surface.
|
above `:ui` can only touch renderer / cinterop internals via its public surface.
|
||||||
`:window` depends on `:ui` + `:foundation` (needs `LazyList`-style scaffolding to
|
`:window` depends on `:ui` + `:foundation` (needs `LazyList`-style scaffolding to
|
||||||
@@ -222,7 +226,7 @@ compose-multiplatform umbrella repo that `:components-resources` vendors from).
|
|||||||
There is no implicit default — `SET_REPO` is required. `scripts/compose-fork/sync.sh`
|
There is no implicit default — `SET_REPO` is required. `scripts/compose-fork/sync.sh`
|
||||||
walks all manifests and copies each selected file byte-for-byte from the pinned
|
walks all manifests and copies each selected file byte-for-byte from the pinned
|
||||||
checkout (each distinct repo sparse-cloned to `../cmp-ref[-<name>]`) into
|
checkout (each distinct repo sparse-cloned to `../cmp-ref[-<name>]`) into
|
||||||
`<module>/src/vendor/{common,native,skikoRenderer,sdlRenderer}/kotlin/`. The
|
`<module>/src/vendor/{common,native,skikoRenderer}/kotlin/`. The
|
||||||
`src/vendor/` tree is **gitignored** — you don't check it in, you re-sync
|
`src/vendor/` tree is **gitignored** — you don't check it in, you re-sync
|
||||||
on demand.
|
on demand.
|
||||||
|
|
||||||
@@ -247,8 +251,8 @@ Two categories of code live in each module:
|
|||||||
Never hand-edit these. If upstream diverges, adjust the manifest or the
|
Never hand-edit these. If upstream diverges, adjust the manifest or the
|
||||||
pinned ref and re-sync. This is the bulk of the codebase (~1500 files
|
pinned ref and re-sync. This is the bulk of the codebase (~1500 files
|
||||||
across the modules).
|
across the modules).
|
||||||
2. **Project code (in `src/commonMain/`, `src/nativeMain/`, `src/skikoRendererMain/`,
|
2. **Project code (in `src/commonMain/`, `src/nativeMain/`,
|
||||||
`src/sdlRendererMain/`)** — code we author (project actuals, glue between
|
`src/skikoRendererMain/`)** — code we author (project actuals, glue between
|
||||||
Compose and SDL3, project-specific extensions).
|
Compose and SDL3, project-specific extensions).
|
||||||
|
|
||||||
### The 5 rules for adding upstream Compose surface
|
### The 5 rules for adding upstream Compose surface
|
||||||
@@ -288,14 +292,12 @@ Two categories of code live in each module:
|
|||||||
reconciled by hand. This is fine; do it when the edit is small and
|
reconciled by hand. This is fine; do it when the edit is small and
|
||||||
the file is unlikely to churn upstream.
|
the file is unlikely to churn upstream.
|
||||||
|
|
||||||
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`, with
|
4. **Skiko-specific things go in `:ui/src/skikoRendererMain/`.** When upstream
|
||||||
an SDL3 equivalent in `:ui/src/sdlRendererMain/`.** When upstream
|
ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …, the
|
||||||
ships a `.skiko.kt` file that uses Skiko's Canvas / Paragraph / …,
|
`.skiko.kt` variant is fine to vendor into `skikoRendererMain` (the Skia
|
||||||
the `.skiko.kt` variant is fine to vendor into `skikoRendererMain`.
|
drawing pipeline — `SkiaCanvas.kt`, `SkiaTextRenderer.kt`,
|
||||||
Then hand-roll (or minimally vendor) the SDL3 counterpart in
|
`SkiaImageCache.kt`, …). All native targets attach this source set;
|
||||||
`sdlRendererMain` — `SkiaCanvas.kt` ↔ `Sdl3Canvas.kt`,
|
mingwX64 layers its fork actuals on top under `skikoRendererMingwMain`.
|
||||||
`SkiaTextRenderer.kt` ↔ `Sdl3TextRenderer.kt` (using SDL3_ttf +
|
|
||||||
FreeType), `SkiaImageCache.kt` ↔ `Sdl3ImageCache.kt` (using SDL3_image).
|
|
||||||
|
|
||||||
5. **Multi-OS project code goes through SDL3, not hand-rolled per-target
|
5. **Multi-OS project code goes through SDL3, not hand-rolled per-target
|
||||||
ifdefs.** SDL3 already handles the platform differences for filesystem
|
ifdefs.** SDL3 already handles the platform differences for filesystem
|
||||||
@@ -313,52 +315,36 @@ call sites don't care.
|
|||||||
|
|
||||||
## Source-set hierarchy (:ui only)
|
## Source-set hierarchy (:ui only)
|
||||||
|
|
||||||
`:ui` owns cinterops + both renderer pipelines. Its source-set tree:
|
`:ui` owns the `sdl3` cinterop + the Skia renderer pipeline. Its source-set tree:
|
||||||
|
|
||||||
```
|
```
|
||||||
commonMain
|
commonMain
|
||||||
└── nativeMain (vendored .native.kt + project native code)
|
└── nativeMain (vendored .native.kt + project native code)
|
||||||
├── skikoRendererMain (Skia drawing pipeline; Skiko on classpath)
|
├── skikoRendererMain (Skia drawing pipeline; official Skiko on classpath)
|
||||||
│ ├── skikoRendererMacosMain (macOS-only Skia actuals — Metal bridge)
|
│ ├── skikoRendererMacosMain (macOS-only Skia actuals — Metal bridge)
|
||||||
│ └── skikoRendererLinuxMain (Linux-only Skia actuals — OpenGL)
|
│ └── skikoRendererLinuxMain (Linux-only Skia actuals — OpenGL)
|
||||||
│ attached to: macosArm64Main / linuxX64Main / linuxArm64Main
|
│ attached to: macosArm64Main / linuxX64Main / linuxArm64Main.
|
||||||
│ ONLY when the Skia renderer is active for the target.
|
└── skikoRendererMingwSharedMain
|
||||||
└── sdlRendererMain (SDL3 drawing pipeline + TTF/image/FreeType)
|
└── skikoRendererMingwMain (mingwX64-only Skia actuals — the bitsycore
|
||||||
├── sdlRendererMacosMain (macOS-only SDL3 driver hint)
|
skiko FORK: skiko-windows-x64.dll bound via
|
||||||
├── sdlRendererLinuxMain (Linux-only SDL3 driver hint)
|
an embedded GNU import lib; OpenGL context)
|
||||||
└── sdlRendererMingwMain (mingwX64-only SDL3 driver hint)
|
attached to: mingwX64Main.
|
||||||
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
|
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
|
||||||
`:ui`'s nativeMain with `actual`s in BOTH `skikoRendererMain` and
|
`:ui`'s nativeMain with `actual`s in `skikoRendererMain` (shared by every native
|
||||||
`sdlRendererMain` — unambiguous because **only one of the two renderer source
|
target). `rendererPreferredGpuMode()` picks Metal on macOS, OpenGL on
|
||||||
sets is attached to a given target**. (They used to be plain duplicate
|
Linux + Windows, with a Software (CPU raster) auto-fallback if the GPU context
|
||||||
declarations with no expect; that compiled per-target but shared nativeMain
|
fails to come up.
|
||||||
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.
|
|
||||||
|
|
||||||
### Cinterop sibling-dependency gotcha
|
### Cinterop
|
||||||
|
|
||||||
`:ui` owns four cinterops in `src/nativeInterop/cinterop/`:
|
`:ui` owns ONE cinterop in `src/nativeInterop/cinterop/`: `sdl3` (SDL_Window /
|
||||||
`sdl3`, `sdl3_ttf`, `sdl3_image`, `freetype`. The `.def` files for `sdl3_ttf` /
|
SDL_Event / SDL_GetBasePath / clipboard / dialogs / GL+Metal context /
|
||||||
`sdl3_image` carry `depends = sdl3` so their `SDL_Surface` / `SDL_Color`
|
SDL_Renderer-for-CPU-raster). It has no siblings, so there's no
|
||||||
references resolve to the *same* types `sdl3` produces (not duplicates inside
|
`depends = <sibling>` propagation to worry about — cinterop `.def` files list
|
||||||
`sdl3_image.SDL_Surface`). **Gradle does not automatically add a sibling
|
each other under `depends =` and Gradle does NOT auto-add a sibling cinterop's
|
||||||
cinterop's klib to a cinterop task's `-library` list**, so the manifest
|
klib to the `-library` list, but with a single cinterop that gotcha is moot.
|
||||||
directive silently fails and you get cryptic
|
|
||||||
`expected CPointer<sdl3.SDL_Surface>?, actual CPointer<sdl3_image.SDL_Surface>?`
|
|
||||||
errors.
|
|
||||||
|
|
||||||
`:ui/build.gradle.kts` works around this by passing the sdl3 cinterop output
|
|
||||||
klib path explicitly via `extraOpts("-library", vSdl3Klib)` on `sdl3_ttf` /
|
|
||||||
`sdl3_image`, plus a task dependency
|
|
||||||
(`cinteropSdl3_ttf*Target.dependsOn(cinteropSdl3*Target)`). If you ever add
|
|
||||||
another `depends = sdl3` cinterop, add it to that list too.
|
|
||||||
|
|
||||||
## Density flow (Option B — layout in physical pixels)
|
## Density flow (Option B — layout in physical pixels)
|
||||||
|
|
||||||
@@ -392,13 +378,13 @@ Passing raw `px.dp` will double-scale on Retina.
|
|||||||
./gradlew :demo:runDebugExecutableLinuxX64
|
./gradlew :demo:runDebugExecutableLinuxX64
|
||||||
./gradlew :apidemo:runDebugExecutableLinuxX64
|
./gradlew :apidemo:runDebugExecutableLinuxX64
|
||||||
|
|
||||||
# Windows (from Windows — mingw cross-build from macOS/Linux fails at cinterop)
|
# Windows (from Windows — mingw cross-build from macOS/Linux fails at cinterop).
|
||||||
|
# Links the bitsycore skiko FORK from GitHub Packages (org.jetbrains.skiko:skiko:
|
||||||
|
# 0.150.1-mingw.1, override -PskikoMingwVersion); the bridge plugin drops
|
||||||
|
# skiko-windows-x64.dll next to the exe (installWindowsSkiaDll).
|
||||||
gradlew.bat :demo:runDebugExecutableMingwX64
|
gradlew.bat :demo:runDebugExecutableMingwX64
|
||||||
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||||
|
|
||||||
# Skiko-free build on macOS/Linux — SDL3 renderer everywhere
|
|
||||||
./gradlew :demo:runDebugExecutableMacosArm64 -Prenderer=sdl3
|
|
||||||
|
|
||||||
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
|
# Stock JVM Compose Desktop (any host) — the parity reference: the SAME shared
|
||||||
# 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 :demo:run
|
||||||
@@ -407,12 +393,16 @@ gradlew.bat :apidemo:runDebugExecutableMingwX64
|
|||||||
|
|
||||||
### System dependencies
|
### System dependencies
|
||||||
|
|
||||||
SDL3 + SDL3_ttf + SDL3_image + FreeType (+ image codecs) are **built from
|
SDL3 is **built from source as a static library on every OS** and linked
|
||||||
source as static libraries on every OS** and linked straight into the
|
straight into the executable — no brew/apt SDL packages. Build it once per host
|
||||||
executable — no brew/apt SDL packages, no runtime .dll/.so/.dylib; a
|
with `python scripts/build-sdl/build-all.py`. Per-host toolchain requirements
|
||||||
distributable is `<app>` + `data.kres`. Build them once per host with
|
and the step breakdown are in [TOOLING.md](TOOLING.md#native-libraries).
|
||||||
`python scripts/build-sdl/build-all.py`. Per-host toolchain requirements and
|
|
||||||
the step breakdown are in [TOOLING.md](TOOLING.md#native-libraries).
|
Skia comes in through the Skiko klibs: the official Maven ones on macOS/Linux
|
||||||
|
(no runtime .so/.dylib — Skia is statically inside the klib), and the bitsycore
|
||||||
|
fork on Windows, whose `skiko-windows-x64.dll` the bridge plugin provisions next
|
||||||
|
to the exe. So a macOS/Linux distributable is `<app>` + `data.kres`; a Windows
|
||||||
|
one is `<app>.exe` + `data.kres` + `skiko-windows-x64.dll`.
|
||||||
|
|
||||||
## Runtime bundling — data.kres
|
## Runtime bundling — data.kres
|
||||||
|
|
||||||
@@ -459,12 +449,12 @@ drift / vendor-clean guardrails are in
|
|||||||
- `compose/sdl/window/src/nativeMain/…/ComposeWindow.kt` — main loop,
|
- `compose/sdl/window/src/nativeMain/…/ComposeWindow.kt` — main loop,
|
||||||
recomposer lifecycle, SDL event dispatch, composition-local seeding.
|
recomposer lifecycle, SDL event dispatch, composition-local seeding.
|
||||||
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` — the interface.
|
- `compose/ui/ui/src/nativeMain/…/RenderBackend.kt` — the interface.
|
||||||
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` — sealed renderer / driver picker.
|
- `compose/ui/ui/src/nativeMain/…/GpuMode.kt` — sealed driver picker
|
||||||
- `compose/ui/ui/src/skikoRendererMain/…/renderer/skia/SkiaRenderBackend.kt`.
|
(`Auto` / `Software` / `Skia.OpenGL` / `Skia.Metal`).
|
||||||
- `compose/ui/ui/src/sdlRendererMain/…/renderer/sdl/Sdl3RenderBackend.kt`.
|
- `compose/ui/ui/src/skikoRendererMain/…/renderer/skia/SkiaRenderBackend.kt` —
|
||||||
- `compose/ui/ui/src/sdlRendererMain/…/renderer/sdl/FreeTypeIcons.kt` —
|
the Skia render backend (shared by every native target).
|
||||||
variable-font axis rasterisation (SDL3_ttf has no axis-set API; we go
|
- `compose/ui/ui/src/skikoRendererMingwMain/…` — mingwX64-only Skia actuals
|
||||||
to FreeType directly for icon families).
|
bound against the skiko fork's flat extern-C surface.
|
||||||
|
|
||||||
### Layout / composition wiring
|
### Layout / composition wiring
|
||||||
- `compose/ui/ui/src/commonMain/…/node/ComposeRootHost.kt` — root LayoutNode
|
- `compose/ui/ui/src/commonMain/…/node/ComposeRootHost.kt` — root LayoutNode
|
||||||
@@ -481,8 +471,7 @@ drift / vendor-clean guardrails are in
|
|||||||
- `compose/ui/ui/src/nativeMain/…/ui/text/ParagraphFactories.native.kt` —
|
- `compose/ui/ui/src/nativeMain/…/ui/text/ParagraphFactories.native.kt` —
|
||||||
actuals for the `Paragraph(…)` / `ParagraphIntrinsics(…)` factory family.
|
actuals for the `Paragraph(…)` / `ParagraphIntrinsics(…)` factory family.
|
||||||
- `compose/ui/ui/src/commonMain/…/text/TextMeasurer.kt` — `NativeTextMeasurer`
|
- `compose/ui/ui/src/commonMain/…/text/TextMeasurer.kt` — `NativeTextMeasurer`
|
||||||
interface (per-renderer implementations: `SkiaTextRenderer` /
|
interface (implemented by `SkiaTextRenderer`).
|
||||||
`Sdl3TextRenderer`).
|
|
||||||
|
|
||||||
### Icons
|
### Icons
|
||||||
- `compose/foundation/foundation/src/nativeMain/…/icons/IconFontIcon.kt` —
|
- `compose/foundation/foundation/src/nativeMain/…/icons/IconFontIcon.kt` —
|
||||||
@@ -516,7 +505,7 @@ profiler. Quick rules of thumb:
|
|||||||
the broad net.
|
the broad net.
|
||||||
- Chasing one reported interaction → run the **probe** (`scripts/probe/`), targeted.
|
- Chasing one reported interaction → run the **probe** (`scripts/probe/`), targeted.
|
||||||
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
|
- Slow frame → **profiler** first (`CDN_PROFILE=1`), optimize second.
|
||||||
- Any renderer change → the **`verify-mac`** runbook gates both legs before commit.
|
- Any renderer change → the **`verify-mac`** runbook gates it before commit.
|
||||||
|
|
||||||
See [RENDERER.md](RENDERER.md) for the renderer work these support.
|
See [RENDERER.md](RENDERER.md) for the renderer work these support.
|
||||||
|
|
||||||
@@ -565,12 +554,6 @@ that should surface in tooling.
|
|||||||
`Modifier.alpha(x)` desugars to `graphicsLayer(alpha = x, clip = true)`.
|
`Modifier.alpha(x)` desugars to `graphicsLayer(alpha = x, clip = true)`.
|
||||||
For a drag ghost that also translates, put `alpha` and `translationX` on
|
For a drag ghost that also translates, put `alpha` and `translationX` on
|
||||||
the SAME `graphicsLayer(...)` so clip stays false.
|
the SAME `graphicsLayer(...)` so clip stays false.
|
||||||
- **Cinterop `depends = sdl3`** — silently doesn't propagate; see the
|
|
||||||
gotcha section above.
|
|
||||||
- **Configuration cache + `-Prenderer=`** — Gradle caches configuration;
|
|
||||||
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** —
|
- **Substituted Maven modules hide their transitives from common metadata** —
|
||||||
when the bridge swaps an official coord for a project module on native
|
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
|
configs, KGP's granular-metadata visibility check drops that Maven module's
|
||||||
@@ -583,17 +566,11 @@ that should surface in tooling.
|
|||||||
declares every target, so only its .module files carry the full variant
|
declares every target, so only its .module files carry the full variant
|
||||||
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
|
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
|
||||||
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
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
|
|
||||||
`ProjectPath` (command-list based). `SkiaCanvas.toSkiaPath` handles
|
|
||||||
both — never assume the type without checking.
|
|
||||||
|
|
||||||
## Useful Gradle tricks
|
## Useful Gradle tricks
|
||||||
|
|
||||||
- `--args="--gpu=sdl3.opengl --screen=Buttons"` — pass CLI to the demo.
|
- `--args="--gpu=skia.opengl --screen=Buttons"` — pass CLI to the demo.
|
||||||
- `--info` — see cinterop classpath + include paths actually used.
|
- `--info` — see cinterop classpath + include paths actually used.
|
||||||
- `--rerun-tasks` — force rebuild after toggling `-Prenderer=`.
|
|
||||||
- After a module rename or IC-cache mismatch: nuke
|
- After a module rename or IC-cache mismatch: nuke
|
||||||
`demo/build/kotlin-native-ic-cache` (or `apidemo/build/…`). Kotlin/Native
|
`demo/build/kotlin-native-ic-cache` (or `apidemo/build/…`). Kotlin/Native
|
||||||
pins module IDs into its klib metadata; a stale cache surfaces as
|
pins module IDs into its klib metadata; a stale cache surfaces as
|
||||||
|
|||||||
@@ -18,20 +18,21 @@ executable for macOS (arm64), Linux (x64/arm64), and Windows (mingwX64), using
|
|||||||
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
|
- **The UI layers are vendored upstream.** `androidx.compose.ui`, `foundation`,
|
||||||
`animation`, and `material3` are copied from Compose Multiplatform verbatim
|
`animation`, and `material3` are copied from Compose Multiplatform verbatim
|
||||||
wherever they compile as is, with project code filling in only the native glue.
|
wherever they compile as is, with project code filling in only the native glue.
|
||||||
- **Rendering is pluggable** behind one `RenderBackend`: Skia (via Skiko) on
|
- **Rendering is Skia everywhere.** macOS and Linux link the official Skiko
|
||||||
macOS and Linux, and a from-scratch SDL3 renderer (`SDL3_ttf` +
|
klibs (Metal / OpenGL); Windows links the bitsycore Skiko fork, which ships
|
||||||
`SDL_RenderGeometry`) on Windows, or anywhere with `-Prenderer=sdl3`.
|
Skiko and Skia together in `skiko-windows-x64.dll`.
|
||||||
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
|
- **The platform is SDL3.** Windowing, input, audio, filesystem, file dialogs,
|
||||||
and clipboard all go through SDL3, so one code path covers every OS.
|
and clipboard all go through SDL3, so one code path covers every OS.
|
||||||
- **No runtime dependencies.** SDL3 and its codecs are built as static
|
- **Lean distributables.** SDL3 is built as a static library and linked in. On
|
||||||
libraries and linked in. A distributable is just the executable plus a
|
macOS and Linux a distributable is just the executable plus a `data.kres`
|
||||||
`data.kres` resource bundle.
|
resource bundle; on Windows it also ships `skiko-windows-x64.dll` next to the
|
||||||
|
exe (auto-provisioned by the bridge plugin).
|
||||||
|
|
||||||
| Platform | Gradle target | Default renderer |
|
| Platform | Gradle target | Renderer |
|
||||||
|----------|---------------|------------------|
|
|----------|---------------|----------|
|
||||||
| macOS arm64 | `macosArm64` | Skia (Metal) |
|
| macOS arm64 | `macosArm64` | Skia (Metal) — official Skiko |
|
||||||
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) |
|
| Linux x64 / arm64 | `linuxX64` / `linuxArm64` | Skia (OpenGL) — official Skiko |
|
||||||
| Windows | `mingwX64` | SDL3 |
|
| Windows | `mingwX64` | Skia — bitsycore Skiko fork |
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -122,7 +123,7 @@ dialogs, canvas, graphics layers, animation, and gestures.
|
|||||||
```bash
|
```bash
|
||||||
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
./gradlew :demo:runDebugExecutableMacosArm64 # macOS (Skia / Metal)
|
||||||
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
./gradlew :demo:runDebugExecutableLinuxX64 # Linux (Skia / OpenGL)
|
||||||
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (SDL3)
|
gradlew.bat :demo:runDebugExecutableMingwX64 # Windows (Skia / Skiko fork)
|
||||||
./gradlew :demo:run # JVM Compose Desktop (reference)
|
./gradlew :demo:run # JVM Compose Desktop (reference)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -138,9 +139,6 @@ client certificates.
|
|||||||
./gradlew :apidemo:run # JVM Compose Desktop (reference)
|
./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
|
## Modules
|
||||||
|
|
||||||
One Gradle module per upstream Compose artifact, mirroring the upstream
|
One Gradle module per upstream Compose artifact, mirroring the upstream
|
||||||
@@ -159,7 +157,7 @@ compatible artifacts are in [CLAUDE.md](CLAUDE.md).
|
|||||||
Build the native libraries once per machine, then build any app target:
|
Build the native libraries once per machine, then build any app target:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/build-sdl/build-all.py # SDL3, SDL3_ttf, SDL3_image, FreeType (static)
|
python3 scripts/build-sdl/build-all.py # SDL3 (static)
|
||||||
./gradlew :demo:runDebugExecutableMacosArm64
|
./gradlew :demo:runDebugExecutableMacosArm64
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+46
-90
@@ -8,27 +8,29 @@ actuals, or the layer engine.
|
|||||||
For build and verification commands see [TOOLING.md](TOOLING.md). For module
|
For build and verification commands see [TOOLING.md](TOOLING.md). For module
|
||||||
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
|
layout, source-set hierarchy, and vendoring rules see [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
## 1. The two renderer legs
|
## 1. The renderer
|
||||||
|
|
||||||
Rendering is pluggable behind one `RenderBackend` interface. Exactly one leg is
|
> **This branch is Skia-only.** The from-scratch SDL renderer leg
|
||||||
active per target:
|
> (`sdlRendererMain`: `Sdl3Canvas` / `Sdl3TextRenderer` / `FreeTypeIcons` / the
|
||||||
|
> SDL render nodes, ~3,800 lines) and its `SDL3_ttf` / `SDL3_image` / FreeType
|
||||||
|
> cinterops were removed once Skiko-on-mingwX64 landed (Route 1a — see
|
||||||
|
> [SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)). SDL3 remains as the
|
||||||
|
> windowing / input / platform layer only. Below, references to "the SDL leg", a
|
||||||
|
> second `RenderBackend` actual, `-Prenderer=sdl3`, and the SDL-leg fidelity work
|
||||||
|
> are **historical** — kept because the convergence decisions still explain the
|
||||||
|
> Skia leg's shape.
|
||||||
|
|
||||||
- **Skia leg** (`skikoRendererMain`, macOS + Linux). Draws through upstream
|
Rendering goes through one `RenderBackend`, implemented once: the **Skia leg**
|
||||||
Compose Multiplatform's own Skia stack, vendored verbatim: `SkiaBackedCanvas`,
|
(`skikoRendererMain`). It draws through upstream Compose Multiplatform's own Skia
|
||||||
`SkiaBackedPaint`, `SkiaShader`, `actual class GraphicsLayer` backed by
|
stack, vendored verbatim: `SkiaBackedCanvas`, `SkiaBackedPaint`, `SkiaShader`,
|
||||||
`org.jetbrains.skiko.node.RenderNode`, `SkiaGraphicsContext`. On this leg the
|
`actual class GraphicsLayer` backed by `org.jetbrains.skiko.node.RenderNode`,
|
||||||
layer and draw engine internals are literally upstream.
|
`SkiaGraphicsContext` — the layer and draw engine internals are literally
|
||||||
- **SDL leg** (`sdlRendererMain`, Windows always; macOS/Linux under
|
upstream. macOS/Linux link the official Skiko klibs; mingwX64 links the
|
||||||
`-Prenderer=sdl3`). A from-scratch renderer on `SDL_RenderGeometry` +
|
bitsycore skiko **fork** (`skikoRendererMingwMain`, Route 1a).
|
||||||
`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`,
|
The text and image pipeline (`SdlParagraph`, `NativeTextMeasurer`, `IconFont`)
|
||||||
`NativeTextMeasurer`, `IconFont`). Text layout, line-breaking, metrics, and
|
is the port's own engine; glyph rasterization is Skia. GPU path per platform:
|
||||||
hit-test are the port's engine on both legs; only glyph rasterization differs
|
Metal (macOS), OpenGL (Linux + Windows), with a CPU-raster `Software` fallback.
|
||||||
(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
|
The seam is kept as narrow and low as possible. Code flows
|
||||||
`Common (upstream) -> shared native engine -> Skia actual / SDL actual`. What we
|
`Common (upstream) -> shared native engine -> Skia actual / SDL actual`. What we
|
||||||
@@ -38,7 +40,7 @@ stack) is preferred over a thin hand-written one.
|
|||||||
|
|
||||||
## 2. The retained-layer engine (the model we copied)
|
## 2. The retained-layer engine (the model we copied)
|
||||||
|
|
||||||
Upstream skiko skips work at three levels. The port has all three.
|
Upstream skiko skips work at three levels. The Skia leg has all three.
|
||||||
|
|
||||||
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
|
- **L1 frame scheduling.** A frame schedules the next only if still dirty.
|
||||||
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
|
Ours: `ComposeWindow.shouldRender()` gates `renderFrame()`; the loop blocks on
|
||||||
@@ -58,8 +60,7 @@ 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.
|
drawing less screen. Dirty-region rendering is therefore an explicit non-goal.
|
||||||
|
|
||||||
The compositing-strategy contract (`requiresLayer()`, from
|
The compositing-strategy contract (`requiresLayer()`, from
|
||||||
`SkiaGraphicsLayer.skiko.kt`) must match on both legs. It decides when a layer
|
`SkiaGraphicsLayer.skiko.kt`) decides when a layer needs an offscreen:
|
||||||
needs an offscreen:
|
|
||||||
|
|
||||||
| Condition | Auto | Offscreen | ModulateAlpha |
|
| Condition | Auto | Offscreen | ModulateAlpha |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -76,43 +77,25 @@ needs an offscreen:
|
|||||||
vendored verbatim. The Skia leg gets real display-list caching and correct
|
vendored verbatim. The Skia leg gets real display-list caching and correct
|
||||||
clip/shadow/renderEffect for free from upstream. After this landed, Skia
|
clip/shadow/renderEffect for free from upstream. After this landed, Skia
|
||||||
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
|
`draw` on LazyColumn fell from 1.75 ms to 0.2 ms.
|
||||||
- **SDL leg ships the geo node as the Windows default.** `SdlDisplayListRenderNode`
|
- **Parity is a golden-master against JVM** (about 2% median, dominated by the
|
||||||
records a leaf's layer-local tessellated geometry, text runs, and icon glyphs
|
shared text engine's small metric delta). See TOOLING.md for how to read it.
|
||||||
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)
|
- **Memory is stable.** The historical composition leak is fixed (see section 6)
|
||||||
and guarded by the `--soaktest` gate in the verify runbook.
|
and guarded by the `--soaktest` gate in the verify runbook.
|
||||||
|
|
||||||
## 4. Decisions to remember
|
## 4. Decisions to remember
|
||||||
|
|
||||||
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
|
- **Goal is G1, cheap upstream-tracking.** The target is low per-bump
|
||||||
reconciliation cost when following upstream, not Windows pixel-parity or
|
reconciliation cost when following upstream. All platforms now render through
|
||||||
feature completeness. The JVM Compose Desktop target (`:demo:run`) is the
|
real Skia — mingwX64 via the bitsycore skiko fork (Route 1a; see
|
||||||
documented Windows fidelity/feature tier: real Skia, full fidelity, on any
|
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md)) — so there is no
|
||||||
host. That makes SDL-leg fidelity work (B3) and real-Skia-on-Windows (Track A)
|
fidelity tier below the JVM cross-check. The JVM Compose Desktop target
|
||||||
optional polish, not obligations.
|
(`:demo:run`) remains the fidelity/parity reference on any host.
|
||||||
- **Vendor, do not hand-roll.** Copy upstream verbatim wherever it compiles.
|
- **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
|
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
|
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?"
|
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
|
Valid answers name a constraint (e.g. no windowing/input toolkit in K/N ->
|
||||||
blitter). "It was easier" is not valid.
|
SDL3). "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
|
- **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:
|
upstream (`SkiaParagraph`) is a font-subsystem replacement, not a canvas swap:
|
||||||
two coexisting font-identity models (the port's name-to-bytes `IconFont` +
|
two coexisting font-identity models (the port's name-to-bytes `IconFont` +
|
||||||
@@ -121,18 +104,8 @@ needs an offscreen:
|
|||||||
`data.kres`-to-`FontCache` bridge, no green intermediate, and it discards the
|
`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
|
P3.1 metrics work. ROI is low: the Skia leg already measures text with skiko
|
||||||
`Font` metrics (via `currentTextMeasurer` -> `SkiaTextRenderer`), which is how
|
`Font` metrics (via `currentTextMeasurer` -> `SkiaTextRenderer`), which is how
|
||||||
parity reached about 2%. Revisit only if complex-script/bidi fidelity on the
|
parity reached about 2%. Revisit only if complex-script/bidi fidelity becomes
|
||||||
native Skia leg becomes a hard requirement.
|
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
|
- **Module split (`:ui-graphics` / `:ui-text`) is shelved as infeasible.** Not
|
||||||
cosmetic churn as originally scoped: the `sdl3` cinterop is a shared substrate
|
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
|
used by 11 graphics-side files AND 12 platform/windowing/node/resources files
|
||||||
@@ -143,13 +116,12 @@ needs an offscreen:
|
|||||||
artifacts" goal it was meant to serve. The one keeper from the attempt:
|
artifacts" goal it was meant to serve. The one keeper from the attempt:
|
||||||
`RenderBackend.drawRoot` now takes `(Canvas) -> Unit`, decoupling the backends
|
`RenderBackend.drawRoot` now takes `(Canvas) -> Unit`, decoupling the backends
|
||||||
from `ComposeRootHost` (a genuine improvement, retained).
|
from `ComposeRootHost` (a genuine improvement, retained).
|
||||||
- **Track A (real Skia on Windows K/N) is shelved.** Kotlin/Native mingwX64 is
|
- **Real Skia on Windows K/N shipped (Route 1a).** mingwX64 links the bitsycore
|
||||||
GNU-ABI; skia-pack Windows is MSVC-ABI, which cannot be statically fused into a
|
skiko fork against `skiko-windows-x64.dll` (with an embedded GNU import lib),
|
||||||
GNU-ABI binary. Building current C++20 Skia to a GNU/mingw static archive is an
|
published to GitHub Packages and auto-provisioned by the bridge plugin. This
|
||||||
open-ended fork (only abandoned Mozilla-era precedent). The only working route
|
replaced the SDL renderer as the Windows path. See
|
||||||
is a runtime DLL, which breaks the no-DLL invariant and adds tens of MB on the
|
[SKIKO-MINGW-FEASIBILITY.md](SKIKO-MINGW-FEASIBILITY.md) for the ABI details
|
||||||
one platform it targets. The SDL leg is the permanent Windows renderer; JVM is
|
and the route trade-offs.
|
||||||
the fidelity escape hatch.
|
|
||||||
|
|
||||||
## 5. Convergence status
|
## 5. Convergence status
|
||||||
|
|
||||||
@@ -163,20 +135,19 @@ needs an offscreen:
|
|||||||
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
|
| B5: engine-convergence deltas audit | Done (clean wins spent by B6) |
|
||||||
| P2.2: composition memory leak | Fixed + soak-gated |
|
| P2.2: composition memory leak | Fixed + soak-gated |
|
||||||
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
|
| P2.3: outsets / blur / renderEffect | Done via upstream `SkiaGraphicsLayer` |
|
||||||
| P3.1: SDL text metrics parity (17% median -> 2%) | Done |
|
| P3.1: text metrics parity (17% median -> 2%) | Done |
|
||||||
| B3: further SDL fidelity | Capped (parity-ranked wins only) |
|
|
||||||
| Module split | Shelved (infeasible as specified) |
|
| Module split | Shelved (infeasible as specified) |
|
||||||
| Track A: real Skia on Windows | Shelved |
|
| Track A: real Skia on Windows (Route 1a) | Done |
|
||||||
|
|
||||||
## 6. Remaining and future work
|
## 6. Remaining and future work
|
||||||
|
|
||||||
Nothing here blocks day-to-day work. These are the open threads worth
|
Nothing here blocks day-to-day work. These are the open threads worth
|
||||||
remembering.
|
remembering.
|
||||||
|
|
||||||
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook covers both renderers
|
- **WIN-SMOKE (pre-ship, Windows only).** The Mac runbook cannot cover the
|
||||||
but cannot cover the shipped mingwX64 binary, the Windows-only `PrintWindow`
|
shipped mingwX64 binary, the Windows-only `PrintWindow` probe, or the
|
||||||
probe, or the common-metadata publish job. Run these on a Windows host before
|
common-metadata publish job. Run these on a Windows host before any release.
|
||||||
any release. This is the only outstanding verification.
|
This is the only outstanding verification.
|
||||||
- **Stabilization at Compose 1.12.0 stable.** The vendored refs are pinned to
|
- **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
|
`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
|
Maven). The native side leads the JVM parity leg (forced to beta02, the latest
|
||||||
@@ -192,17 +163,12 @@ remembering.
|
|||||||
shadows already match JVM.
|
shadows already match JVM.
|
||||||
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
|
- D3: dedupe `LayerTransformationMatrix.kt` against the now-vendored
|
||||||
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
|
`Matrices.skiko`. Blocked on D2 (the shared owner-layer hit-test needs a
|
||||||
both-legs matrix fn).
|
matrix fn usable from the owner layer).
|
||||||
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
|
- D4/D5: `SemanticsRegion` intersect/difference are stubs, and `CharHelpers`
|
||||||
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
|
is naive grapheme/bidi vs upstream ICU. Real fidelity gaps, gated on an
|
||||||
accessibility or complex-script roadmap.
|
accessibility or complex-script roadmap.
|
||||||
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
|
- D6: `Focusability` / `PlatformVelocityTracker` are byte-equal to upstream;
|
||||||
vendoring them saves nothing.
|
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 +
|
- **Native-resource lifecycle.** Wire `GraphicsLayer`/RenderNode +
|
||||||
`SdlImageBitmap.close()` fully into cache eviction and the renderer
|
`SdlImageBitmap.close()` fully into cache eviction and the renderer
|
||||||
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
|
`destroy()` chain; demote the periodic GC nudge once ownership covers it.
|
||||||
@@ -225,13 +191,6 @@ remembering.
|
|||||||
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
|
Found by exact live-counters, static-mode isolation, macOS `leaks`/`heap`/
|
||||||
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
|
`vmmap`, and component bisection. RSS alone cannot pinpoint a referenced leak;
|
||||||
budget heap tooling for this class.
|
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
|
- **Screenshots miss crashes and settle-timing.** Free-running screenshots on
|
||||||
animated/settling screens give false signals. Use render-to-quiescence +
|
animated/settling screens give false signals. Use render-to-quiescence +
|
||||||
virtual frame time for parity, the probe for interaction/crash coverage.
|
virtual frame time for parity, the probe for interaction/crash coverage.
|
||||||
@@ -252,9 +211,6 @@ remembering.
|
|||||||
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
|
- `compose/ui/ui/src/nativeMain/.../RenderBackend.kt`: the interface.
|
||||||
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
|
- `compose/ui/ui/src/nativeMain/.../GpuMode.kt`: renderer / driver picker.
|
||||||
- `compose/ui/ui/src/skikoRendererMain/.../renderer/skia/SkiaRenderBackend.kt`.
|
- `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,
|
- `compose/ui/ui/src/commonMain/.../node/ComposeRootHost.kt`: root host,
|
||||||
hit-test, event dispatch, snapshot observer sweep.
|
hit-test, event dispatch, snapshot observer sweep.
|
||||||
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
|
- `compose/ui/ui/src/commonMain/.../node/impl/ComposeOwner.kt`: the project
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# Skiko on mingwX64 — Feasibility & Effort Assessment
|
||||||
|
|
||||||
|
Investigation of the effort to run the real Skia leg (`org.jetbrains.skiko`)
|
||||||
|
on the Kotlin/Native **mingwX64** target, under the relaxed constraint that a
|
||||||
|
**DLL is acceptable** (the static / no-DLL invariant may be dropped). Companion
|
||||||
|
to [RENDERER.md](RENDERER.md) §4 "Track A" — which this assessment **partly
|
||||||
|
supersedes** (see [Corrections](#corrections-to-renderermd-4) below).
|
||||||
|
|
||||||
|
Pins at time of writing: skiko `0.150.1` (Skia m150), kotlin `2.4.0`,
|
||||||
|
compose `1.12.0-beta02`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
Feasible — and **more tractable than RENDERER.md §4 claims**. Two of that
|
||||||
|
section's load-bearing premises are now factually stale:
|
||||||
|
|
||||||
|
1. **"Building C++20 Skia to a GNU/mingw archive is an open-ended fork (only
|
||||||
|
abandoned Mozilla-era precedent)."** — **False.** MSYS2 maintains
|
||||||
|
`mingw-w64-skia` at **Skia milestone 143 (C++20)** for `mingw64` (GCC),
|
||||||
|
`ucrt64`, and `clang64` (clang → `x86_64-w64-windows-gnu`), producing
|
||||||
|
`libskia.dll` + a GNU import lib `libskia.dll.a`. Last rebuilt **2026-07-24**.
|
||||||
|
The GNU-ABI recipe is ~8 small GN patches (the key one adds
|
||||||
|
`is_mingw = is_win && (cxx=="g++"||cxx=="clang++")`).
|
||||||
|
2. **"The only working route is a runtime DLL, which breaks the no-DLL
|
||||||
|
invariant."** — The DLL route is real, but the reason it works is **not**
|
||||||
|
what the section implies. See the crux below.
|
||||||
|
|
||||||
|
**Effort:** ~**1–2 weeks** to a proven spike + a CPU-raster Skia-on-Windows
|
||||||
|
prototype; ~**1–2 months** to a tested, GPU-accelerated Windows Skia leg with
|
||||||
|
published fork klibs and repo wiring — **plus** permanent
|
||||||
|
`bitsycore/skiko`-fork maintenance (re-sync at every skiko bump). Difficulty:
|
||||||
|
**research-grade / high**, because no working prior art exists (the two GitHub
|
||||||
|
`skiko-mingw64` forks are vaporware — 0 commits ahead of upstream, no mingw
|
||||||
|
source set). Confidence: **medium** (high on mechanism, medium on timeline).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spike result — EXECUTED, GREEN ✅ (2026-07-24)
|
||||||
|
|
||||||
|
The core de-risking experiment has been **run on this machine** and passed. The
|
||||||
|
whole Route 1a link mechanism works on the exact target toolchain (Kotlin
|
||||||
|
**2.4.0**, mingwX64, LLVM **21** `ld.lld`, `x86_64-pc-windows-gnu`).
|
||||||
|
|
||||||
|
**What was built** (`spike-skiko-mingw/` in this repo):
|
||||||
|
- A tiny DLL reproducing skiko's binding shape — an **opaque handle over a real
|
||||||
|
C++/STL object** (`std::string`), **alloc+free paired inside the DLL**, flat
|
||||||
|
`extern "C"` surface. Compiled **MSVC-ABI via `clang-cl /LD /MT /EHsc`**
|
||||||
|
(static CRT). Exports verified undecorated: `spike_make/size/free`.
|
||||||
|
- A **GNU import library** (`libspike.dll.a`) generated with `dlltool`.
|
||||||
|
- A **Kotlin/Native mingwX64 executable** binding it via cinterop (plain C
|
||||||
|
header) + `linkerOpts("-L… -lspike")`.
|
||||||
|
|
||||||
|
**Results:**
|
||||||
|
- `linkReleaseExecutableMingwX64` **linked clean** — **KT-65671 did NOT bite**
|
||||||
|
(the DLL path puts zero C++ objects in the K/N link graph, so the
|
||||||
|
`--allow-multiple-definition` / `.drectve __clang_call_terminate` failures
|
||||||
|
never arise). LLVM 21 `ld.lld` is also far newer than the 1.9.x-era reports.
|
||||||
|
- Runtime: `SPIKE-RESULT size=7 expected=7 ok=true`, exit 0 — the C++
|
||||||
|
`std::string(7,'x').size()` round-trips through the opaque handle correctly.
|
||||||
|
- `objdump -p` confirms the exe imports `spike.dll`, and `spike.dll` depends on
|
||||||
|
**`KERNEL32.dll` only** → **self-contained, no VC++ redist needed**. The exe
|
||||||
|
carries K/N's `msvcrt.dll` while the DLL carries its own static MSVC CRT —
|
||||||
|
**two CRTs coexist in one process** with no issue (ownership paired inside the
|
||||||
|
DLL), empirically confirming the CRT-boundary discipline.
|
||||||
|
|
||||||
|
**Implication:** the one load-bearing unknown (does the DLL route link on
|
||||||
|
2.4.0?) is resolved **in favour**. Route 1a is de-risked at the mechanism level;
|
||||||
|
remaining effort is engineering the real `skiko.dll` (compile skiko's shim +
|
||||||
|
skia-pack MSVC Skia into one DLL) and repo wiring — not proving feasibility.
|
||||||
|
Feasibility confidence is now **high**; residual medium confidence is on
|
||||||
|
timeline only.
|
||||||
|
|
||||||
|
Reproduce: `./gradlew -p spike-skiko-mingw linkReleaseExecutableMingwX64`, then
|
||||||
|
run the exe with `spike-skiko-mingw/native` on `PATH`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The crux — and why "just use a DLL" is only half the story
|
||||||
|
|
||||||
|
**The blocker is a C++ ABI mismatch at the binding boundary, and it is
|
||||||
|
orthogonal to static-vs-dynamic linkage.**
|
||||||
|
|
||||||
|
- **K/N mingwX64 is GNU/Itanium ABI.** `konan.properties`:
|
||||||
|
`targetTriple.mingw_x64 = x86_64-pc-windows-gnu`,
|
||||||
|
`linker.mingw_x64 = ld.lld.exe`,
|
||||||
|
`linkerKonanFlags.mingw_x64 = -static-libgcc -static-libstdc++ … -lwinpthread`
|
||||||
|
— GCC libstdc++/libgcc, Itanium mangling (`_Z3fooi`).
|
||||||
|
- **skia-pack's only Windows Skia is MSVC-ABI** — built with `clang-cl`,
|
||||||
|
`is_trivial_abi=false`, shipped as MSVC `.lib`; C++ exports are MSVC-mangled
|
||||||
|
(`?foo@@YAHH@Z`).
|
||||||
|
|
||||||
|
LLVM issue #60847 ("LLD cannot link against MSVC libraries in MinGW toolchain",
|
||||||
|
closed *not planned*) confirms the two mangling schemes never reconcile.
|
||||||
|
**Wrapping the same MSVC C++ Skia in a DLL does not change this** — a DLL's C++
|
||||||
|
exports are still MSVC-mangled. So dynamic-vs-static is a red herring; **the C++
|
||||||
|
ABI at whatever boundary the mangled C++ crosses is the wall.**
|
||||||
|
|
||||||
|
What makes it tractable is *where that boundary sits in skiko*:
|
||||||
|
|
||||||
|
- **skiko's Kotlin↔native boundary is already a flat, unmangled C symbol
|
||||||
|
boundary.** Kotlin declares
|
||||||
|
`@ExternalSymbolName("org_jetbrains_skia_Data__1nSize") external fun _nSize(…)`;
|
||||||
|
`ExternalSymbolName` is a typealias for `kotlin.native.SymbolName`, which
|
||||||
|
resolves to a *plain named symbol* at link. The generated `.def` is
|
||||||
|
header-less (only `linkerOpts`). **No C++ type ever crosses into Kotlin.**
|
||||||
|
- **The C++ ABI coupling lives one layer down**, at the *shim↔Skia* link:
|
||||||
|
~78 `extern "C"` (`SKIKO_EXPORT`) `.cc` files under `src/nativeJsMain/cpp/`
|
||||||
|
that `reinterpret_cast<SkData*>(ptr)->size()` etc. This shim **must share one
|
||||||
|
C++ ABI with Skia** — today they're one GNU-ABI island fused via
|
||||||
|
`-include-binary`.
|
||||||
|
|
||||||
|
**Consequence:** a DLL is viable **iff** the *entire* C++ island (shim + Skia)
|
||||||
|
is one consistent ABI internally, and only the **flat extern-C export surface**
|
||||||
|
crosses to K/N via a generated import library. On x86_64 there is a single
|
||||||
|
Windows calling convention and extern-C exports are undecorated on both MSVC and
|
||||||
|
mingw, so `ld.lld` links such a DLL via `gendef`+`dlltool` (or directly, LLD
|
||||||
|
13+). **The repo already does exactly this for system DLLs** (`sdl3.def`:
|
||||||
|
`linkerOpts.mingw_x64 = -lkernel32 -luser32 -lgdi32 …`).
|
||||||
|
|
||||||
|
Crucially the extern-C façade **already exists** (the `nativeJsMain/cpp` shim
|
||||||
|
exports precisely the `org_jetbrains_skia_*` symbols the K/N side binds) — it is
|
||||||
|
**reused, not authored anew**. The catch: no ownership / exceptions / STL may
|
||||||
|
cross the C line — skiko is already handle/RefCnt-based, so this holds if
|
||||||
|
authored carefully. (The JVM `jvmMain/cpp` bridge exports `Java_…` JNI symbols
|
||||||
|
and is **not** reusable — the *native* shim is the right one.)
|
||||||
|
|
||||||
|
The non-negotiable that survives all of this: **upstream skiko ships no
|
||||||
|
mingwX64 K/N target at all** (`throw GradleException("$os not yet supported")`
|
||||||
|
for non-{Mac,iOS,tvOS,Linux}; "Windows" in skiko means the JVM/AWT clang-cl
|
||||||
|
path). So this is a **from-scratch skiko build-system port**, not a flag flip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The dominant risk: KT-65671
|
||||||
|
|
||||||
|
`KT-65671` ("Kotlin/Native: Failed linking cinterop static library for
|
||||||
|
mingwX64", **Open**, filed against 1.9.x, status on 2.4.0 unverified): K/N's
|
||||||
|
mingw link step chokes when **fusing an external C++ static archive** —
|
||||||
|
`ld.lld` rejects `--allow-multiple-definition`, and
|
||||||
|
`-exclude-symbols:__clang_call_terminate` in the objects is rejected in
|
||||||
|
`.drectve`. This is skiko's **exact static-fusion pattern**.
|
||||||
|
|
||||||
|
Key implication for route choice: a **pure extern-C DLL boundary sidesteps this
|
||||||
|
bug class entirely** — no C++ objects enter the K/N link graph; only a flat
|
||||||
|
import surface crosses. So, counter-intuitively, the **DLL route is the
|
||||||
|
risk-*reducing* route** w.r.t. KT-65671, and it is exactly what the user is
|
||||||
|
willing to accept.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### ★ Recommended — DLL with a flat extern-C export, K/N binds via import lib
|
||||||
|
|
||||||
|
Put **all** the C++ (skiko's existing shim + Skia) inside one DLL; export only
|
||||||
|
the flat `org_jetbrains_skia_*` symbols; bind from a new mingwX64 skiko target
|
||||||
|
via a generated import lib. K/N resolves at link; the Windows loader binds
|
||||||
|
`skiko.dll` at process start — **no dlopen/`staticLoad` code needed** (the
|
||||||
|
shipped `staticLoad()` no-op is fine for the import-lib path).
|
||||||
|
|
||||||
|
Two sub-choices for the DLL internals (K/N neither knows nor cares which):
|
||||||
|
|
||||||
|
- **1a — MSVC internals (minimal fork).** Recompile skiko's `nativeJsMain/cpp`
|
||||||
|
shim with `clang-cl` against **skia-pack's existing MSVC Windows `.lib`**
|
||||||
|
(`windows-x64` archives are already published). **Zero Skia fork.** Net-new:
|
||||||
|
add `__declspec(dllexport)` to `SKIKO_EXPORT` on this build, a mingw/DLL
|
||||||
|
branch in skiko's build logic, import-lib generation. CRT discipline (MSVC CRT
|
||||||
|
in the DLL vs K/N's static libgcc) is a correctness constraint on the C API
|
||||||
|
(handles + primitives only), not a linkability blocker.
|
||||||
|
- **1b — GNU internals.** Build the shim + Skia GNU-ABI (reuse MSYS2's
|
||||||
|
`mingw-w64-skia` patch set) into the DLL. Slightly more Skia-side work, but a
|
||||||
|
single toolchain end-to-end.
|
||||||
|
|
||||||
|
**Effort:** ~2–4 weeks once the spike is green. **Risk: medium** (sidesteps
|
||||||
|
KT-65671). **"Just works" preserved:** none — ships `skiko.dll` (tens of MB)
|
||||||
|
next to `data.kres` (accepted). **Sub-choice 1a is the least-fork option.**
|
||||||
|
|
||||||
|
### Route 2 — GNU-ABI static Skia + real mingwX64 skiko target (purist)
|
||||||
|
|
||||||
|
Fork skia-pack (or reuse MSYS2's patches) to emit **GNU-ABI static** Skia;
|
||||||
|
fork skiko to add a mingwX64 target that recompiles the existing extern-C shim
|
||||||
|
GNU-ABI and **static-fuses** exactly as macOS/Linux do today.
|
||||||
|
|
||||||
|
**Effort:** Skia build itself is now **days** (MSYS2 proves it). skiko port +
|
||||||
|
link reconciliation ~3–6 weeks. **Risk: medium-high** — this route hits
|
||||||
|
**KT-65671 head-on** (static C++ archive fusion). **Preserves the static /
|
||||||
|
no-DLL invariant** — the only reason to prefer it, and the user said they don't
|
||||||
|
need it.
|
||||||
|
|
||||||
|
### Route 3 — CPU-raster-only (a scope reduction, not an ABI shortcut)
|
||||||
|
|
||||||
|
The repo's `SkiaSurfaceBridge` (`Surface.makeRasterDirect` → `SDL_UpdateTexture`
|
||||||
|
→ `SDL_RenderTexture`) needs **no GPU context** and compiles on mingwX64 the
|
||||||
|
moment a skiko klib exists. Use it to **skip authoring the Windows GPU bridge**
|
||||||
|
for a first cut. Still requires solving Route 1/2's binding problem first.
|
||||||
|
**Saves ~1–2 weeks** of GPU-bridge work → the natural **milestone 1** of
|
||||||
|
whichever route you pick.
|
||||||
|
|
||||||
|
### Baseline — do nothing (already shipping)
|
||||||
|
|
||||||
|
Windows renders via the from-scratch **SDL leg** (`SDL_RenderGeometry` +
|
||||||
|
SDL3_ttf + FreeType); the **JVM parity target** is the full-fidelity Skia
|
||||||
|
reference. The bar any Skia-on-Windows work must clear is "materially better
|
||||||
|
than the SDL leg's fidelity, worth the DLL + maintenance cost."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended work breakdown (Route 1a, CPU-raster first)
|
||||||
|
|
||||||
|
**A. Skia — none (1a) / days (1b).**
|
||||||
|
Route 1a reuses skia-pack's published `windows-x64` MSVC `.lib`. (1b: reuse the
|
||||||
|
MSYS2 `mingw-w64-skia` PKGBUILD + patches at skiko's pinned milestone; backends
|
||||||
|
CPU-raster + `skia_use_gl`, `skia_use_freetype`.)
|
||||||
|
|
||||||
|
**B. `bitsycore/skiko` fork — the bulk, medium risk.**
|
||||||
|
1. Add `mingwX64()` + a `configureNativeTarget(OS.Windows, Arch.X64, …)` branch;
|
||||||
|
replace the `throw GradleException("$os not yet supported")` / empty
|
||||||
|
`linkerFlags` fall-throughs in `NativeTasksConfiguration.kt` with a Windows
|
||||||
|
branch (PE/COFF, `llvm-ar`).
|
||||||
|
2. Compile the **existing** `nativeJsMain/cpp` shim (not the JVM JNI bridge)
|
||||||
|
into `skiko.dll`; make `SKIKO_EXPORT` export the flat symbols
|
||||||
|
(`__declspec(dllexport)` or `--export-all-symbols`/def file).
|
||||||
|
3. Generate the GNU import lib (`gendef` + `dlltool` → `libskiko.dll.a`).
|
||||||
|
4. cinterop `.def` stays header-less (`linkerOpts`/`libraryPaths`), matching the
|
||||||
|
repo's own idiom. Publish mingwX64 klibs + the DLL.
|
||||||
|
|
||||||
|
**C. Windows context actual — deferred behind milestone 1.**
|
||||||
|
5. **Milestone 1:** wire only the CPU-raster `SkiaSurfaceBridge` (already
|
||||||
|
Windows-ready).
|
||||||
|
6. **Milestone 2:** author `SkiaD3D11Bridge` / `SkiaVulkanBridge` — extract HWND
|
||||||
|
via `SDL_GetWindowProperties`, wrap `DirectContext.makeD3D11`/`makeVulkan`,
|
||||||
|
mirror the Metal/GL bridges' per-frame acquire/present.
|
||||||
|
|
||||||
|
**D. This repo's wiring — small, low risk (integration points confirmed).**
|
||||||
|
7. `compose/ui/ui/build.gradle.kts` — flip `isSkiaTarget("mingwX64")` to `true`
|
||||||
|
(currently hardcoded `false`, ~line 58); create a `skikoRendererMingwMain`
|
||||||
|
and `mingwX64Main.dependsOn(...)` it (today mingw attaches only to
|
||||||
|
`sdlRendererMingwMain`, lines ~203–206; the skiko tree, lines ~220–235, is
|
||||||
|
created only under `!useSdl3Everywhere` and never for mingw). Add the fork's
|
||||||
|
coords to that source set.
|
||||||
|
8. **No new expect/actual needed:** `createRenderBackend` /
|
||||||
|
`rendererPreferredGpuMode` are `expect`s in `nativeMain` with `actual`s in
|
||||||
|
`skikoRendererMain` and `sdlRendererMain`; attaching mingwX64 to the skiko
|
||||||
|
source set makes the Skia actual resolve for Windows (one renderer source set
|
||||||
|
per target — the existing invariant).
|
||||||
|
9. Add a Windows GPU-bridge branch to `rendererPreferredGpuMode()`; verify with
|
||||||
|
the demo/apidemo (`--gpu`, `--screenshot`) and the parity harness.
|
||||||
|
|
||||||
|
Repo surface to cover is narrow: **~67 `org.jetbrains.skia` classes + 5
|
||||||
|
`org.jetbrains.skiko.node` APIs** (`RenderNode`, `RenderNodeContext`,
|
||||||
|
`SkikoRenderDelegate`, `SystemTheme`, `currentSystemTheme`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The one spike to run first (~1–2 days)
|
||||||
|
|
||||||
|
Before any fork work, **empirically test the K/N-mingw link path**: build a
|
||||||
|
trivial `extern "C"` C++ DLL (one function returning a handle + one that uses
|
||||||
|
it) — or consume MSYS2's `mingw-w64-x86_64-skia` DLL + `libskia.dll.a`
|
||||||
|
directly — from a throwaway mingwX64 K/N project via cinterop + import lib, and
|
||||||
|
**run it**. This answers, in a day instead of a month:
|
||||||
|
|
||||||
|
- Does K/N mingw link + call a GNU-ABI DLL via an import lib at all?
|
||||||
|
- Does **KT-65671** reproduce on Kotlin 2.4.0 (import-lib vs static fusion)?
|
||||||
|
- DLL vs static-fusion behaviour on this toolchain.
|
||||||
|
|
||||||
|
If green, Routes 1/2 are de-risked. (Caveat: MSYS2 Skia exports Skia's *mangled
|
||||||
|
C++* symbols, so a direct cinterop of it tests only the *link mechanism*, not
|
||||||
|
the skiko API — for the API you still need the extern-C shim. The trivial-C-DLL
|
||||||
|
variant is the cleaner mechanism test.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Biggest risks / unknowns
|
||||||
|
|
||||||
|
1. **KT-65671** — whether K/N's mingw link works on 2.4.0 for external C++;
|
||||||
|
the DLL route sidesteps it, the static route hits it. *The single fact that
|
||||||
|
swings the effort.*
|
||||||
|
2. **Toolchain matching** — Konan bundles LLVM 16 clang + `ld.lld`; the C++20
|
||||||
|
Skia + shim must be built by a toolchain whose objects/imports LLD accepts.
|
||||||
|
3. **CRT / ownership discipline** across the C boundary (only matters for 1a).
|
||||||
|
4. **Maintenance** — SKIKO-446 / SKIKO-611 are Open, unassigned → almost
|
||||||
|
certainly a **permanent `bitsycore/skiko` fork** with per-bump re-sync tax.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Pursue **Route 1a** (skiko fork producing a DLL with a flat extern-C export over
|
||||||
|
skia-pack's existing MSVC Windows Skia), **scoped CPU-raster first** (Route 3
|
||||||
|
milestone), GPU bridge second. It matches the user's accepted DLL tolerance,
|
||||||
|
requires **zero Skia fork**, and **dodges KT-65671**. Keep Route 2 (static,
|
||||||
|
GNU-ABI) in reserve only if reclaiming the no-DLL invariant later becomes
|
||||||
|
valuable. **Run the MSYS2/trivial-DLL spike before committing to any fork.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation status — WORKING END-TO-END ✅ (2026-07-24)
|
||||||
|
|
||||||
|
**`demo.exe` (mingwX64) renders the Material 3 Buttons screen through real Skia**
|
||||||
|
and wrote a 1000×700 screenshot ("settled at frame 3"), using the CPU-raster
|
||||||
|
`SkiaSurfaceBridge` backed by the forked `skiko-windows-x64.dll`. Route 1a is
|
||||||
|
complete. Build/run:
|
||||||
|
|
||||||
|
```
|
||||||
|
# fork (C:/Dev/skiko), one-time env: SKIKO_VSBT_PATH=<VS BuildTools dir>
|
||||||
|
./gradlew :skiko:publishKotlinMultiplatformPublicationToMavenLocal \
|
||||||
|
:skiko:publishMingwX64PublicationToMavenLocal -Pskiko.native.windows.enabled=true
|
||||||
|
# compose repo
|
||||||
|
./gradlew :demo:linkDebugExecutableMingwX64 -PwindowsSkia=true
|
||||||
|
cp <fork>/skiko/build/out/link/Release-windows-native-x64/skiko-windows-x64.dll <demo exe dir>/
|
||||||
|
<demo exe dir>/demo.exe --screenshot=out.bmp --screen=Buttons
|
||||||
|
```
|
||||||
|
|
||||||
|
Fork fixes needed beyond the DLL build (skiko uses a CUSTOM hierarchy template,
|
||||||
|
`applyDefaultHierarchyTemplate=false`):
|
||||||
|
1. `sourceHierarchy.kt` — add `group("windows"){ withMingwX64() }` under `native`
|
||||||
|
(else the mingwX64 klib compiles EMPTY — missing all of `org.jetbrains.skia`).
|
||||||
|
2. `SkikoProjectContext` — add `supportNativeWindows` into `supportAnyNative` so
|
||||||
|
the `@SymbolName` opt-in reaches the mingw compile.
|
||||||
|
3. `Resources.native.kt` — `ftell` is 32-bit `Int` on Windows (LLP64); `==` fix.
|
||||||
|
4. `windowsMain` actuals for `SkiaLayer` / `currentSystemTheme` (linux is a stub).
|
||||||
|
5. Force-export the 2 ICU symbols (`uloc_getDefault_skiko`,
|
||||||
|
`uloc_toLanguageTag_skiko`) that Kotlin binds via `@SymbolName`.
|
||||||
|
|
||||||
|
Compose-repo wiring (all behind `-PwindowsSkia=true`): `mavenLocal()` in
|
||||||
|
settings, `isSkiaTarget("mingwX64")`, a separate `skikoRendererMingwMain` tree
|
||||||
|
on the fork's **root** coord (`org.jetbrains.skiko:skiko:0.0.0-SNAPSHOT` — the
|
||||||
|
platform artifact alone doesn't expose api-elements), `PlatformGpu` Windows →
|
||||||
|
`Software`, and a one-line `PlatformGpu.mingw.kt`.
|
||||||
|
|
||||||
|
## Earlier milestone — fork BUILDS ✅ (2026-07-24)
|
||||||
|
|
||||||
|
Route 1a is not just designed — the `bitsycore/skiko` fork now **builds the
|
||||||
|
native Windows Skia DLL + a mingwX64 klib** on this machine (`C:/Dev/skiko`,
|
||||||
|
skiko `v0.150.1`).
|
||||||
|
|
||||||
|
Edits made (all in the fork):
|
||||||
|
- `src/nativeJsMain/cpp/common.h` — `SKIKO_EXPORT` gains `__declspec(dllexport)`
|
||||||
|
under a `SKIKO_WINDOWS_DLL` guard.
|
||||||
|
- `skiko/build.gradle.kts` — `configureNativeTarget(OS.Windows, Arch.X64,
|
||||||
|
mingwX64())`, gated behind `-Pskiko.native.windows.enabled=true`.
|
||||||
|
- `buildSrc/.../NativeTasksConfiguration.kt` — a Windows compile branch
|
||||||
|
(clang-cl + `-DSKIKO_WINDOWS_DLL`) and a new `configureWindowsNativeTarget`
|
||||||
|
that links `skiko-windows-x64.dll` via `lld-link` (reusing the JVM template +
|
||||||
|
`resolveBinaryInputs(…, TargetEnv.JVM, …)`), auto-generates the GNU import lib
|
||||||
|
(`dumpbin`→`.def`→`dlltool`), and wires the K/N cinterop `linkerOpts`.
|
||||||
|
|
||||||
|
Results:
|
||||||
|
- The native bridge (`nativeJsMain/cpp`) **compiled on Windows with clang-cl,
|
||||||
|
zero source changes.** The only link fix was adding `d3d12.lib
|
||||||
|
d3dcompiler.lib dxgi.lib` for Skia's `SK_DIRECT3D` backend.
|
||||||
|
- Produced **`skiko-windows-x64.dll` (14.2 MB, 1005 exported
|
||||||
|
`org_jetbrains_skia_*` symbols)**, `libskiko-windows-x64.dll.a` (GNU import
|
||||||
|
lib), and the **mingwX64 klib** (`build/classes/kotlin/mingwX64/main/klib`).
|
||||||
|
|
||||||
|
Prerequisite for any skiko Windows build on this box:
|
||||||
|
`SKIKO_VSBT_PATH='C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools'`
|
||||||
|
(Gradle's VS locator doesn't auto-find VS 18 BuildTools).
|
||||||
|
|
||||||
|
Remaining: publish the fork to `mavenLocal` → a tiny K/N mingw consumer that
|
||||||
|
calls a real Skia function (links the import lib, runs with the DLL on `PATH`)
|
||||||
|
for the definitive end-to-end proof → then wire the compose repo (flip
|
||||||
|
`isSkiaTarget("mingwX64")`, add `skikoRendererMingwMain`, ship the DLL next to
|
||||||
|
the exe, CPU-raster `SkiaSurfaceBridge` first). One known TODO: the klib's
|
||||||
|
`linkerOpts` use an absolute import-lib path (fine same-machine; make relative /
|
||||||
|
bundled for distribution).
|
||||||
|
|
||||||
|
## Corrections to RENDERER.md §4
|
||||||
|
|
||||||
|
The shelving note should be updated — its two central technical claims are stale
|
||||||
|
as of 2026-07:
|
||||||
|
|
||||||
|
- "GNU-ABI Skia is an open-ended fork / only abandoned Mozilla-era precedent" —
|
||||||
|
**false**: MSYS2 maintains a modern (m143, C++20) GNU-ABI Skia, rebuilt
|
||||||
|
routinely.
|
||||||
|
- The accurate residual blocker is **the K/N-side link/binding port**
|
||||||
|
(KT-65671 + authoring the mingwX64 skiko target), **not** building Skia, and
|
||||||
|
the DLL route is *risk-reducing*, not merely "the only working route."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources (primary, load-bearing)
|
||||||
|
|
||||||
|
- skiko v0.150.1: `skiko/build.gradle.kts`, `buildSrc/.../NativeTasksConfiguration.kt`,
|
||||||
|
`src/commonMain/kotlin/org/jetbrains/skia/Data.kt`,
|
||||||
|
`src/nativeMain/kotlin/org/jetbrains/skia/Actuals.native.kt`,
|
||||||
|
`src/nativeJsMain/cpp/common.h`, `.../impl/Library.native.kt`
|
||||||
|
- skia-pack `script/build.py`; skia.org build docs
|
||||||
|
- Kotlin `konan.properties` (v2.2.0); kotlinlang native-c-interop / target-support
|
||||||
|
- LLVM issue #60847; **KT-65671**; **SKIKO-446**, **SKIKO-611**
|
||||||
|
- MSYS2 `mingw-w64-skia` (packages.msys2.org; MINGW-packages PKGBUILD + patches)
|
||||||
|
- Vaporware forks: github.com/Cdm2883/skiko-mingw64, crowforkotlin/skiko-mingw64
|
||||||
|
- Repo: `compose/ui/ui/build.gradle.kts`, the `SkiaMetalBridge` / `SkiaGLBridge`
|
||||||
|
/ `SkiaSurfaceBridge` / `RenderBackendFactory` seam
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
Everything the port currently leaves as a no-op, stub, hardcode, or partial
|
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
|
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` /
|
is an audit of PROJECT code only (the `*.native.kt` / `com.compose.sdl.*`
|
||||||
`com.compose.sdl.*` actuals); vendored upstream under `src/vendor/` is out of
|
actuals); vendored upstream under `src/vendor/` is out of scope.
|
||||||
scope.
|
|
||||||
|
|
||||||
Severity is a rough guide, not a mandate:
|
Severity is a rough guide, not a mandate:
|
||||||
|
|
||||||
@@ -14,10 +13,10 @@ Severity is a rough guide, not a mandate:
|
|||||||
- **Cosmetic**: minor fidelity or edge-case behavior.
|
- **Cosmetic**: minor fidelity or edge-case behavior.
|
||||||
|
|
||||||
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
|
Context for prioritizing: the project goal is **G1, cheap upstream-tracking**
|
||||||
(see [RENDERER.md](RENDERER.md)). The **JVM Compose Desktop target is the
|
(see [RENDERER.md](RENDERER.md)). There is now ONE renderer — **Skia**
|
||||||
documented Windows fidelity/feature tier**, so SDL-leg visual gaps (section H)
|
(macOS/Linux official Skiko; mingwX64 the bitsycore skiko fork), so Windows
|
||||||
are lower priority than the cross-cutting platform gaps that affect every
|
renders through real Skia. The priorities below are the cross-cutting platform
|
||||||
backend including Skia on macOS/Linux.
|
gaps that affect every OS. JVM Compose Desktop remains a parity cross-check.
|
||||||
|
|
||||||
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
|
Renderer-internal cleanups (the deferred D2 shadow-lighting split, D3 matrix
|
||||||
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
|
dedupe) live in [RENDERER.md](RENDERER.md#6-remaining-and-future-work), not
|
||||||
@@ -37,28 +36,13 @@ Platform / cross-cutting:
|
|||||||
via SDL (I-beam over text, hand over links).
|
via SDL (I-beam over text, hand over links).
|
||||||
- **WindowInfo** (`bad23073`) — `isWindowFocused` + `containerSize` / `containerDpSize`
|
- **WindowInfo** (`bad23073`) — `isWindowFocused` + `containerSize` / `containerDpSize`
|
||||||
report real values (were hardcoded `true` / `Zero`).
|
report real values (were hardcoded `true` / `Zero`).
|
||||||
- **ImageBitmap from bytes** (SDL leg) — `ByteArray.decodeToImageBitmap()` /
|
- **ImageBitmap from bytes** — `ByteArray.decodeToImageBitmap()` /
|
||||||
`createImageBitmap(bytes)` decodes via SDL3_image; was
|
`createImageBitmap(bytes)` decodes encoded image bytes; was
|
||||||
`UnsupportedOperationException`. Verified `demo --imagebytestest` on both legs.
|
`UnsupportedOperationException`. Verified `demo --imagebytestest`.
|
||||||
- **`FontFamily.Monospace`** — generic families map to `"generic:<name>"`;
|
- **`FontFamily.Monospace`** — generic families map to `"generic:<name>"`;
|
||||||
`registerGenericFonts` registers bundled NotoSansMono under `generic:monospace`
|
`registerGenericFonts` registers bundled NotoSansMono under `generic:monospace`
|
||||||
(bundled only when the app references it). Verified `demo --fonttest`.
|
(bundled only when the app references it). Verified `demo --fonttest`.
|
||||||
|
|
||||||
SDL renderer fidelity (all verified against the Skia leg via `demo --<name>test`):
|
|
||||||
|
|
||||||
- **DashPathEffect** (`--dashtest`), **gradient TileMode** repeat/mirror/decal
|
|
||||||
(`--tilemodetest`), **drawPoints/drawRawPoints** Points/Lines/Polygon
|
|
||||||
(`--pointstest`), **rotated images** via textured `SDL_RenderGeometry`
|
|
||||||
(`--rotimgtest`).
|
|
||||||
- **BlendMode** (`774271eb`, `--blendtest`) — Plus/Modulate/Multiply/Src via SDL
|
|
||||||
hardware blend (`withBlend`); other separable modes fall back to SrcOver.
|
|
||||||
- **Stroke joins + Square cap** (`3518521c`, `--jointest`) — miter/bevel/round
|
|
||||||
(`emitJoin`) + round/square end caps (`emitCap`), pixel-identical to Skia.
|
|
||||||
- **ColorFilter on shapes** (`169a51f0`, `--filtertest`) — tint / colorMatrix
|
|
||||||
(grayscale/saturation) / lighting per-vertex; pixel-exact vs Skia.
|
|
||||||
- **Stroked elliptical ovals** (`efaf20d9`, `--ovaltest`) — true ellipse band, not
|
|
||||||
a circular ring on the averaged radius.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Release-blocking shortlist for stable 1.12
|
## Release-blocking shortlist for stable 1.12
|
||||||
@@ -70,7 +54,7 @@ The cross-cutting items to weigh first (all detailed below):
|
|||||||
menu, upstream TODO CMP-7819).
|
menu, upstream TODO CMP-7819).
|
||||||
2. **Accessibility is entirely absent** (section A). Decide whether stable
|
2. **Accessibility is entirely absent** (section A). Decide whether stable
|
||||||
requires any screen-reader support at all.
|
requires any screen-reader support at all.
|
||||||
3. Fonts (section D): compose-resources `Font(Res.font.x)` works on both legs, and
|
3. Fonts (section D): compose-resources `Font(Res.font.x)` works, and
|
||||||
`FontFamily.Monospace` now renders NotoSansMono. Remaining: `Serif`/`Cursive`
|
`FontFamily.Monospace` now renders NotoSansMono. Remaining: `Serif`/`Cursive`
|
||||||
generics (no bundled font) and the androidx `Font(bytes)`/`PlatformFontLoader`
|
generics (no bundled font) and the androidx `Font(bytes)`/`PlatformFontLoader`
|
||||||
path. Nice-to-have, not a blocker.
|
path. Nice-to-have, not a blocker.
|
||||||
@@ -116,9 +100,8 @@ calendar/clock/number prefs). These require a bundled CLDR subset or a K/N i18n
|
|||||||
## D. Font resolution
|
## D. Font resolution
|
||||||
|
|
||||||
**Custom fonts via compose-resources work.** `org.jetbrains.compose.resources.Font(Res.font.x)`
|
**Custom fonts via compose-resources work.** `org.jetbrains.compose.resources.Font(Res.font.x)`
|
||||||
loads the bytes and registers them with the project font registry (IconFont → NamedFont)
|
loads the bytes and registers them with the project font registry (IconFont → NamedFont),
|
||||||
on both renderer legs (`FontResources.sdl.kt`, shared by the skiko leg via a srcDir
|
so the standard CMP way to bundle a font renders correctly.
|
||||||
alias), so the standard CMP way to bundle a font renders correctly.
|
|
||||||
|
|
||||||
- `FontFamily.Serif` / `FontFamily.Cursive` still collapse to the default sans — no bundled
|
- `FontFamily.Serif` / `FontFamily.Cursive` still collapse to the default sans — no bundled
|
||||||
serif/cursive font yet (`downloadNotoFonts` fetches Sans + SansMono only). Register one under
|
serif/cursive font yet (`downloadNotoFonts` fetches Sans + SansMono only). Register one under
|
||||||
@@ -149,56 +132,29 @@ Drop INTO the window (files + text) is fully wired and works. Drag OUT does not.
|
|||||||
- `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/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.**
|
- `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.**
|
||||||
|
|
||||||
## H. SDL renderer graphics (Windows / `-Prenderer=sdl3` only)
|
## H. Skia renderer anomalies
|
||||||
|
|
||||||
These affect only the SDL leg. On macOS/Linux the Skia leg implements them
|
Skia is the renderer everywhere, so its bugs are rarely tracked here. This one
|
||||||
correctly, and JVM is the documented Windows fidelity tier, so these are lower
|
surfaced while adding BlendMode coverage (`demo --blendtest`):
|
||||||
priority under G1. Listed because SDL is the shipped Windows renderer. (Several
|
|
||||||
SDL fidelity gaps are now closed — see **Completed** above.)
|
|
||||||
|
|
||||||
The biggest remaining value here is the **offscreen-layer work** (blur + real
|
- `BlendMode.Multiply` renders wrong. A filled `drawRect` with opaque
|
||||||
`saveLayer`); the two are the same infrastructure and warrant a dedicated pass
|
`Color.Cyan` over opaque `Color.Yellow` reads back `(0,0,255)` blue; the
|
||||||
with the full parity + verify-mac gate, not a probe.
|
multiply formula can only yield `(0,255,0)` green for opaque cyan×yellow.
|
||||||
|
`Plus` and `Modulate` composite correctly on the same path, so
|
||||||
- **Blur / RenderEffect** — `compose/ui/ui/src/sdlRendererMain/.../graphics/RenderEffect.sdl.kt:16` `RenderEffect.isSupported()` is `false`; `Modifier.blur()` and `graphicsLayer{renderEffect=...}` do nothing; `.../graphics/shadow/Blur.native.kt:33` `Paint.setBlurFilter` NOP. Needs an offscreen render target + a blur pass (box-blur infra already exists in `Sdl3ShadowCache`). **Nice-to-have (Windows visual parity).**
|
`paint.blendMode` IS applied — the fault is specific to `MULTIPLY` (likely a
|
||||||
- **`saveLayer` has no offscreen buffer** — `Sdl3Canvas.kt:436`: 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. Same offscreen infra as blur. **Nice-to-have (blocker for correct group-alpha over overlapping content).**
|
Metal-backend / premultiply interaction in the graphics-layer flatten, not
|
||||||
- **`clipPath` degrades to a bounding box** — `Sdl3Canvas.kt:509`: an arbitrary path clip collapses to its AABB; a rotated/sheared `clipRect` also collapses to an AABB, so non-rect clips leak. (Rounded/difference clips are real, with feathered AA.) **Nice-to-have.**
|
the `BlendMode.toSkia()` map, which is correct). Needs isolating on the Skia
|
||||||
- **`ActualImageShader` / `ActualCompositeShader` are stubs** — `CanvasPaintActuals.native.kt:106`: an image/composite `ShaderBrush` degrades to a solid white/black fill (the tessellator emits per-vertex colours, not UVs). **Nice-to-have.**
|
draw path. **Nice-to-have.**
|
||||||
- **`PathFillType` (NonZero vs EvenOdd)** — mostly ignored beyond the 2-contour border-ring case; interior holes and self-intersections are not cut out, and concave fills self-overlap (fan triangulation) (`Sdl3DrawScope.kt:366,717`). Needs a scanline/tessellation fill. **Nice-to-have.**
|
|
||||||
- **`drawVertices`** (custom vertex meshes) is still a NOP in the DrawScope. **Nice-to-have.**
|
|
||||||
- **ColorFilter/blur on IMAGE blits** — image blits honor only `BlendModeColorFilter` tint (`Sdl3Canvas.kt:1281`); a `ColorMatrix`/lighting filter or blur on a bitmap needs per-pixel work. Capture-mode (`graphicsLayer`) block-replay also drops the shape colorFilter. **Nice-to-have.**
|
|
||||||
- **`SdlImageBitmap.readPixels` is a no-op** (`Sdl3Offscreen.kt:117`) — can't read back an offscreen-rendered bitmap. **Cosmetic / nice-to-have.**
|
|
||||||
- **Drop shadow is approximated** (`Sdl3Canvas.kt:785`) — 9-slice / stacked rings, not a true gaussian; ambient vs spot largely collapsed; AA is a ~1px geometry fringe, not analytic coverage. **Cosmetic.**
|
|
||||||
- `DashPathEffect` `corner`/`chain`/`stamped` variants remain NOP (only the interval dash is implemented). **Cosmetic.**
|
|
||||||
|
|
||||||
## I. Skia renderer (macOS/Linux default) anomalies
|
|
||||||
|
|
||||||
The Skia leg is normally the fidelity reference, so its bugs are rarely tracked
|
|
||||||
here. This one surfaced while adding SDL BlendMode parity (`demo --blendtest`):
|
|
||||||
|
|
||||||
- `BlendMode.Multiply` renders wrong on the Skia leg. A filled `drawRect` with
|
|
||||||
opaque `Color.Cyan` over opaque `Color.Yellow` reads back `(0,0,255)` blue;
|
|
||||||
the multiply formula can only yield `(0,255,0)` green for opaque cyan×yellow,
|
|
||||||
and the SDL leg produces exactly that. `Plus` and `Modulate` composite
|
|
||||||
correctly on the same path, so `paint.blendMode` IS applied — the fault is
|
|
||||||
specific to `MULTIPLY` (likely a Metal-backend / premultiply interaction in
|
|
||||||
the graphics-layer flatten, not the `BlendMode.toSkia()` map, which is
|
|
||||||
correct). Needs isolating on the Skia draw path. **Nice-to-have (Skia leg).**
|
|
||||||
|
|
||||||
## J. Needs further review (2026-07 bug audit)
|
## J. Needs further review (2026-07 bug audit)
|
||||||
|
|
||||||
An audit (3 parallel passes over the renderer, recent SDL features, and the
|
An audit (parallel passes over the renderer and the text/input/event actuals)
|
||||||
text/input/event actuals) surfaced these. The clear, contained bugs were fixed
|
surfaced these. The clear, contained bugs were fixed in the same pass; the items
|
||||||
in the same pass (see below); the items here are left open because they are
|
here are left open because they are either invasive, risky, or a judgement call.
|
||||||
either invasive, risky, or a cosmetic-AA judgement call.
|
(The renderer-side fixes from that pass predate the single-Skia consolidation and
|
||||||
|
are no longer tracked here.)
|
||||||
|
|
||||||
Fixed in the audit (for reference, not open work): the `roundRectCore`
|
Fixed in the audit (for reference, not open work): `Locale.region` no longer
|
||||||
inverted-rect crash; `drawArc(useCenter=false)` now fills the segment (was always
|
|
||||||
a pie sector); `BlendMode.Src` no longer punches the AA fringe out via
|
|
||||||
`SDL_BLENDMODE_NONE`; the cover-fill fast-path no longer drops the paint's
|
|
||||||
`colorFilter`; `saveLayer` in capture mode keeps the save/restore stack balanced;
|
|
||||||
stroked oval / round-rect inner radius is clamped ≥ 0; the per-vertex
|
|
||||||
`ColorMatrix` copy is hoisted out of the sample loop; `Locale.region` no longer
|
|
||||||
returns the language for a single-subtag tag; and a `MOUSE_LEAVE` now clears
|
returns the language for a single-subtag tag; and a `MOUSE_LEAVE` now clears
|
||||||
hover so a widget doesn't stay highlighted after the cursor exits.
|
hover so a widget doesn't stay highlighted after the cursor exits.
|
||||||
|
|
||||||
@@ -208,10 +164,3 @@ Open — platform / input:
|
|||||||
- **A window created unfocused is still promoted to RESUMED** (`ComposeWindow.kt:575-576, 713-719`). `windowFocused`/`windowVisible` default `true`, so a second `Window {}` opened while another holds focus (or one opened minimized) reports `RESUMED` until SDL later delivers a focus/minimize event. Should query `SDL_GetWindowFlags` at creation. **Review.**
|
- **A window created unfocused is still promoted to RESUMED** (`ComposeWindow.kt:575-576, 713-719`). `windowFocused`/`windowVisible` default `true`, so a second `Window {}` opened while another holds focus (or one opened minimized) reports `RESUMED` until SDL later delivers a focus/minimize event. Should query `SDL_GetWindowFlags` at creation. **Review.**
|
||||||
- **IME text-input area is only pushed on the first `TEXT_EDITING`** (`ComposeWindow.kt:803`; `updateImeArea()` not called on focus gain / first `StartTextInput` / caret move). The first candidate popup can appear at (0,0) before correcting. The rect math itself is correct. **Review.**
|
- **IME text-input area is only pushed on the first `TEXT_EDITING`** (`ComposeWindow.kt:803`; `updateImeArea()` not called on focus gain / first `StartTextInput` / caret move). The first candidate popup can appear at (0,0) before correcting. The rect math itself is correct. **Review.**
|
||||||
- **`Snapshot.sendApplyNotifications()` is called synchronously inside the global write observer** (`ComposeWindow.kt:143-146`). Compose Desktop's `GlobalSnapshotManager` deliberately defers it to a separate dispatch; calling it per-write is redundant (the main loop already calls it each frame) and risks re-entrancy during snapshot application. Verify against the runtime's guard before changing — behaviour-sensitive. **Review (risky).**
|
- **`Snapshot.sendApplyNotifications()` is called synchronously inside the global write observer** (`ComposeWindow.kt:143-146`). Compose Desktop's `GlobalSnapshotManager` deliberately defers it to a separate dispatch; calling it per-write is redundant (the main loop already calls it each frame) and risks re-entrancy during snapshot application. Verify against the runtime's guard before changing — behaviour-sensitive. **Review (risky).**
|
||||||
|
|
||||||
Open — SDL renderer (cosmetic AA / draw-time):
|
|
||||||
|
|
||||||
- **Stroke joins are not antialiased and reach ~0.5px past the band** (`Sdl3DrawScope.kt` `emitJoin`). The segment band feathers to `halfW − kAaHalf` solid + fringe, but joins fill solid to full `halfW` with a hard edge, so corners read as slightly darker/crisper nubs — visible on thin or translucent strokes. Needs feathered join geometry. **Review (cosmetic).**
|
|
||||||
- **Round join over-draws the stroke body** (`Sdl3DrawScope.kt` `emitJoin`, `StrokeJoin.Round`): a full disc overlaps the adjacent segments, so a semi-transparent stroke double-blends darker at each corner. Fill only the convex wedge. **Review (cosmetic).**
|
|
||||||
- **Text / icon draws ignore the draw-time `colorFilter`** — `activeColorFilter` is consulted only by the tessellation sampler; glyph/icon runs blit through the text renderer with a pre-resolved colour, so a `BlendModeColorFilter` tint applied at draw time to a `Text`/icon node isn't honored (acceptable if callers pre-fold the tint into the glyph colour). **Review.**
|
|
||||||
- **Rounded-clip containment ignores stroke half-width** (`Sdl3Canvas.kt` `admitDraw`/`tryDrawRectUnderPendingClips`): a stroked shape whose fill-box is inside a pending rounded clip but whose stroke pokes past it skips mask realization, so the stroke's outer half is bounded only by the AABB, not the rounded outline. Marginal (only bites at the exact clip boundary). **Review.**
|
|
||||||
|
|||||||
+23
-16
@@ -8,18 +8,18 @@ For architecture, source-set layout, and vendoring rules, see
|
|||||||
|
|
||||||
## Native libraries
|
## Native libraries
|
||||||
|
|
||||||
SDL3, SDL3_ttf, SDL3_image, and FreeType are built from source as static
|
SDL3 is built from source as a static library and linked straight into the
|
||||||
libraries and linked straight into the executable. One script does it on every
|
executable — the windowing, input, and platform-integration layer. One script
|
||||||
OS. Output lands in the gitignored `libs/`; versions are pinned in
|
does it on every OS. Output lands in the gitignored `libs/`; the version is
|
||||||
`scripts/build-sdl/build-sdl.properties`.
|
pinned in `scripts/build-sdl/build-sdl.properties`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/build-sdl/build-all.py # freetype, sdl3, sdl3-image, sdl3-ttf
|
python3 scripts/build-sdl/build-all.py # sdl3
|
||||||
python3 scripts/build-sdl/build-all.py sdl3-ttf # rebuild one step
|
python3 scripts/build-sdl/build-all.py sdl3 # rebuild the step
|
||||||
```
|
```
|
||||||
|
|
||||||
Needs `git`, `cmake`, and Python 3 on every host (`ninja` is fetched when
|
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.
|
absent). Run it once per machine, or after bumping the pinned version.
|
||||||
|
|
||||||
## Vendoring upstream Compose
|
## Vendoring upstream Compose
|
||||||
|
|
||||||
@@ -63,16 +63,16 @@ python3 scripts/compose-coverage.py --missing ui-text # list uncovered decl
|
|||||||
### One-command gate
|
### One-command gate
|
||||||
|
|
||||||
`verify-mac.sh` is the runbook to run after any renderer or layout change. On
|
`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
|
macOS or Linux it exercises the Skia renderer and exits non-zero on any
|
||||||
`-Prenderer=sdl3`) and exits non-zero on any failure:
|
failure:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/verify-mac.sh
|
scripts/verify-mac.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
It runs, per leg: the vendor drift and clean checks, a build of `:demo` and
|
It runs: the vendor drift and clean checks, a build of `:demo` and `:apidemo`,
|
||||||
`:apidemo`, the interaction probes, the parity sweep, a memory soak, and a
|
the interaction probes, the parity sweep, a memory soak, and a frame-time spot
|
||||||
frame-time spot check. The Windows target is verified separately (see below).
|
check. The Windows target is verified separately (see below).
|
||||||
|
|
||||||
### Parity: native vs JVM
|
### Parity: native vs JVM
|
||||||
|
|
||||||
@@ -135,6 +135,13 @@ gradlew.bat :demo:runDebugExecutableMingwX64
|
|||||||
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
gradlew.bat :apidemo:runDebugExecutableMingwX64
|
||||||
```
|
```
|
||||||
|
|
||||||
|
mingwX64 renders through Skia via the **bitsycore skiko fork**, consumed from
|
||||||
|
GitHub Packages as `org.jetbrains.skiko:skiko:0.150.1-mingw.1` (macOS/Linux use
|
||||||
|
official Skiko). The runtime `skiko-windows-x64.dll` is auto-provisioned next
|
||||||
|
to the executable by the bridge plugin — no manual copy. The fork itself is
|
||||||
|
published by a separate GitHub Actions workflow in the fork repo, out of band
|
||||||
|
from this repo's release flow.
|
||||||
|
|
||||||
## Consuming the port
|
## Consuming the port
|
||||||
|
|
||||||
To build a third-party app against the published klibs, apply the bridge Gradle
|
To build a third-party app against the published klibs, apply the bridge Gradle
|
||||||
@@ -154,8 +161,8 @@ single file to edit; know which axis you are changing.
|
|||||||
| 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`. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Skiko | `skiko` in `gradle/libs.versions.toml`. | macOS/Linux use official Skiko; mingwX64 uses the bitsycore fork (`0.150.1-mingw.1` from GitHub Packages), published out of band by the fork repo's own workflow. 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. |
|
| SDL3 | `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. |
|
| 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
|
### Bump the upstream Compose ref
|
||||||
@@ -168,8 +175,8 @@ Run this on each upstream bump; it is the flow that keeps the sync tax low.
|
|||||||
3. `python3 scripts/compose-fork/check-vendor-drift.py`. For any manual vendor
|
3. `python3 scripts/compose-fork/check-vendor-drift.py`. For any manual vendor
|
||||||
whose upstream base actually changed, reconcile by hand; otherwise re-stamp
|
whose upstream base actually changed, reconcile by hand; otherwise re-stamp
|
||||||
its `// VENDOR-BASE:` header to the new ref.
|
its `// VENDOR-BASE:` header to the new ref.
|
||||||
4. Build both legs and fix any fallout.
|
4. Build and fix any fallout.
|
||||||
5. `scripts/verify-mac.sh` (both legs green, including the soak and parity gates).
|
5. `scripts/verify-mac.sh` (green, including the soak and parity gates).
|
||||||
6. If a matching Compose version is now published to Maven, bump
|
6. If a matching Compose version is now published to Maven, bump
|
||||||
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
|
`vComposeJvmVersion` in demo/apidemo/material-symbols to close the skew.
|
||||||
7. Run WIN-SMOKE on a Windows host.
|
7. Run WIN-SMOKE on a Windows host.
|
||||||
|
|||||||
+5
-5
@@ -160,11 +160,11 @@ allprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Whether the current host can build the mingwX64 target. Kotlin/Native can
|
// Whether the current host can build the mingwX64 target. Kotlin/Native can
|
||||||
// only cross-compile mingwX64 cinterops from a Windows host — the sdl3_ttf /
|
// only cross-compile the mingwX64 sdl3 cinterop from a Windows host — it needs
|
||||||
// sdl3_image cinterops need Windows SDL3 headers under libs/ (produced by
|
// the Windows SDL3 headers under libs/ (produced by scripts/build-sdl/build-all.py
|
||||||
// scripts/build-sdl/build-all.py run on a Windows host). Declaring `mingwX64()` on
|
// run on a Windows host). Declaring `mingwX64()` on a non-Windows host is safe
|
||||||
// a non-Windows host is safe for pure-Kotlin modules but blows up the moment
|
// for pure-Kotlin modules but blows up the moment the sdl3 cinterop tries to
|
||||||
// a `depends = sdl3` cinterop tries to include SDL3_ttf/SDL_ttf.h.
|
// include SDL3's headers.
|
||||||
// Override with `-PforceMingw=true` if you actually have the headers wired.
|
// Override with `-PforceMingw=true` if you actually have the headers wired.
|
||||||
val vHostSupportsMingw = System.getProperty("os.name").startsWith("Windows") ||
|
val vHostSupportsMingw = System.getProperty("os.name").startsWith("Windows") ||
|
||||||
(findProperty("forceMingw") as? String)?.toBoolean() == true
|
(findProperty("forceMingw") as? String)?.toBoolean() == true
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
// Maven artifact ships no mingwX64/linux klibs. Public API is byte-for-byte
|
// Maven artifact ships no mingwX64/linux klibs. Public API is byte-for-byte
|
||||||
// upstream (painterResource / stringResource / Font / qualifiers / Res codegen
|
// upstream (painterResource / stringResource / Font / qualifiers / Res codegen
|
||||||
// compatibility); the platform actuals are this port's: data.kres reading,
|
// compatibility); the platform actuals are this port's: data.kres reading,
|
||||||
// SDL3_image decoding, SDL locale/theme environment. Apps' JVM targets keep
|
// image decode via :ui's Skia decoder, SDL locale/theme environment. Apps' JVM
|
||||||
// using the official Maven artifact — this module is native-only.
|
// targets keep using the official Maven artifact — this module is native-only.
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
@@ -17,7 +17,6 @@ repositories {
|
|||||||
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
|
||||||
}
|
}
|
||||||
|
|
||||||
val useSdl3Everywhere = (findProperty("renderer") as? String) == "sdl3"
|
|
||||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -41,52 +40,11 @@ kotlin {
|
|||||||
}
|
}
|
||||||
nativeMain {
|
nativeMain {
|
||||||
kotlin.srcDir("src/vendor/native/kotlin")
|
kotlin.srcDir("src/vendor/native/kotlin")
|
||||||
}
|
// The Font / Image resource actuals — pure project code that delegates
|
||||||
|
// to :ui's IconFont / NamedFont / decodeEncodedImageBitmap (the Skia
|
||||||
// ============
|
// decoder). Renderer-agnostic and skiko-free, so a single native set
|
||||||
// Renderer roots — same conditional wiring as :ui: only the source
|
// covers every target. (Dir name is historical — .sdl.kt.)
|
||||||
// sets that will actually be attached are created.
|
|
||||||
val sdlRendererMain = create("sdlRendererMain") {
|
|
||||||
dependsOn(nativeMain.get())
|
|
||||||
kotlin.srcDir("src/vendor/sdlRenderer/kotlin")
|
|
||||||
}
|
|
||||||
if (vHostSupportsMingw) {
|
|
||||||
val sdlRendererMingwMain = create("sdlRendererMingwMain") { dependsOn(sdlRendererMain) }
|
|
||||||
get("mingwX64Main").dependsOn(sdlRendererMingwMain)
|
|
||||||
}
|
|
||||||
|
|
||||||
val macosArm64Main = get("macosArm64Main")
|
|
||||||
val linuxX64Main = get("linuxX64Main")
|
|
||||||
val linuxArm64Main = get("linuxArm64Main")
|
|
||||||
|
|
||||||
if (useSdl3Everywhere) {
|
|
||||||
val sdlRendererMacosMain = create("sdlRendererMacosMain") { dependsOn(sdlRendererMain) }
|
|
||||||
val sdlRendererLinuxMain = create("sdlRendererLinuxMain") { dependsOn(sdlRendererMain) }
|
|
||||||
macosArm64Main.dependsOn(sdlRendererMacosMain)
|
|
||||||
linuxX64Main.dependsOn(sdlRendererLinuxMain)
|
|
||||||
linuxArm64Main.dependsOn(sdlRendererLinuxMain)
|
|
||||||
} else {
|
|
||||||
// The Font/Image actuals for this module are pure project code
|
|
||||||
// (:ui's IconFont / NamedFont / decodeEncodedImageBitmap in nativeMain)
|
|
||||||
// so they work identically under either renderer. Only one renderer
|
|
||||||
// source set is attached to a given target, so pointing skikoRenderer
|
|
||||||
// at sdlRendererMain's srcDir doesn't cause duplicate actuals.
|
|
||||||
// Upstream's skikoMain vendored files here referenced Compose-Desktop
|
|
||||||
// Skia extensions (SystemFont / toComposeImageBitmap / nativeCanvas)
|
|
||||||
// that this port's :ui doesn't expose — sharing the SDL actuals sidesteps
|
|
||||||
// that gap entirely.
|
|
||||||
val skikoRendererMain = create("skikoRendererMain") {
|
|
||||||
dependsOn(nativeMain.get())
|
|
||||||
kotlin.srcDir("src/sdlRendererMain/kotlin")
|
kotlin.srcDir("src/sdlRendererMain/kotlin")
|
||||||
dependencies {
|
|
||||||
implementation(libs.skiko)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val skikoRendererMacosMain = create("skikoRendererMacosMain") { dependsOn(skikoRendererMain) }
|
|
||||||
val skikoRendererLinuxMain = create("skikoRendererLinuxMain") { dependsOn(skikoRendererMain) }
|
|
||||||
macosArm64Main.dependsOn(skikoRendererMacosMain)
|
|
||||||
linuxX64Main.dependsOn(skikoRendererLinuxMain)
|
|
||||||
linuxArm64Main.dependsOn(skikoRendererLinuxMain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
# actuals are project code:
|
# actuals are project code:
|
||||||
# src/nativeMain — ResourceReader (data.kres), getSystemEnvironment (SDL),
|
# src/nativeMain — ResourceReader (data.kres), getSystemEnvironment (SDL),
|
||||||
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser)
|
# pure-Kotlin DomXmlParser (upstream's is Darwin NSXMLParser)
|
||||||
# src/sdlRendererMain — image decode via the :ui SDL3_image hook, SVG unsupported,
|
# src/sdlRendererMain — the Font/Image resource actuals: renderer-agnostic
|
||||||
# Font via the project font registry
|
# project code (image decode via :ui's Skia decoder, Font
|
||||||
# vendor/skikoRenderer — upstream skikoMain Skia-bound files, verbatim
|
# via the project font registry). Dir name is historical;
|
||||||
|
# the build attaches it to nativeMain.
|
||||||
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
|
# Re-sync: python scripts/compose-fork/sync.py components/resources/library
|
||||||
|
|
||||||
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
|
SET_REPO=https://github.com/JetBrains/compose-multiplatform@<COMPOSE_REF>
|
||||||
@@ -54,10 +55,9 @@ skikoMain/kotlin/org/jetbrains/compose/resources/ResourceReader.skiko.kt -> src/
|
|||||||
|
|
||||||
# Skia-bound pieces — upstream's skikoMain actuals rely on Compose-Desktop Skia
|
# Skia-bound pieces — upstream's skikoMain actuals rely on Compose-Desktop Skia
|
||||||
# extensions (SystemFont / toComposeImageBitmap / nativeCanvas) this port's :ui
|
# extensions (SystemFont / toComposeImageBitmap / nativeCanvas) this port's :ui
|
||||||
# doesn't expose. Since the sdlRenderer actuals (src/sdlRendererMain/kotlin/) use
|
# doesn't expose. The module's own Font/Image actuals (src/sdlRendererMain/kotlin/,
|
||||||
# pure project APIs (IconFont / NamedFont / decodeEncodedImageBitmap) that work
|
# attached to nativeMain) use pure project APIs (IconFont / NamedFont /
|
||||||
# on either renderer, the module's build shares those with skikoRendererMain
|
# decodeEncodedImageBitmap) instead, so opt the upstream Skia-bound files out.
|
||||||
# instead of vendoring the upstream files. Opt them out of the sync.
|
|
||||||
!skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
|
!skikoMain/kotlin/org/jetbrains/compose/resources/ImageResources.skiko.kt
|
||||||
!skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
|
!skikoMain/kotlin/org/jetbrains/compose/resources/FontResources.skiko.kt
|
||||||
!skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt
|
!skikoMain/kotlin/org/jetbrains/compose/resources/SvgPainter.kt
|
||||||
|
|||||||
+20
-39
@@ -1,38 +1,31 @@
|
|||||||
package com.compose.sdl.text
|
package com.compose.sdl.text
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
import androidx.compose.ui.text.font.FontVariation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: IconText — project-only text composable for icon fonts
|
// MARK: IconText — icon-font text on the standard text path
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
A minimal project text composable that renders a codepoint through a named
|
A minimal project text composable that renders a codepoint through a named
|
||||||
icon font with optional variable-font axis settings (Material Symbols).
|
icon font with optional variable-font axis settings (Material Symbols FILL /
|
||||||
|
wght / GRAD / opsz).
|
||||||
|
|
||||||
Sits outside `androidx.compose.foundation.text.BasicText` so BasicText can
|
Now just a [BasicText]: the icon font family and its variable axes are threaded
|
||||||
remain a byte-identical match of upstream. Icons need:
|
through [namedFontFamily] (`axes = …`), which the skiko text engine reads via
|
||||||
- `fontFamily: String` — the registered icon font family name (project's
|
`FontFamily.projectFontVariations()` and applies to the typeface. So icons
|
||||||
`FontFamily.Named`, not upstream `FontFamily.Resolver`).
|
measure + draw through the same skiko `skparagraph` path as ordinary text — no
|
||||||
- `fontVariationSettings` — per-usage variable-font axis values (FILL,
|
separate renderer/measurer seam.
|
||||||
wght, GRAD, opsz for Material Symbols).
|
|
||||||
|
|
||||||
Upstream `TextStyle` has neither: fontFamily routes through the
|
Material `Icon(codepoint = …, fontFamily = …)` uses this; Text / BasicText /
|
||||||
`FontFamily.Resolver`, and axis values are set per-Font at construction
|
TextField go through the upstream-shaped path already.
|
||||||
time. Rather than fight that abstraction for one composable, icons use
|
|
||||||
this project path — the TextDrawElement modifier feeds directly into
|
|
||||||
SdlParagraph / Sdl3Canvas.drawNativeText.
|
|
||||||
|
|
||||||
Material `Icon(codepoint = ..., fontFamily = ...)` uses this. Everything
|
|
||||||
else (Text, BasicText, TextField) goes through the upstream-shaped path.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun IconText(
|
fun IconText(
|
||||||
@@ -44,28 +37,16 @@ fun IconText(
|
|||||||
textAlign: TextAlign = TextAlign.Start,
|
textAlign: TextAlign = TextAlign.Start,
|
||||||
fontVariationSettings: List<FontVariation.Setting>? = null,
|
fontVariationSettings: List<FontVariation.Setting>? = null,
|
||||||
) {
|
) {
|
||||||
// Layout runs in physical pixels (LocalDensity = DPR), so the icon font
|
BasicText(
|
||||||
// size must also convert `sp → px`. Matches SdlParagraph's `fontSize.value
|
|
||||||
// * density`. Without this the icons render at half size on Retina.
|
|
||||||
val vDensity = LocalDensity.current.density
|
|
||||||
val vFontPx = (fontSize.value * vDensity).toInt().coerceAtLeast(1)
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
androidx.compose.ui.layout.Layout(
|
|
||||||
modifier = TextDrawElement(
|
|
||||||
text = text,
|
text = text,
|
||||||
spans = null,
|
modifier = modifier,
|
||||||
|
style = TextStyle(
|
||||||
color = if (color == Color.Unspecified) Color.Black else color,
|
color = if (color == Color.Unspecified) Color.Black else color,
|
||||||
fontSizePx = vFontPx,
|
fontSize = fontSize,
|
||||||
|
fontFamily = namedFontFamily(fontFamily, axes = fontVariationSettings),
|
||||||
textAlign = textAlign,
|
textAlign = textAlign,
|
||||||
softWrap = false,
|
|
||||||
fontFamily = fontFamily,
|
|
||||||
fontVariations = fontVariationSettings,
|
|
||||||
),
|
),
|
||||||
) { _, constraints ->
|
softWrap = false,
|
||||||
val vSize = currentTextMeasurer.measure(text, vFontPx, Int.MAX_VALUE, fontFamily, fontVariationSettings)
|
maxLines = 1,
|
||||||
val w = vSize.width.coerceIn(constraints.minWidth, constraints.maxWidth)
|
)
|
||||||
val h = vSize.height.coerceIn(constraints.minHeight, constraints.maxHeight)
|
|
||||||
layout(w, h) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import androidx.compose.ui.input.pointer.PointerEventType
|
|||||||
import androidx.lifecycle.enableSavedStateHandles
|
import androidx.lifecycle.enableSavedStateHandles
|
||||||
import com.compose.sdl.node.ComposeRootHost
|
import com.compose.sdl.node.ComposeRootHost
|
||||||
import com.compose.sdl.res.currentImageLoader
|
import com.compose.sdl.res.currentImageLoader
|
||||||
import com.compose.sdl.text.currentTextMeasurer
|
|
||||||
import com.compose.sdl.window.LocalPopupHost
|
import com.compose.sdl.window.LocalPopupHost
|
||||||
import com.compose.sdl.window.PopupLayer
|
import com.compose.sdl.window.PopupLayer
|
||||||
import com.compose.sdl.window.createPopupHostState
|
import com.compose.sdl.window.createPopupHostState
|
||||||
@@ -530,12 +529,15 @@ internal class WindowInstance(
|
|||||||
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
|
private val onFrame: ((RenderBackend, Int) -> Boolean)?,
|
||||||
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
|
private val contentHolder: () -> (@Composable ComposeWindowScope.() -> Unit),
|
||||||
) {
|
) {
|
||||||
private val gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
// Resolved renderer mode. `var` so init() can fall back GPU → CPU raster when
|
||||||
val backend = SDL3Backend(
|
// a Skia GPU context can't be created (RDP / headless / missing GL driver).
|
||||||
initialTitle, inWidth, inHeight, gpuMode = gpuMode,
|
private var gpuMode = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||||
iconLightResourcePaths = inIcon?.light ?: emptyList(),
|
private val initialWidth = inWidth
|
||||||
iconDarkResourcePaths = inIcon?.dark ?: emptyList(),
|
private val initialHeight = inHeight
|
||||||
)
|
private val icon = inIcon
|
||||||
|
// Assigned by init() from the first backend that comes up (see makeBackend()).
|
||||||
|
lateinit var backend: SDL3Backend
|
||||||
|
private set
|
||||||
private var renderBackend: RenderBackend? = null
|
private var renderBackend: RenderBackend? = null
|
||||||
lateinit var host: ComposeRootHost
|
lateinit var host: ComposeRootHost
|
||||||
private set
|
private set
|
||||||
@@ -606,14 +608,42 @@ internal class WindowInstance(
|
|||||||
needsFrame = true
|
needsFrame = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(inScope: CoroutineScope): Boolean {
|
// Creates + initialises an SDL3Backend for [mode] and its RenderBackend. On
|
||||||
if (!backend.init()) return false
|
// success commits `backend` and returns the renderer; else tears everything
|
||||||
backend.updateWindowSize()
|
// down and returns null so the caller can retry with a different mode.
|
||||||
|
private fun makeBackend(mode: GpuMode): RenderBackend? {
|
||||||
|
val vBackend = SDL3Backend(
|
||||||
|
initialTitle, initialWidth, initialHeight, gpuMode = mode,
|
||||||
|
iconLightResourcePaths = icon?.light ?: emptyList(),
|
||||||
|
iconDarkResourcePaths = icon?.dark ?: emptyList(),
|
||||||
|
)
|
||||||
|
if (!vBackend.init()) {
|
||||||
|
vBackend.destroy(inQuitSdl = false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
vBackend.updateWindowSize()
|
||||||
|
val vRender = createRenderBackend(vBackend, mode)
|
||||||
|
if (vRender == null || !vRender.ensureSize(vBackend.pixelWidth, vBackend.pixelHeight)) {
|
||||||
|
vRender?.destroy()
|
||||||
|
vBackend.destroy(inQuitSdl = false)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
backend = vBackend
|
||||||
|
return vRender
|
||||||
|
}
|
||||||
|
|
||||||
val vRender = createRenderBackend(backend, gpuMode)
|
fun init(inScope: CoroutineScope): Boolean {
|
||||||
if (vRender == null || !vRender.ensureSize(backend.pixelWidth, backend.pixelHeight)) {
|
// Try the resolved gpuMode; if it's a Skia GPU mode whose context/bridge
|
||||||
println("Failed to init render backend for $gpuMode")
|
// can't come up (RDP / headless / missing GL driver), fall back once to
|
||||||
backend.destroy(inQuitSdl = false)
|
// CPU raster (Software) so the window still opens instead of failing.
|
||||||
|
var vRender = makeBackend(gpuMode)
|
||||||
|
if (vRender == null && gpuMode is GpuMode.Skia) {
|
||||||
|
println("GPU renderer ($gpuMode) unavailable — falling back to CPU raster (Software)")
|
||||||
|
gpuMode = GpuMode.Software
|
||||||
|
vRender = makeBackend(gpuMode)
|
||||||
|
}
|
||||||
|
if (vRender == null) {
|
||||||
|
println("Failed to init render backend")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
renderBackend = vRender
|
renderBackend = vRender
|
||||||
@@ -740,7 +770,6 @@ internal class WindowInstance(
|
|||||||
measuring, or drawing this window's tree. */
|
measuring, or drawing this window's tree. */
|
||||||
fun installGlobals() {
|
fun installGlobals() {
|
||||||
val vRender = renderBackend ?: return
|
val vRender = renderBackend ?: return
|
||||||
currentTextMeasurer = vRender.textMeasurer
|
|
||||||
currentImageLoader = vRender.imageLoader
|
currentImageLoader = vRender.imageLoader
|
||||||
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
|
com.compose.sdl.text.currentViewportWidth = backend.pixelWidth
|
||||||
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
|
com.compose.sdl.text.currentViewportHeight = backend.pixelHeight
|
||||||
|
|||||||
+44
-159
@@ -1,25 +1,21 @@
|
|||||||
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
|
||||||
|
|
||||||
// :core — the renderer-agnostic Compose base + both renderer pipelines.
|
// :ui — the Compose base + the Skia rendering pipeline (all native targets).
|
||||||
//
|
//
|
||||||
// Source-set hierarchy:
|
// Source-set hierarchy:
|
||||||
// commonMain
|
// commonMain
|
||||||
// └── nativeMain (vendored .native.kt + project SDL3 wrappers)
|
// └── nativeMain (vendored .native.kt + project SDL3 wrappers)
|
||||||
// ├── skikoRendererMain (Skia drawing pipeline; Skiko on classpath)
|
// ├── skikoRendererMain (Skia pipeline; OFFICIAL Skiko — macOS/Linux)
|
||||||
// │ ├── skikoRendererMacosMain (macOS-only Skia actuals — Metal bridge)
|
// │ ├── skikoRendererMacosMain (macOS Metal bridge) → macosArm64
|
||||||
// │ └── skikoRendererLinuxMain (Linux-only Skia actuals — OpenGL)
|
// │ └── skikoRendererLinuxMain (Linux OpenGL) → linuxX64/Arm64
|
||||||
// │ attached: macosArm64Main / linuxX64Main / linuxArm64Main
|
// └── skikoRendererMingwSharedMain (Skia pipeline; the bitsycore skiko FORK —
|
||||||
// │ only when Skia path is active (default on macOS/Linux)
|
// └── skikoRendererMingwMain mingwX64 has no official Skiko klib) → mingwX64
|
||||||
// └── sdlRendererMain (SDL3 drawing pipeline + TTF/IMG/FreeType)
|
|
||||||
// ├── sdlRendererMacosMain (macOS-only SDL3 driver hint)
|
|
||||||
// ├── sdlRendererLinuxMain (Linux-only SDL3 driver hint)
|
|
||||||
// └── sdlRendererMingwMain (mingwX64-only SDL3 driver hint)
|
|
||||||
// attached: mingwX64Main always; macOS/Linux when -Prenderer=sdl3
|
|
||||||
//
|
//
|
||||||
// `-Prenderer=sdl3` flips macOS/Linux targets onto the SDL3 path. The
|
// SDL3 stays as the windowing / input / platform layer (the single `sdl3`
|
||||||
// `:renderer-skia` and `:renderer-sdl3` sibling modules are gone — their code
|
// cinterop). The from-scratch SDL renderer and its SDL3_ttf / SDL3_image /
|
||||||
// lives here. `:window` depends only on `:core`; `createRenderBackend` comes
|
// FreeType cinterops were removed — every target now renders through Skia.
|
||||||
// from whichever of the renderer source sets is active for the target.
|
// `:window` depends only on this module and calls createRenderBackend() /
|
||||||
|
// rendererPreferredGpuMode(), which resolve to the Skia actuals.
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
@@ -27,38 +23,12 @@ plugins {
|
|||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -Prenderer=sdl3 flips macOS/Linux targets onto sdlRendererMain (Skiko-free build).
|
// SDL3 headers/libs from the in-repo static build tree at <repo>/libs
|
||||||
val useSdl3Everywhere = (findProperty("renderer") as? String) == "sdl3"
|
// (scripts/build-sdl/build-all.py). The .def file is pathless and relies on
|
||||||
|
// these -I / -libraryPath injections.
|
||||||
// All targets pull headers from the in-repo static build tree at <repo>/libs,
|
|
||||||
// populated by scripts/build-sdl/build-all.py (optionally one library at a time).
|
|
||||||
// Same paths on macOS / Linux / Windows — the .def files are pathless and
|
|
||||||
// rely on these -I injections. Cross-target cinterop indexing under
|
|
||||||
// kotlin.mpp.enableCInteropCommonization is fine: clang silently ignores -I
|
|
||||||
// dirs that don't exist, and the libs/ tree only has headers for the host
|
|
||||||
// that built them (they aren't cross-installed).
|
|
||||||
val vLibs = "${rootDir.invariantSeparatorsPath}/libs"
|
val vLibs = "${rootDir.invariantSeparatorsPath}/libs"
|
||||||
val vHostSdlInclude: String = "$vLibs/SDL3/include"
|
val vHostSdlInclude: String = "$vLibs/SDL3/include"
|
||||||
val vHostFtInclude: String = "$vLibs/FreeType/include/freetype2"
|
|
||||||
val vHostTtfInclude: String = "$vLibs/SDL3_ttf/include"
|
|
||||||
val vHostImageInclude: String = "$vLibs/SDL3_image/include"
|
|
||||||
|
|
||||||
// Static-archive dirs for cinterop's `staticLibraries = …` directive. Same
|
|
||||||
// paths on every host — cinterop only reads the one for the target being
|
|
||||||
// built, so cross-target indexing on a host that hasn't built libs/ for a
|
|
||||||
// foreign target is fine (cinterop skips the archive lookup).
|
|
||||||
val vSdlLibDir: String = "$vLibs/SDL3/lib"
|
val vSdlLibDir: String = "$vLibs/SDL3/lib"
|
||||||
val vFtLibDir: String = "$vLibs/FreeType/lib"
|
|
||||||
val vTtfLibDir: String = "$vLibs/SDL3_ttf/lib"
|
|
||||||
val vImageLibDir: String = "$vLibs/SDL3_image/lib"
|
|
||||||
|
|
||||||
// Renderer assignment per target. mingwX64 is always SDL3; macOS / Linux
|
|
||||||
// default to Skia, switch to SDL3 under -Prenderer=sdl3.
|
|
||||||
fun isSkiaTarget(targetName: String): Boolean = when (targetName) {
|
|
||||||
"mingwX64" -> false
|
|
||||||
"macosArm64", "linuxX64", "linuxArm64" -> !useSdl3Everywhere
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
// Skip mingwX64 on non-Windows hosts; see root build.gradle.kts.
|
||||||
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
val vHostSupportsMingw = rootProject.extra["vHostSupportsMingw"] as Boolean
|
||||||
@@ -72,85 +42,24 @@ kotlin {
|
|||||||
applyDefaultHierarchyTemplate()
|
applyDefaultHierarchyTemplate()
|
||||||
|
|
||||||
targets.withType<KotlinNativeTarget>().all {
|
targets.withType<KotlinNativeTarget>().all {
|
||||||
val vTargetName = name
|
|
||||||
// Whether this target compiles the SDL3 drawing pipeline. mingwX64 is
|
|
||||||
// always SDL3 (no Skiko on Windows); macOS / Linux switch to it only
|
|
||||||
// under -Prenderer=sdl3. SDL3 on macOS/Linux is a DEBUG target we don't
|
|
||||||
// ship in releases, so the default release build (Skia) skips the
|
|
||||||
// sdl3_ttf / sdl3_image / freetype cinterops entirely — one fewer set
|
|
||||||
// of system headers to install in CI, and a smaller klib footprint.
|
|
||||||
val vSdlRenderer = vTargetName == "mingwX64" ||
|
|
||||||
((vTargetName == "macosArm64" || vTargetName == "linuxX64" || vTargetName == "linuxArm64") && useSdl3Everywhere)
|
|
||||||
|
|
||||||
// Path to the sdl3 cinterop output klib for this target — used to
|
|
||||||
// wire `depends = sdl3` in sdl3_ttf / sdl3_image / freetype below.
|
|
||||||
// Gradle does NOT auto-add the sdl3 klib to the dependent cinterop
|
|
||||||
// tasks' -library list, so cinterop generates its own SDL_Surface /
|
|
||||||
// SDL_Color inside sdl3_image / sdl3_ttf instead of reusing the sdl3
|
|
||||||
// ones. Passing the path explicitly via extraOpts forces the link.
|
|
||||||
val vSdl3Klib = layout.buildDirectory.dir(
|
|
||||||
"classes/kotlin/$vTargetName/main/cinterop/ui-cinterop-sdl3"
|
|
||||||
).get().asFile.absolutePath
|
|
||||||
|
|
||||||
compilations["main"].cinterops {
|
compilations["main"].cinterops {
|
||||||
// sdl3 stays for every target — :window uses the SDL3 main-loop
|
// The one SDL3 cinterop — windowing / input / SDL_GetBasePath / … —
|
||||||
// types (SDL_Window / SDL_Event / SDL_GetBasePath / …) regardless
|
// used by every target regardless of GPU path.
|
||||||
// of the renderer choice.
|
|
||||||
create("sdl3") {
|
create("sdl3") {
|
||||||
defFile(project.file("src/nativeInterop/cinterop/sdl3.def"))
|
defFile(project.file("src/nativeInterop/cinterop/sdl3.def"))
|
||||||
packageName("sdl3")
|
packageName("sdl3")
|
||||||
extraOpts("-compiler-options", "-I$vHostSdlInclude")
|
extraOpts("-compiler-options", "-I$vHostSdlInclude")
|
||||||
extraOpts("-libraryPath", vSdlLibDir)
|
extraOpts("-libraryPath", vSdlLibDir)
|
||||||
}
|
}
|
||||||
if (vSdlRenderer) {
|
|
||||||
create("sdl3_ttf") {
|
|
||||||
defFile(project.file("src/nativeInterop/cinterop/sdl3_ttf.def"))
|
|
||||||
packageName("sdl3_ttf")
|
|
||||||
extraOpts("-library", vSdl3Klib)
|
|
||||||
extraOpts("-compiler-options", "-I$vHostSdlInclude")
|
|
||||||
extraOpts("-compiler-options", "-I$vHostTtfInclude")
|
|
||||||
extraOpts("-libraryPath", vTtfLibDir)
|
|
||||||
}
|
|
||||||
create("sdl3_image") {
|
|
||||||
defFile(project.file("src/nativeInterop/cinterop/sdl3_image.def"))
|
|
||||||
packageName("sdl3_image")
|
|
||||||
extraOpts("-library", vSdl3Klib)
|
|
||||||
extraOpts("-compiler-options", "-I$vHostSdlInclude")
|
|
||||||
extraOpts("-compiler-options", "-I$vHostImageInclude")
|
|
||||||
extraOpts("-libraryPath", vImageLibDir)
|
|
||||||
}
|
|
||||||
// FreeType powers variable-font axis rendering (FILL / wght /
|
|
||||||
// GRAD / opsz) on Material Symbols icons in the SDL3 path.
|
|
||||||
create("freetype") {
|
|
||||||
defFile(project.file("src/nativeInterop/cinterop/freetype.def"))
|
|
||||||
packageName("freetype")
|
|
||||||
extraOpts("-compiler-options", "-I$vHostFtInclude")
|
|
||||||
extraOpts("-libraryPath", vFtLibDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire the task graph so cinteropSdl3_ttf/_image*Target run AFTER
|
|
||||||
// cinteropSdl3*Target — the -library reference above only points at
|
|
||||||
// the klib path; without a task dependency Gradle might run the
|
|
||||||
// dependent cinterop first and the path wouldn't exist yet.
|
|
||||||
if (vSdlRenderer) {
|
|
||||||
val vT = vTargetName.replaceFirstChar { it.uppercase() }
|
|
||||||
tasks.matching { it.name == "cinteropSdl3_ttf$vT" || it.name == "cinteropSdl3_image$vT" }
|
|
||||||
.configureEach { dependsOn("cinteropSdl3$vT") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain {
|
||||||
// Files vendored VERBATIM from upstream Compose by
|
// Files vendored VERBATIM from upstream Compose by
|
||||||
// scripts/compose-fork/sync.sh. Kept in their own folder so it's
|
// scripts/compose-fork/sync.sh. Never hand-edit; re-run sync.
|
||||||
// obvious they are generated — never hand-edit; re-run sync instead.
|
|
||||||
kotlin.srcDir("src/vendor/common/kotlin")
|
kotlin.srcDir("src/vendor/common/kotlin")
|
||||||
dependencies {
|
dependencies {
|
||||||
// Split-out lower ui artifacts (CMP layout). The graphics/text/framework
|
|
||||||
// vendored code + renderers here import ui.util / ui.geometry / ui.unit /
|
|
||||||
// ui.backhandler from these modules.
|
|
||||||
api(project(":ui-util"))
|
api(project(":ui-util"))
|
||||||
api(project(":ui-geometry"))
|
api(project(":ui-geometry"))
|
||||||
api(project(":ui-unit"))
|
api(project(":ui-unit"))
|
||||||
@@ -163,11 +72,6 @@ kotlin {
|
|||||||
api("androidx.savedstate:savedstate-compose:1.5.0")
|
api("androidx.savedstate:savedstate-compose:1.5.0")
|
||||||
api("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
|
api("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
|
||||||
api("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
|
api("androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
|
||||||
// Navigation 3 runtime — the backstack / NavEntry / scene model. Published for
|
|
||||||
// all our K/N targets (mingwX64 + macos/linux) and pulls only runtime / lifecycle /
|
|
||||||
// savedstate / collection / serialization (no androidx.compose.ui). The NavDisplay
|
|
||||||
// UI (androidx.navigation3:navigation3-ui) has NO K/N desktop artifact, so it's not
|
|
||||||
// here — a display is hand-rolled on the project's own ui/animation.
|
|
||||||
api("androidx.navigation3:navigation3-runtime:1.1.4")
|
api("androidx.navigation3:navigation3-runtime:1.1.4")
|
||||||
api("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.11.0")
|
api("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.11.0")
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
@@ -181,48 +85,14 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
// Renderer roots. Each is a child of nativeMain; per-platform
|
// Skia renderer. macOS/Linux use the OFFICIAL Skiko; mingwX64 uses the
|
||||||
// intermediates below attach to one of these. Only the renderer
|
// bitsycore skiko FORK (no official mingw klib), in a separate tree so
|
||||||
// source sets that will actually be attached are created, so Gradle
|
// only mingw gets the fork coord.
|
||||||
// doesn't warn about unused source sets when the build is asymmetric
|
|
||||||
// (e.g. -Prenderer=sdl3 wouldn't use any skikoRenderer* sets).
|
|
||||||
|
|
||||||
val sdlRendererMain = create("sdlRendererMain") {
|
|
||||||
dependsOn(nativeMain.get())
|
|
||||||
// src/vendor/sdlRenderer/kotlin holds files vendored verbatim
|
|
||||||
// from upstream's skikoMain that are SDL3-friendly (no Skia refs)
|
|
||||||
// or whose SDL3 actual we provide; same "never hand-edit" rule
|
|
||||||
// as the other vendor srcDirs.
|
|
||||||
kotlin.srcDir("src/vendor/sdlRenderer/kotlin")
|
|
||||||
// SDL3_ttf / SDL3_image / freetype cinterop bindings come from
|
|
||||||
// the per-target cinterop block above; no separate Gradle deps.
|
|
||||||
}
|
|
||||||
// mingwX64 is SDL3 always — attach its intermediate only when the
|
|
||||||
// target itself was declared (host is Windows). Non-Windows hosts skip
|
|
||||||
// both the target and its source-set wiring.
|
|
||||||
if (vHostSupportsMingw) {
|
|
||||||
val sdlRendererMingwMain = create("sdlRendererMingwMain") { dependsOn(sdlRendererMain) }
|
|
||||||
mingwX64Main.get().dependsOn(sdlRendererMingwMain)
|
|
||||||
}
|
|
||||||
|
|
||||||
val macosArm64Main = get("macosArm64Main")
|
|
||||||
val linuxX64Main = get("linuxX64Main")
|
|
||||||
val linuxArm64Main = get("linuxArm64Main")
|
|
||||||
|
|
||||||
if (useSdl3Everywhere) {
|
|
||||||
// macOS / Linux flip to SDL3 — create the sdl intermediates.
|
|
||||||
val sdlRendererMacosMain = create("sdlRendererMacosMain") { dependsOn(sdlRendererMain) }
|
|
||||||
val sdlRendererLinuxMain = create("sdlRendererLinuxMain") { dependsOn(sdlRendererMain) }
|
|
||||||
macosArm64Main.dependsOn(sdlRendererMacosMain)
|
|
||||||
linuxX64Main.dependsOn(sdlRendererLinuxMain)
|
|
||||||
linuxArm64Main.dependsOn(sdlRendererLinuxMain)
|
|
||||||
} else {
|
|
||||||
// Default: macOS / Linux use Skia. Create the skiko tree.
|
|
||||||
val skikoRendererMain = create("skikoRendererMain") {
|
val skikoRendererMain = create("skikoRendererMain") {
|
||||||
dependsOn(nativeMain.get())
|
dependsOn(nativeMain.get())
|
||||||
// src/vendor/skikoRenderer/kotlin holds upstream's `skikoMain`
|
// src/vendor/skikoRenderer/kotlin — upstream `skikoMain` files
|
||||||
// files (Skia-tied actuals / helpers like BlendMode.skiko.kt)
|
// (Skia-tied actuals like BlendMode.skiko.kt) vendored verbatim.
|
||||||
// vendored verbatim. Same "never hand-edit" rule.
|
|
||||||
kotlin.srcDir("src/vendor/skikoRenderer/kotlin")
|
kotlin.srcDir("src/vendor/skikoRenderer/kotlin")
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.skiko)
|
implementation(libs.skiko)
|
||||||
@@ -230,22 +100,37 @@ kotlin {
|
|||||||
}
|
}
|
||||||
val skikoRendererMacosMain = create("skikoRendererMacosMain") { dependsOn(skikoRendererMain) }
|
val skikoRendererMacosMain = create("skikoRendererMacosMain") { dependsOn(skikoRendererMain) }
|
||||||
val skikoRendererLinuxMain = create("skikoRendererLinuxMain") { dependsOn(skikoRendererMain) }
|
val skikoRendererLinuxMain = create("skikoRendererLinuxMain") { dependsOn(skikoRendererMain) }
|
||||||
macosArm64Main.dependsOn(skikoRendererMacosMain)
|
get("macosArm64Main").dependsOn(skikoRendererMacosMain)
|
||||||
linuxX64Main.dependsOn(skikoRendererLinuxMain)
|
get("linuxX64Main").dependsOn(skikoRendererLinuxMain)
|
||||||
linuxArm64Main.dependsOn(skikoRendererLinuxMain)
|
get("linuxArm64Main").dependsOn(skikoRendererLinuxMain)
|
||||||
|
|
||||||
|
if (vHostSupportsMingw) {
|
||||||
|
// Route 1a: mingwX64 Skia leg on the fork. Two levels so the shared
|
||||||
|
// PlatformGpu expect (Shared) has its mingw actual (Mingw), mirroring
|
||||||
|
// the macos/linux split. Depends on the fork ROOT coord (not the
|
||||||
|
// platform artifact) so KMP variant-resolution exposes api-elements.
|
||||||
|
val skikoRendererMingwSharedMain = create("skikoRendererMingwSharedMain") {
|
||||||
|
dependsOn(nativeMain.get())
|
||||||
|
kotlin.srcDir("src/skikoRendererMain/kotlin")
|
||||||
|
kotlin.srcDir("src/vendor/skikoRenderer/kotlin")
|
||||||
|
dependencies {
|
||||||
|
// Published by the fork's CI to GitHub Packages; version
|
||||||
|
// overridable via -PskikoMingwVersion=.
|
||||||
|
implementation("org.jetbrains.skiko:skiko:${providers.gradleProperty("skikoMingwVersion").getOrElse("0.150.1-mingw.1")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val skikoRendererMingwMain = create("skikoRendererMingwMain") { dependsOn(skikoRendererMingwSharedMain) }
|
||||||
|
get("mingwX64Main").dependsOn(skikoRendererMingwMain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
freeCompilerArgs.addAll(
|
freeCompilerArgs.addAll(
|
||||||
"-Xcollection-literals",
|
"-Xcollection-literals",
|
||||||
// Silence the expect/actual-classes Beta warning that vendored files
|
// Silence the expect/actual-classes Beta warning vendored files trip.
|
||||||
// (e.g. ImageBitmap / Paint / Canvas / Path / ClipEntry) tripped. See
|
|
||||||
// https://youtrack.jetbrains.com/issue/KT-61573.
|
// https://youtrack.jetbrains.com/issue/KT-61573.
|
||||||
"-Xexpect-actual-classes",
|
"-Xexpect-actual-classes",
|
||||||
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
|
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
|
||||||
// Vendored code is upstream foundation/ui, which their build compiles with these
|
|
||||||
// module-level opt-ins (e.g. lazy-layout prefetch uses ExperimentalFoundationApi).
|
|
||||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||||
"-opt-in=androidx.compose.ui.InternalComposeUiApi",
|
"-opt-in=androidx.compose.ui.InternalComposeUiApi",
|
||||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi"
|
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi"
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
package com.compose.sdl.text
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.AnnotatedString.Range
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.TextUnit
|
|
||||||
import androidx.compose.ui.unit.TextUnitType
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Per-line style runs (renderer helper)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Project render glue with no official Compose equivalent — the text renderers
|
|
||||||
turn an AnnotatedString's spans into per-line style runs (colour + weight +
|
|
||||||
italic + background + decoration + size). Lives in
|
|
||||||
`com.compose.sdl.text` rather than `androidx.compose.ui.text`. */
|
|
||||||
|
|
||||||
/** A run of one style within a wrapped line, line-local [start, end) cols.
|
|
||||||
`weight` is the OpenType wght axis (100..900, 400 = default), `italic` =
|
|
||||||
FontStyle.Italic (paragraph base or span), `background` = SpanStyle.background
|
|
||||||
(Unspecified = none), `underline`/`lineThrough` = TextDecoration flags
|
|
||||||
(paragraph base or span), `fontSize` = the span's size (Unspecified = the
|
|
||||||
paragraph size; Em scales it, Sp resolves through the density).
|
|
||||||
|
|
||||||
Measurement/wrapping is still base-style, so a bold/resized run's glyphs
|
|
||||||
paint into a line box wrapped at base metrics; that's a known simplification
|
|
||||||
(real fidelity needs a re-wrap per style run). Paint-time advances DO use
|
|
||||||
the run's own style, so runs push each other over correctly. */
|
|
||||||
class ColorRun(
|
|
||||||
val start: Int,
|
|
||||||
val end: Int,
|
|
||||||
val color: Color,
|
|
||||||
val weight: Int = 400,
|
|
||||||
val italic: Boolean = false,
|
|
||||||
val background: Color = Color.Unspecified,
|
|
||||||
val underline: Boolean = false,
|
|
||||||
val lineThrough: Boolean = false,
|
|
||||||
val fontSize: TextUnit = TextUnit.Unspecified,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Style runs for a single wrapped line, given the AnnotatedString spans (whose
|
|
||||||
start/end index the ORIGINAL text) and this line's start offset. Gaps use
|
|
||||||
the base style; overlapping spans compose colour/background/size last-wins,
|
|
||||||
weight max-wins (bold beats regular), italic/underline/lineThrough OR-wins
|
|
||||||
(also OR'd with the paragraph-level base flags — a span's explicit
|
|
||||||
TextDecoration.None can't clear a base decoration; simplification).
|
|
||||||
|
|
||||||
Spans are assumed sorted by start with non-decreasing end (true for the
|
|
||||||
tokenizers and for buildAnnotatedString appended in order). That lets us
|
|
||||||
binary-search to the first span reaching this line and stop at the first span
|
|
||||||
past it, so a visible line of a 20k-line highlighted body touches only its
|
|
||||||
own handful of spans instead of scanning the whole (tens-of-thousands) list
|
|
||||||
every frame — the difference between smooth and janky scrolling. */
|
|
||||||
fun lineColorRuns(
|
|
||||||
inLine: String,
|
|
||||||
inLineStart: Int,
|
|
||||||
inSpans: List<Range<SpanStyle>>,
|
|
||||||
inDefault: Color,
|
|
||||||
inBaseItalic: Boolean = false,
|
|
||||||
inBaseUnderline: Boolean = false,
|
|
||||||
inBaseLineThrough: Boolean = false,
|
|
||||||
): List<ColorRun> {
|
|
||||||
val vN = inLine.length
|
|
||||||
if (vN == 0) return emptyList()
|
|
||||||
val vLineEnd = inLineStart + vN
|
|
||||||
// Per-column style. Nullable arrays let us distinguish "explicit default" from
|
|
||||||
// "no span touched me" — the coalesce step uses `inDefault` when a column stayed null.
|
|
||||||
val vCols = arrayOfNulls<Color>(vN)
|
|
||||||
val vWgt = IntArray(vN)
|
|
||||||
val vIt = BooleanArray(vN)
|
|
||||||
val vBg = arrayOfNulls<Color>(vN)
|
|
||||||
val vUl = BooleanArray(vN)
|
|
||||||
val vSt = BooleanArray(vN)
|
|
||||||
val vSz = arrayOfNulls<TextUnit>(vN)
|
|
||||||
// First span whose end reaches into this line (skip the prefix before it).
|
|
||||||
var vIdx = firstSpanReaching(inSpans, inLineStart)
|
|
||||||
while (vIdx < inSpans.size) {
|
|
||||||
val vS = inSpans[vIdx]
|
|
||||||
// Sorted by start → once a span starts at/after the line end, so do all
|
|
||||||
// the rest; nothing more can overlap.
|
|
||||||
if (vS.start >= vLineEnd) break
|
|
||||||
vIdx++
|
|
||||||
val vColor = vS.item.color
|
|
||||||
val vSpanWeight = vS.item.fontWeight?.weight ?: 0
|
|
||||||
val vSpanItalic = vS.item.fontStyle == FontStyle.Italic
|
|
||||||
val vSpanBg = vS.item.background
|
|
||||||
val vDeco = vS.item.textDecoration
|
|
||||||
val vSpanUl = vDeco != null && vDeco.contains(TextDecoration.Underline)
|
|
||||||
val vSpanSt = vDeco != null && vDeco.contains(TextDecoration.LineThrough)
|
|
||||||
val vSpanSz = vS.item.fontSize
|
|
||||||
if (vColor == Color.Unspecified && vSpanWeight == 0 && !vSpanItalic &&
|
|
||||||
vSpanBg == Color.Unspecified && !vSpanUl && !vSpanSt && vSpanSz == TextUnit.Unspecified
|
|
||||||
) continue
|
|
||||||
val vA = vS.start - inLineStart
|
|
||||||
val vB = vS.end - inLineStart
|
|
||||||
if (vB <= 0 || vA >= vN) continue
|
|
||||||
var i = if (vA < 0) 0 else vA
|
|
||||||
val vEnd = if (vB > vN) vN else vB
|
|
||||||
while (i < vEnd) {
|
|
||||||
if (vColor != Color.Unspecified) vCols[i] = vColor
|
|
||||||
if (vSpanWeight > vWgt[i]) vWgt[i] = vSpanWeight
|
|
||||||
if (vSpanItalic) vIt[i] = true
|
|
||||||
if (vSpanBg != Color.Unspecified) vBg[i] = vSpanBg
|
|
||||||
if (vSpanUl) vUl[i] = true
|
|
||||||
if (vSpanSt) vSt[i] = true
|
|
||||||
if (vSpanSz != TextUnit.Unspecified) vSz[i] = vSpanSz
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Coalesce equal-style columns into runs. Two adjacent columns coalesce
|
|
||||||
// only when every dimension matches.
|
|
||||||
val vRuns = ArrayList<ColorRun>()
|
|
||||||
var i = 0
|
|
||||||
while (i < vN) {
|
|
||||||
val vC = vCols[i] ?: inDefault
|
|
||||||
val vW = if (vWgt[i] == 0) 400 else vWgt[i]
|
|
||||||
val vI = vIt[i] || inBaseItalic
|
|
||||||
val vB = vBg[i] ?: Color.Unspecified
|
|
||||||
val vU = vUl[i] || inBaseUnderline
|
|
||||||
val vS2 = vSt[i] || inBaseLineThrough
|
|
||||||
val vZ = vSz[i] ?: TextUnit.Unspecified
|
|
||||||
var j = i + 1
|
|
||||||
while (j < vN &&
|
|
||||||
(vCols[j] ?: inDefault) == vC &&
|
|
||||||
(if (vWgt[j] == 0) 400 else vWgt[j]) == vW &&
|
|
||||||
(vIt[j] || inBaseItalic) == vI &&
|
|
||||||
(vBg[j] ?: Color.Unspecified) == vB &&
|
|
||||||
(vUl[j] || inBaseUnderline) == vU &&
|
|
||||||
(vSt[j] || inBaseLineThrough) == vS2 &&
|
|
||||||
(vSz[j] ?: TextUnit.Unspecified) == vZ
|
|
||||||
) j++
|
|
||||||
vRuns.add(ColorRun(i, j, vC, vW, vI, vB, vU, vS2, vZ))
|
|
||||||
i = j
|
|
||||||
}
|
|
||||||
return vRuns
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Style-aware metrics helpers
|
|
||||||
// ==================
|
|
||||||
// Shared by BOTH the renderers (paint-time advances) and SdlParagraph
|
|
||||||
// (layout-time widths/heights) so painted glyph runs land exactly inside the
|
|
||||||
// measured box — per-run fontSize / fontWeight change metrics, not just paint.
|
|
||||||
|
|
||||||
/** Resolves a run's SpanStyle.fontSize to pixels: Em scales the paragraph's
|
|
||||||
base size, Sp resolves through the density the base size was resolved with,
|
|
||||||
Unspecified inherits the base. */
|
|
||||||
fun resolveRunPx(inRun: ColorRun, inBasePx: Int, inDensity: Float): Int = when (inRun.fontSize.type) {
|
|
||||||
TextUnitType.Em -> (inBasePx * inRun.fontSize.value).roundToInt().coerceAtLeast(1)
|
|
||||||
TextUnitType.Sp -> (inRun.fontSize.value * inDensity).roundToInt().coerceAtLeast(1)
|
|
||||||
else -> inBasePx
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A run's font axes: its own weight when set (400 = "no run weight"), else
|
|
||||||
the paragraph's base axes. */
|
|
||||||
fun runVariations(
|
|
||||||
inRun: ColorRun,
|
|
||||||
inBaseVariations: List<FontVariation.Setting>?,
|
|
||||||
): List<FontVariation.Setting>? =
|
|
||||||
if (inRun.weight != 400) listOf(FontVariation.weight(inRun.weight)) else inBaseVariations
|
|
||||||
|
|
||||||
/** Whether any span carries a metric-affecting style (size / weight) — the
|
|
||||||
gate for the styled measurement paths, so plain/colour-only text keeps the
|
|
||||||
cheap single-measure route. */
|
|
||||||
fun spansAffectMetrics(inSpans: List<Range<SpanStyle>>?): Boolean =
|
|
||||||
inSpans != null && inSpans.any {
|
|
||||||
it.item.fontSize != TextUnit.Unspecified || it.item.fontWeight != null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Width of a text slice, span-aware: sums each style run's advance at its
|
|
||||||
resolved size/weight through [inMeasurer]. `inGlobalStart` maps the slice
|
|
||||||
into the spans' index space. */
|
|
||||||
fun styledSliceWidth(
|
|
||||||
inSlice: String,
|
|
||||||
inGlobalStart: Int,
|
|
||||||
inSpans: List<Range<SpanStyle>>,
|
|
||||||
inBasePx: Int,
|
|
||||||
inDensity: Float,
|
|
||||||
inMeasurer: TextMeasurer,
|
|
||||||
inFontFamily: String?,
|
|
||||||
inBaseVariations: List<FontVariation.Setting>?,
|
|
||||||
): Float {
|
|
||||||
if (inSlice.isEmpty()) return 0f
|
|
||||||
var vW = 0f
|
|
||||||
for (vRun in lineColorRuns(inSlice, inGlobalStart, inSpans, Color.Unspecified)) {
|
|
||||||
val vPx = resolveRunPx(vRun, inBasePx, inDensity)
|
|
||||||
val vVars = runVariations(vRun, inBaseVariations)
|
|
||||||
vW += inMeasurer.measure(
|
|
||||||
inSlice.substring(vRun.start, vRun.end), vPx, Int.MAX_VALUE, inFontFamily, vVars,
|
|
||||||
).width
|
|
||||||
}
|
|
||||||
return vW
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tallest run cell height on a line — the line's box height when spans carry
|
|
||||||
their own sizes; the base cell height when they don't (or the line is empty). */
|
|
||||||
fun styledLineCellHeight(
|
|
||||||
inLine: String,
|
|
||||||
inGlobalStart: Int,
|
|
||||||
inSpans: List<Range<SpanStyle>>,
|
|
||||||
inBasePx: Int,
|
|
||||||
inDensity: Float,
|
|
||||||
inMeasurer: TextMeasurer,
|
|
||||||
inFontFamily: String?,
|
|
||||||
inBaseVariations: List<FontVariation.Setting>?,
|
|
||||||
): Float {
|
|
||||||
var vH = inMeasurer.lineHeight(inBasePx, inFontFamily, inBaseVariations)
|
|
||||||
for (vRun in lineColorRuns(inLine, inGlobalStart, inSpans, Color.Unspecified)) {
|
|
||||||
val vPx = resolveRunPx(vRun, inBasePx, inDensity)
|
|
||||||
if (vPx != inBasePx) {
|
|
||||||
val vRunH = inMeasurer.lineHeight(vPx, inFontFamily, runVariations(vRun, inBaseVariations))
|
|
||||||
if (vRunH > vH) vH = vRunH
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return vH
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Index of the first span whose end is past inLineStart — i.e. the first that
|
|
||||||
can reach into a line starting there. Binary search; relies on spans being
|
|
||||||
sorted with non-decreasing end (see lineColorRuns). */
|
|
||||||
private fun firstSpanReaching(inSpans: List<Range<SpanStyle>>, inLineStart: Int): Int {
|
|
||||||
var vLo = 0
|
|
||||||
var vHi = inSpans.size
|
|
||||||
while (vLo < vHi) {
|
|
||||||
val vMid = (vLo + vHi) ushr 1
|
|
||||||
if (inSpans[vMid].end <= inLineStart) vLo = vMid + 1 else vHi = vMid
|
|
||||||
}
|
|
||||||
return vLo
|
|
||||||
}
|
|
||||||
@@ -30,6 +30,10 @@ class NamedFont(
|
|||||||
override val weight: FontWeight = FontWeight.Normal,
|
override val weight: FontWeight = FontWeight.Normal,
|
||||||
override val style: FontStyle = FontStyle.Normal,
|
override val style: FontStyle = FontStyle.Normal,
|
||||||
val variationSettings: FontVariation.Settings = FontVariation.Settings(weight, style),
|
val variationSettings: FontVariation.Settings = FontVariation.Settings(weight, style),
|
||||||
|
// Raw variable-font axis settings (Material Symbols FILL/wght/GRAD/opsz),
|
||||||
|
// threaded through the family so the skiko text engine can apply them via
|
||||||
|
// FontFamily.projectFontVariations() — icons render on the standard text path.
|
||||||
|
val axes: List<FontVariation.Setting>? = null,
|
||||||
) : Font {
|
) : Font {
|
||||||
override val loadingStrategy: FontLoadingStrategy = FontLoadingStrategy.Blocking
|
override val loadingStrategy: FontLoadingStrategy = FontLoadingStrategy.Blocking
|
||||||
|
|
||||||
@@ -52,7 +56,8 @@ fun namedFontFamily(
|
|||||||
name: String,
|
name: String,
|
||||||
weight: FontWeight = FontWeight.Normal,
|
weight: FontWeight = FontWeight.Normal,
|
||||||
style: FontStyle = FontStyle.Normal,
|
style: FontStyle = FontStyle.Normal,
|
||||||
): FontFamily = FontFamily(NamedFont(name, weight, style))
|
axes: List<FontVariation.Setting>? = null,
|
||||||
|
): FontFamily = FontFamily(NamedFont(name, weight, style, axes = axes))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the renderer font-registry name for a `FontFamily`:
|
* Extract the renderer font-registry name for a `FontFamily`:
|
||||||
@@ -69,3 +74,9 @@ fun FontFamily?.projectFontName(): String? = when (this) {
|
|||||||
if (name == "sans-serif") null else "generic:$name"
|
if (name == "sans-serif") null else "generic:$name"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Variable-font axis settings carried by a [namedFontFamily]'s [NamedFont]
|
||||||
|
* (Material Symbols FILL/wght/GRAD/opsz); null for every other family. The
|
||||||
|
* skiko text engine applies these on top of / instead of the paragraph weight. */
|
||||||
|
fun FontFamily?.projectFontVariations(): List<FontVariation.Setting>? =
|
||||||
|
((this as? FontListFontFamily)?.firstOrNull() as? NamedFont)?.axes
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
package com.compose.sdl.text
|
|
||||||
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
|
||||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
|
||||||
import androidx.compose.ui.node.DrawModifierNode
|
|
||||||
import androidx.compose.ui.node.ModifierNodeElement
|
|
||||||
import androidx.compose.ui.text.AnnotatedString.Range
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Native text drawing bridge (B5)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
Bridges commonMain text drawing to the platform renderer without a hard
|
|
||||||
dependency on it. A renderer's `Canvas` (Sdl3Canvas / SkiaBackedCanvas)
|
|
||||||
implements [NativeTextCanvas]; the text [DrawModifierNode] gets the live Canvas via
|
|
||||||
`drawIntoCanvas` and, if it's a NativeTextCanvas, asks it to paint the glyphs. Layout
|
|
||||||
sizing still goes through `currentTextMeasurer` so bounds match the drawn text.
|
|
||||||
|
|
||||||
This keeps text as a real upstream DrawModifierNode in the modifier chain — it draws
|
|
||||||
through the coordinator pipeline like any other draw node.
|
|
||||||
*/
|
|
||||||
interface NativeTextCanvas {
|
|
||||||
fun drawNativeText(
|
|
||||||
inText: String,
|
|
||||||
inSpans: List<Range<SpanStyle>>?,
|
|
||||||
inX: Float,
|
|
||||||
inY: Float,
|
|
||||||
inBoxWidth: Float,
|
|
||||||
inBoxHeight: Float,
|
|
||||||
inColor: Color,
|
|
||||||
inFontSizePx: Int,
|
|
||||||
inTextAlign: TextAlign,
|
|
||||||
inSoftWrap: Boolean,
|
|
||||||
inFontFamily: String?,
|
|
||||||
inFontVariations: List<FontVariation.Setting>?,
|
|
||||||
// Paragraph-level TextStyle.fontStyle == Italic + merged textDecoration
|
|
||||||
// (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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modifier element carrying the text paint params onto a LayoutNode's chain.
|
|
||||||
// Public (not internal) so :foundation's IconText.kt can reach it — same
|
|
||||||
// package, different module.
|
|
||||||
data class TextDrawElement(
|
|
||||||
val text: String,
|
|
||||||
val spans: List<Range<SpanStyle>>?,
|
|
||||||
val color: Color,
|
|
||||||
val fontSizePx: Int,
|
|
||||||
val textAlign: TextAlign,
|
|
||||||
val softWrap: Boolean,
|
|
||||||
val fontFamily: String?,
|
|
||||||
val fontVariations: List<FontVariation.Setting>?,
|
|
||||||
) : ModifierNodeElement<TextDrawNode>() {
|
|
||||||
|
|
||||||
override fun create(): TextDrawNode =
|
|
||||||
TextDrawNode(text, spans, color, fontSizePx, textAlign, softWrap, fontFamily, fontVariations)
|
|
||||||
|
|
||||||
override fun update(node: TextDrawNode) {
|
|
||||||
node.text = text
|
|
||||||
node.spans = spans
|
|
||||||
node.color = color
|
|
||||||
node.fontSizePx = fontSizePx
|
|
||||||
node.textAlign = textAlign
|
|
||||||
node.softWrap = softWrap
|
|
||||||
node.fontFamily = fontFamily
|
|
||||||
node.fontVariations = fontVariations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DrawModifierNode that paints the text via the renderer Canvas at the node's origin.
|
|
||||||
class TextDrawNode(
|
|
||||||
var text: String,
|
|
||||||
var spans: List<Range<SpanStyle>>?,
|
|
||||||
var color: Color,
|
|
||||||
var fontSizePx: Int,
|
|
||||||
var textAlign: TextAlign,
|
|
||||||
var softWrap: Boolean,
|
|
||||||
var fontFamily: String?,
|
|
||||||
var fontVariations: List<FontVariation.Setting>?,
|
|
||||||
) : Modifier.Node(), DrawModifierNode {
|
|
||||||
|
|
||||||
override fun ContentDrawScope.draw() {
|
|
||||||
drawContent()
|
|
||||||
drawIntoCanvas { vCanvas ->
|
|
||||||
(vCanvas as? NativeTextCanvas)?.drawNativeText(
|
|
||||||
text, spans, 0f, 0f, size.width, size.height,
|
|
||||||
color, fontSizePx, textAlign, softWrap, fontFamily, fontVariations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package com.compose.sdl.text
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: TextMeasurer (project-only render-bridge)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/* Upstream Compose has its own androidx.compose.ui.text.TextMeasurer (a class
|
|
||||||
that returns TextLayoutResult) — different shape, engine-tied. Per FIDELITY's
|
|
||||||
relocate rule, our reduced render-bridge interface lives here instead of
|
|
||||||
colliding on the official name. The native backend installs an impl at
|
|
||||||
startup (Skia / SDL3) via currentTextMeasurer. */
|
|
||||||
|
|
||||||
/** Wrapped layout result. `lines[i]` is the visible text of wrapped line i;
|
|
||||||
`lineStarts[i]` is the offset into the original text where that line
|
|
||||||
begins. lineStarts[i] + lines[i].length may be less than lineStarts[i+1]
|
|
||||||
when the gap contains explicit '\n' characters (which are consumed
|
|
||||||
between lines and don't appear in any line's text). */
|
|
||||||
class WrappedText(val lines: List<String>, val lineStarts: IntArray)
|
|
||||||
|
|
||||||
/** Shared abstraction so that the commonMain layout pass (TextMeasurePolicy)
|
|
||||||
can get the same width / height that the native renderer will actually
|
|
||||||
draw. The native backend installs a Skia-backed implementation at startup. */
|
|
||||||
interface TextMeasurer {
|
|
||||||
/** Measure the text's laid-out size. If inMaxWidth is bounded, lines wrap
|
|
||||||
at word boundaries (or mid-word if a single word exceeds the limit).
|
|
||||||
inFontFamily picks a registered IconFont; null falls back to the
|
|
||||||
renderer's default font. inFontVariations applies variable-font axis
|
|
||||||
settings (Material Symbols wght / FILL / etc.) when supported by the
|
|
||||||
active renderer. */
|
|
||||||
fun measure(
|
|
||||||
inText: String,
|
|
||||||
inFontSize: Int,
|
|
||||||
inMaxWidth: Int = Int.MAX_VALUE,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<FontVariation.Setting>? = null,
|
|
||||||
): IntSize
|
|
||||||
|
|
||||||
/** Wrapped lines + the original-text offset where each begins. */
|
|
||||||
fun wrap(
|
|
||||||
inText: String,
|
|
||||||
inFontSize: Int,
|
|
||||||
inMaxWidth: Int = Int.MAX_VALUE,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<FontVariation.Setting>? = null,
|
|
||||||
): WrappedText
|
|
||||||
|
|
||||||
/** Exact line height the renderer uses for this fontSize, as a Float so
|
|
||||||
callers (TextField cursor / click math) line up with rendered glyph
|
|
||||||
slots even when the per-line drift is sub-pixel. */
|
|
||||||
fun lineHeight(
|
|
||||||
inFontSize: Int,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<FontVariation.Setting>? = null,
|
|
||||||
): Float
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Default fallback
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
private val kFallbackTextMeasurer = object : TextMeasurer {
|
|
||||||
override fun measure(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String?, inFontVariations: List<FontVariation.Setting>?): IntSize {
|
|
||||||
val vCharW = (inFontSize * 0.6f).toInt().coerceAtLeast(1)
|
|
||||||
return IntSize(vCharW * inText.length, (inFontSize * 1.3f).toInt())
|
|
||||||
}
|
|
||||||
override fun wrap(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String?, inFontVariations: List<FontVariation.Setting>?): WrappedText {
|
|
||||||
val vLines = if (inText.isEmpty()) listOf("") else inText.split('\n')
|
|
||||||
val vStarts = IntArray(vLines.size)
|
|
||||||
var vAcc = 0
|
|
||||||
for (i in vLines.indices) {
|
|
||||||
vStarts[i] = vAcc
|
|
||||||
vAcc += vLines[i].length + 1 // +1 for the consumed '\n' between hard lines
|
|
||||||
}
|
|
||||||
return WrappedText(vLines, vStarts)
|
|
||||||
}
|
|
||||||
override fun lineHeight(inFontSize: Int, inFontFamily: String?, inFontVariations: List<FontVariation.Setting>?): Float = inFontSize * 1.3f
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentTextMeasurer: TextMeasurer = kFallbackTextMeasurer
|
|
||||||
|
|
||||||
/** Logical size of the window, set by the render loop each frame. Lets commonMain
|
|
||||||
composables (selection highlights cull per-line work, DropdownMenu flips/clamps
|
|
||||||
itself) read the viewport without a hard dependency on the window layer.
|
|
||||||
0 until first set — callers treat that as "viewport unknown". */
|
|
||||||
var currentViewportHeight: Int = 0
|
|
||||||
var currentViewportWidth: Int = 0
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.compose.sdl.text
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Viewport size (project-only)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Logical size of the window, set by the render loop each frame. Lets commonMain
|
||||||
|
composables (selection highlights cull per-line work, DropdownMenu flips/clamps
|
||||||
|
itself) read the viewport without a hard dependency on the window layer.
|
||||||
|
0 until first set — callers treat that as "viewport unknown". */
|
||||||
|
var currentViewportHeight: Int = 0
|
||||||
|
var currentViewportWidth: Int = 0
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
headers = ft2build.h freetype/freetype.h freetype/ftmm.h freetype/ftoutln.h freetype/ftbitmap.h
|
|
||||||
headerFilter = freetype2/** ft2build.h freetype/**
|
|
||||||
|
|
||||||
# libfreetype.a is baked into the cinterop klib. See sdl3.def — the static
|
|
||||||
# archive location is injected via :ui build.gradle.kts (-libraryPath).
|
|
||||||
# Optional deps (HarfBuzz / PNG / Brotli / BZip2 / zlib) were disabled in
|
|
||||||
# build-freetype.sh so this archive has no external references beyond libc.
|
|
||||||
staticLibraries = libfreetype.a
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
headers = SDL3_image/SDL_image.h
|
|
||||||
headerFilter = SDL3_image/**
|
|
||||||
depends = sdl3
|
|
||||||
|
|
||||||
# libSDL3_image.a + its vendored codec archives (PNG/JPG built-in via stb,
|
|
||||||
# WEBP, SVG built-in) are all baked into the cinterop klib. See sdl3.def —
|
|
||||||
# the static archive location is injected via :ui build.gradle.kts
|
|
||||||
# (-libraryPath). build-all.py's sdl3-image step normalises libzlibstatic.a
|
|
||||||
# → libz.a on Windows so the same filename works everywhere.
|
|
||||||
staticLibraries = libSDL3_image.a libpng16.a libz.a libwebp.a libwebpdemux.a libwebpmux.a libsharpyuv.a
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
headers = SDL3_ttf/SDL_ttf.h
|
|
||||||
headerFilter = SDL3_ttf/**
|
|
||||||
depends = sdl3
|
|
||||||
|
|
||||||
# libSDL3_ttf.a is baked into the cinterop klib. See sdl3.def — the static
|
|
||||||
# archive location is injected via :ui build.gradle.kts (-libraryPath).
|
|
||||||
# No extra system libs beyond what sdl3.def / freetype.def already declare.
|
|
||||||
staticLibraries = libSDL3_ttf.a
|
|
||||||
+61
-27
@@ -7,22 +7,52 @@ import androidx.compose.ui.unit.Constraints
|
|||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: ParagraphIntrinsics + Paragraph factory actuals (SDL)
|
// MARK: ParagraphIntrinsics + Paragraph factory actuals (skiko engine)
|
||||||
// ==================
|
// ==================
|
||||||
|
//
|
||||||
|
// The `Paragraph()` / `ParagraphIntrinsics()` factories (expects in vendored
|
||||||
|
// commonMain) resolve here to the skiko-backed engine. Because `:ui`'s native
|
||||||
|
// hierarchy is nativeMain -> skiko (inverted vs upstream skiko -> native), the
|
||||||
|
// real SkiaParagraph lives in the child skiko source set; these parent-level
|
||||||
|
// actuals bridge to it through `makeSkiaParagraph` / `paragraphIntrinsicWidths`,
|
||||||
|
// whose actuals sit in skikoRendererMain (compiled into both the official-skiko
|
||||||
|
// and mingw-fork siblings). Same pattern as createRenderBackend.
|
||||||
|
|
||||||
/** Carries text+style for the intrinsics-based Paragraph factories; intrinsic widths come from a
|
/** Construct the nativeMain [SkiaParagraph] over a skiko-backed ops seam. */
|
||||||
throwaway unbounded SdlParagraph. `density` here is the LocalDensity scalar (dpr on Retina); it
|
private fun makeSkiaParagraph(
|
||||||
converts sp → pixels so the intrinsic widths land in the same pixel space the layout tree measures
|
text: String,
|
||||||
itself in. */
|
style: TextStyle,
|
||||||
internal class SdlParagraphIntrinsics(
|
width: Float,
|
||||||
|
maxLines: Int,
|
||||||
|
ellipsize: Boolean,
|
||||||
|
density: Float,
|
||||||
|
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
|
||||||
|
): Paragraph = SkiaParagraph(
|
||||||
|
text, style, width,
|
||||||
|
buildParagraphOps(text, style, width, maxLines, ellipsize, density, spanStyles),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** [min, max] intrinsic width from a throwaway unbounded skiko layout. */
|
||||||
|
internal expect fun paragraphIntrinsicWidths(
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
density: Float,
|
||||||
|
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
|
||||||
|
): FloatArray
|
||||||
|
|
||||||
|
/** Carries text+style for the intrinsics-based Paragraph factories; intrinsic
|
||||||
|
widths come from an unbounded skiko layout. `density` is the LocalDensity
|
||||||
|
scalar (dpr on Retina): it converts sp → pixels so intrinsic widths land in
|
||||||
|
the same physical-pixel space the layout tree measures in. */
|
||||||
|
internal class NativeParagraphIntrinsics(
|
||||||
val paragraphText: String,
|
val paragraphText: String,
|
||||||
val paragraphStyle: TextStyle,
|
val paragraphStyle: TextStyle,
|
||||||
val density: Float,
|
val density: Float,
|
||||||
val spanStyles: List<AnnotatedString.Range<SpanStyle>> = emptyList(),
|
val spanStyles: List<AnnotatedString.Range<SpanStyle>> = emptyList(),
|
||||||
) : ParagraphIntrinsics {
|
) : ParagraphIntrinsics {
|
||||||
private val probe = SdlParagraph(paragraphText, paragraphStyle, Float.POSITIVE_INFINITY, Int.MAX_VALUE, density, spanStyles)
|
private val widths = paragraphIntrinsicWidths(paragraphText, paragraphStyle, density, spanStyles)
|
||||||
override val minIntrinsicWidth: Float = probe.minIntrinsicWidth
|
override val minIntrinsicWidth: Float = widths[0]
|
||||||
override val maxIntrinsicWidth: Float = probe.maxIntrinsicWidth
|
override val maxIntrinsicWidth: Float = widths[1]
|
||||||
override val hasStaleResolvedFonts: Boolean = false
|
override val hasStaleResolvedFonts: Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +69,7 @@ actual fun ParagraphIntrinsics(
|
|||||||
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
||||||
density: Density,
|
density: Density,
|
||||||
resourceLoader: Font.ResourceLoader,
|
resourceLoader: Font.ResourceLoader,
|
||||||
): ParagraphIntrinsics = SdlParagraphIntrinsics(text, style, density.density, spanStyles)
|
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, spanStyles)
|
||||||
|
|
||||||
actual fun ParagraphIntrinsics(
|
actual fun ParagraphIntrinsics(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -48,7 +78,7 @@ actual fun ParagraphIntrinsics(
|
|||||||
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
||||||
density: Density,
|
density: Density,
|
||||||
fontFamilyResolver: FontFamily.Resolver,
|
fontFamilyResolver: FontFamily.Resolver,
|
||||||
): ParagraphIntrinsics = SdlParagraphIntrinsics(text, style, density.density, spanStyles)
|
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, spanStyles)
|
||||||
|
|
||||||
actual fun ParagraphIntrinsics(
|
actual fun ParagraphIntrinsics(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -57,7 +87,7 @@ actual fun ParagraphIntrinsics(
|
|||||||
density: Density,
|
density: Density,
|
||||||
fontFamilyResolver: FontFamily.Resolver,
|
fontFamilyResolver: FontFamily.Resolver,
|
||||||
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
||||||
): ParagraphIntrinsics = SdlParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles())
|
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles())
|
||||||
|
|
||||||
actual fun ParagraphIntrinsics(
|
actual fun ParagraphIntrinsics(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -67,12 +97,11 @@ actual fun ParagraphIntrinsics(
|
|||||||
fontFamilyResolver: FontFamily.Resolver,
|
fontFamilyResolver: FontFamily.Resolver,
|
||||||
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
||||||
softWrap: Boolean,
|
softWrap: Boolean,
|
||||||
): ParagraphIntrinsics = SdlParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles())
|
): ParagraphIntrinsics = NativeParagraphIntrinsics(text, style, density.density, annotations.filterSpanStyles())
|
||||||
|
|
||||||
// AnnotatedString.Annotation is a sealed interface with subtypes SpanStyle,
|
// AnnotatedString.Annotation is a sealed interface (SpanStyle, ParagraphStyle,
|
||||||
// ParagraphStyle, LinkAnnotation, StringAnnotation, TtsAnnotation, etc. Only
|
// LinkAnnotation, StringAnnotation, TtsAnnotation, …). Only SpanStyle affects
|
||||||
// SpanStyle contributes to glyph rendering — the paint path knows how to
|
// glyph layout/painting.
|
||||||
// interpret it via drawNativeText's `inSpans` argument.
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun List<AnnotatedString.Range<out AnnotatedString.Annotation>>.filterSpanStyles():
|
private fun List<AnnotatedString.Range<out AnnotatedString.Annotation>>.filterSpanStyles():
|
||||||
List<AnnotatedString.Range<SpanStyle>> =
|
List<AnnotatedString.Range<SpanStyle>> =
|
||||||
@@ -91,7 +120,7 @@ actual fun Paragraph(
|
|||||||
width: Float,
|
width: Float,
|
||||||
density: Density,
|
density: Density,
|
||||||
resourceLoader: Font.ResourceLoader,
|
resourceLoader: Font.ResourceLoader,
|
||||||
): Paragraph = SdlParagraph(text, style, width, maxLines, density.density, spanStyles)
|
): Paragraph = makeSkiaParagraph(text, style, width, maxLines, ellipsis, density.density, spanStyles)
|
||||||
|
|
||||||
actual fun Paragraph(
|
actual fun Paragraph(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -103,7 +132,7 @@ actual fun Paragraph(
|
|||||||
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
||||||
maxLines: Int,
|
maxLines: Int,
|
||||||
ellipsis: Boolean,
|
ellipsis: Boolean,
|
||||||
): Paragraph = SdlParagraph(text, style, width, maxLines, density.density, spanStyles)
|
): Paragraph = makeSkiaParagraph(text, style, width, maxLines, ellipsis, density.density, spanStyles)
|
||||||
|
|
||||||
actual fun Paragraph(
|
actual fun Paragraph(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -115,7 +144,7 @@ actual fun Paragraph(
|
|||||||
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
||||||
maxLines: Int,
|
maxLines: Int,
|
||||||
ellipsis: Boolean,
|
ellipsis: Boolean,
|
||||||
): Paragraph = SdlParagraph(text, style, widthFrom(constraints), maxLines, density.density, spanStyles)
|
): Paragraph = makeSkiaParagraph(text, style, widthFrom(constraints), maxLines, ellipsis, density.density, spanStyles)
|
||||||
|
|
||||||
actual fun Paragraph(
|
actual fun Paragraph(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -127,7 +156,9 @@ actual fun Paragraph(
|
|||||||
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
placeholders: List<AnnotatedString.Range<Placeholder>>,
|
||||||
maxLines: Int,
|
maxLines: Int,
|
||||||
overflow: TextOverflow,
|
overflow: TextOverflow,
|
||||||
): Paragraph = SdlParagraph(text, style, widthFrom(constraints), maxLines, density.density, spanStyles)
|
): Paragraph = makeSkiaParagraph(
|
||||||
|
text, style, widthFrom(constraints), maxLines, overflow == TextOverflow.Ellipsis, density.density, spanStyles,
|
||||||
|
)
|
||||||
|
|
||||||
actual fun Paragraph(
|
actual fun Paragraph(
|
||||||
paragraphIntrinsics: ParagraphIntrinsics,
|
paragraphIntrinsics: ParagraphIntrinsics,
|
||||||
@@ -135,8 +166,8 @@ actual fun Paragraph(
|
|||||||
ellipsis: Boolean,
|
ellipsis: Boolean,
|
||||||
width: Float,
|
width: Float,
|
||||||
): Paragraph {
|
): Paragraph {
|
||||||
val vI = paragraphIntrinsics as SdlParagraphIntrinsics
|
val i = paragraphIntrinsics as NativeParagraphIntrinsics
|
||||||
return SdlParagraph(vI.paragraphText, vI.paragraphStyle, width, maxLines, vI.density, vI.spanStyles)
|
return makeSkiaParagraph(i.paragraphText, i.paragraphStyle, width, maxLines, ellipsis, i.density, i.spanStyles)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun Paragraph(
|
actual fun Paragraph(
|
||||||
@@ -145,8 +176,8 @@ actual fun Paragraph(
|
|||||||
maxLines: Int,
|
maxLines: Int,
|
||||||
ellipsis: Boolean,
|
ellipsis: Boolean,
|
||||||
): Paragraph {
|
): Paragraph {
|
||||||
val vI = paragraphIntrinsics as SdlParagraphIntrinsics
|
val i = paragraphIntrinsics as NativeParagraphIntrinsics
|
||||||
return SdlParagraph(vI.paragraphText, vI.paragraphStyle, widthFrom(constraints), maxLines, vI.density, vI.spanStyles)
|
return makeSkiaParagraph(i.paragraphText, i.paragraphStyle, widthFrom(constraints), maxLines, ellipsis, i.density, i.spanStyles)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun Paragraph(
|
actual fun Paragraph(
|
||||||
@@ -155,6 +186,9 @@ actual fun Paragraph(
|
|||||||
maxLines: Int,
|
maxLines: Int,
|
||||||
overflow: TextOverflow,
|
overflow: TextOverflow,
|
||||||
): Paragraph {
|
): Paragraph {
|
||||||
val vI = paragraphIntrinsics as SdlParagraphIntrinsics
|
val i = paragraphIntrinsics as NativeParagraphIntrinsics
|
||||||
return SdlParagraph(vI.paragraphText, vI.paragraphStyle, widthFrom(constraints), maxLines, vI.density, vI.spanStyles)
|
return makeSkiaParagraph(
|
||||||
|
i.paragraphText, i.paragraphStyle, widthFrom(constraints), maxLines,
|
||||||
|
overflow == TextOverflow.Ellipsis, i.density, i.spanStyles,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
package androidx.compose.ui.text
|
|
||||||
|
|
||||||
import com.compose.sdl.text.projectFontName
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Rect
|
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Canvas
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Path
|
|
||||||
import androidx.compose.ui.graphics.Shadow
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.style.ResolvedTextDirection
|
|
||||||
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
|
|
||||||
import com.compose.sdl.text.resolveRunPx
|
|
||||||
import com.compose.sdl.text.runVariations
|
|
||||||
import com.compose.sdl.text.spansAffectMetrics
|
|
||||||
import com.compose.sdl.text.styledLineCellHeight
|
|
||||||
import com.compose.sdl.text.styledSliceWidth
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: SdlParagraph — Paragraph actual bridged to the project TextMeasurer
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
The upstream `Paragraph` (Skia/skiko-only) can't be used on the SDL target, so this bridges the
|
|
||||||
upstream Paragraph interface to the project's name-based `TextMeasurer` (SDL_ttf / FreeType line
|
|
||||||
wrapping + measurement). MEASUREMENT is real (width/height/line metrics/offset<->position/cursor/
|
|
||||||
word boundary); PAINT is stubbed for now (Phase 1) — MultiParagraph draws through the existing
|
|
||||||
project text path, so this actual is exercised for LAYOUT geometry, not glyph rasterisation yet.
|
|
||||||
|
|
||||||
fontSize is `sp` and is converted to physical pixels through `density`. `Modifier.padding` /
|
|
||||||
`defaultMinSize` / etc. also resolve `Dp.toPx()` via `LocalDensity` in the tree; this file matches
|
|
||||||
that convention so text measurement lands in the same pixel space as everything else.
|
|
||||||
*/
|
|
||||||
internal class SdlParagraph(
|
|
||||||
private val text: String,
|
|
||||||
private val style: TextStyle,
|
|
||||||
widthConstraint: Float,
|
|
||||||
private val maxLines: Int,
|
|
||||||
private val density: Float = 1f,
|
|
||||||
// AnnotatedString span ranges (colour, weight, family, size). Colour-only
|
|
||||||
// spans stay measurement-free; size/weight spans switch the width/height/
|
|
||||||
// advance paths to the styled helpers (styledSliceWidth / cell height) so
|
|
||||||
// the layout box matches the styled glyphs the paint path draws. WRAPPING
|
|
||||||
// break points still come from the base style (known simplification).
|
|
||||||
private val spanStyles: List<AnnotatedString.Range<SpanStyle>> = emptyList(),
|
|
||||||
) : Paragraph {
|
|
||||||
|
|
||||||
private val fontPx: Int =
|
|
||||||
((if (style.fontSize.isUnspecified) 14f else style.fontSize.value) * density).toInt().coerceAtLeast(1)
|
|
||||||
private val family: String? = style.fontFamily.projectFontName()
|
|
||||||
|
|
||||||
private val maxWidthPx: Int =
|
|
||||||
if (widthConstraint.isFinite() && widthConstraint > 0f) widthConstraint.toInt() else Int.MAX_VALUE
|
|
||||||
|
|
||||||
// The paragraph's fontWeight mapped onto the variable font's `wght` axis.
|
|
||||||
// Threaded through BOTH measurement and paint so the layout box matches the
|
|
||||||
// weighted glyphs — otherwise heavier text is measured at Normal width and
|
|
||||||
// the last character clips (e.g. "Search" → "Searc").
|
|
||||||
private val variations: List<androidx.compose.ui.text.font.FontVariation.Setting>? =
|
|
||||||
style.fontWeight?.let { listOf(androidx.compose.ui.text.font.FontVariation.weight(it.weight)) }
|
|
||||||
|
|
||||||
private val wrapped = currentTextMeasurer.wrap(text, fontPx, maxWidthPx, family, variations)
|
|
||||||
private val allLines: List<String> = wrapped.lines
|
|
||||||
private val lineStarts: IntArray = wrapped.lineStarts
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// single-measure route.
|
|
||||||
private val metricSpans: Boolean = spansAffectMetrics(spanStyles)
|
|
||||||
|
|
||||||
override val lineCount: Int = minOf(allLines.size, maxLines).coerceAtLeast(1)
|
|
||||||
override val didExceedMaxLines: Boolean = allLines.size > maxLines
|
|
||||||
|
|
||||||
private fun measureStr(inStr: String): Float =
|
|
||||||
currentTextMeasurer.measure(inStr, fontPx, Int.MAX_VALUE, family, variations).width.toFloat()
|
|
||||||
|
|
||||||
/** Width of a slice of the ORIGINAL text starting at inGlobalStart — styled
|
|
||||||
(per-run size/weight) when metric spans exist, base otherwise. */
|
|
||||||
private fun measureSlice(inSlice: String, inGlobalStart: Int): Float =
|
|
||||||
if (metricSpans) {
|
|
||||||
styledSliceWidth(inSlice, inGlobalStart, spanStyles, fontPx, density, currentTextMeasurer, family, variations)
|
|
||||||
} else measureStr(inSlice)
|
|
||||||
|
|
||||||
// Lazy — measuring every wrapped line up-front taxed paragraphs that are
|
|
||||||
// only ever painted (draw doesn't read per-line widths; alignment and
|
|
||||||
// selection do, on demand).
|
|
||||||
private val lineWidths: FloatArray by lazy {
|
|
||||||
FloatArray(lineCount) { measureSlice(allLines[it], lineStarts[it]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) { if (it == 0) firstLineH else lh }
|
|
||||||
else FloatArray(lineCount) {
|
|
||||||
// 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 {
|
|
||||||
val vTops = FloatArray(lineCount + 1)
|
|
||||||
for (i in 0 until lineCount) vTops[i + 1] = vTops[i] + lineHeights[i]
|
|
||||||
vTops
|
|
||||||
}
|
|
||||||
|
|
||||||
override val width: Float by lazy {
|
|
||||||
if (maxWidthPx == Int.MAX_VALUE) (lineWidths.maxOrNull() ?: 0f) else widthConstraint
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
// then clamps maxIntrinsicWidth into [minWidth, Infinity]). Measuring the whole
|
|
||||||
// text `.replace("\n", " ")` as one line reported a 17-line gutter as a ~600px
|
|
||||||
// single line, so a Modifier.width(18.dp) gutter Text got laid out at 600px
|
|
||||||
// wide and the line-number column vanished under the body text.
|
|
||||||
// Both intrinsics are LAZY — they measure every hard line / every word, and
|
|
||||||
// most paragraphs (bounded-width Text) never read them. Eager computation
|
|
||||||
// here used to tax every Text() layout with a full extra measurement pass.
|
|
||||||
override val maxIntrinsicWidth: Float by lazy {
|
|
||||||
var vMax = 0f
|
|
||||||
var vStart = 0
|
|
||||||
while (vStart <= text.length) {
|
|
||||||
val vNl = text.indexOf('\n', vStart)
|
|
||||||
val vEnd = if (vNl < 0) text.length else vNl
|
|
||||||
val vLine = text.substring(vStart, vEnd)
|
|
||||||
val vW = if (vLine.isEmpty()) 0f else measureSlice(vLine, vStart)
|
|
||||||
if (vW > vMax) vMax = vW
|
|
||||||
if (vNl < 0) break
|
|
||||||
vStart = vNl + 1
|
|
||||||
}
|
|
||||||
vMax
|
|
||||||
}
|
|
||||||
// Manual whitespace scan — the old Regex("\\s+").split compiled the regex
|
|
||||||
// and allocated the full word list on every paragraph construction.
|
|
||||||
override val minIntrinsicWidth: Float by lazy {
|
|
||||||
var vMax = 0f
|
|
||||||
var vI = 0
|
|
||||||
val vN = text.length
|
|
||||||
while (vI < vN) {
|
|
||||||
while (vI < vN && text[vI].isWhitespace()) vI++
|
|
||||||
val vStart = vI
|
|
||||||
while (vI < vN && !text[vI].isWhitespace()) vI++
|
|
||||||
if (vI > vStart) vMax = max(vMax, measureSlice(text.substring(vStart, vI), vStart))
|
|
||||||
}
|
|
||||||
vMax
|
|
||||||
}
|
|
||||||
|
|
||||||
// Baseline ≈ 80% of the line's box — the same heuristic as before, now per
|
|
||||||
// line so a size-span line reports a baseline inside ITS taller box.
|
|
||||||
override val firstBaseline: Float by lazy { lineHeights[0] * 0.8f }
|
|
||||||
override val lastBaseline: Float by lazy { lineTops[lineCount - 1] + lineHeights[lineCount - 1] * 0.8f }
|
|
||||||
|
|
||||||
override val placeholderRects: List<Rect?> = emptyList()
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Line metrics
|
|
||||||
override fun getLineLeft(lineIndex: Int): Float = 0f
|
|
||||||
override fun getLineRight(lineIndex: Int): Float = lineWidths.getOrElse(lineIndex) { 0f }
|
|
||||||
override fun getLineWidth(lineIndex: Int): Float = lineWidths.getOrElse(lineIndex) { 0f }
|
|
||||||
override fun getLineTop(lineIndex: Int): Float = lineTops[lineIndex.coerceIn(0, lineCount)]
|
|
||||||
override fun getLineBottom(lineIndex: Int): Float = lineTops[(lineIndex + 1).coerceIn(0, lineCount)]
|
|
||||||
override fun getLineHeight(lineIndex: Int): Float = lineHeights.getOrElse(lineIndex) { lh }
|
|
||||||
override fun getLineBaseline(lineIndex: Int): Float =
|
|
||||||
getLineTop(lineIndex) + getLineHeight(lineIndex) * 0.8f
|
|
||||||
override fun getLineStart(lineIndex: Int): Int = lineStarts.getOrElse(lineIndex) { 0 }
|
|
||||||
override fun getLineEnd(lineIndex: Int, visibleEnd: Boolean): Int =
|
|
||||||
lineStarts.getOrElse(lineIndex) { 0 } + allLines.getOrElse(lineIndex) { "" }.length
|
|
||||||
override fun isLineEllipsized(lineIndex: Int): Boolean = false
|
|
||||||
|
|
||||||
override fun getLineForOffset(offset: Int): Int {
|
|
||||||
// Bound by BOTH lineCount and the lineStarts length (they should match, but a
|
|
||||||
// caller may pass an offset at/after text end during selection) and clamp the
|
|
||||||
// result so it's always a valid index into the per-line arrays.
|
|
||||||
val vN = minOf(lineCount, lineStarts.size)
|
|
||||||
var vLine = 0
|
|
||||||
for (i in 0 until vN) {
|
|
||||||
if (lineStarts[i] <= offset) vLine = i else break
|
|
||||||
}
|
|
||||||
return vLine.coerceIn(0, (lineCount - 1).coerceAtLeast(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getLineForVerticalPosition(vertical: Float): Int {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Offset <-> position
|
|
||||||
|
|
||||||
// Per-line cumulative advances, built lazily on the first position query:
|
|
||||||
// advances[c] = rendered width of the line's first c chars. Cursor math and
|
|
||||||
// selection then read O(1) / binary-search instead of re-measuring substring
|
|
||||||
// prefixes on every call (the old path was O(n²) per pointer event).
|
|
||||||
private val lineAdvances = arrayOfNulls<FloatArray>(lineCount)
|
|
||||||
|
|
||||||
private fun advancesFor(inLine: Int): FloatArray {
|
|
||||||
lineAdvances.getOrNull(inLine)?.let { if (it != null) return it }
|
|
||||||
val vLineStr = allLines.getOrElse(inLine) { "" }
|
|
||||||
val vArr = FloatArray(vLineStr.length + 1)
|
|
||||||
if (!metricSpans) {
|
|
||||||
for (c in 1..vLineStr.length) vArr[c] = measureStr(vLineStr.substring(0, c))
|
|
||||||
} else {
|
|
||||||
// Styled prefixes: full runs before the column at their own size/
|
|
||||||
// weight, the partial run measured at ITS style — matches the paint
|
|
||||||
// path's per-run advances so the cursor/selection track the glyphs.
|
|
||||||
val vRuns = com.compose.sdl.text.lineColorRuns(
|
|
||||||
vLineStr, lineStarts.getOrElse(inLine) { 0 }, spanStyles, Color.Unspecified,
|
|
||||||
)
|
|
||||||
var vBase = 0f
|
|
||||||
for (vRun in vRuns) {
|
|
||||||
val vPx = resolveRunPx(vRun, fontPx, density)
|
|
||||||
val vVars = runVariations(vRun, variations)
|
|
||||||
for (c in vRun.start until vRun.end) {
|
|
||||||
vArr[c + 1] = vBase + currentTextMeasurer.measure(
|
|
||||||
vLineStr.substring(vRun.start, c + 1), vPx, Int.MAX_VALUE, family, vVars,
|
|
||||||
).width
|
|
||||||
}
|
|
||||||
vBase = vArr[vRun.end]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inLine in lineAdvances.indices) lineAdvances[inLine] = vArr
|
|
||||||
return vArr
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float {
|
|
||||||
val vLine = getLineForOffset(offset)
|
|
||||||
val vAdv = advancesFor(vLine)
|
|
||||||
val vCol = (offset - lineStarts.getOrElse(vLine) { 0 }).coerceIn(0, vAdv.size - 1)
|
|
||||||
return vAdv[vCol]
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getOffsetForPosition(position: Offset): Int {
|
|
||||||
val vLine = getLineForVerticalPosition(position.y)
|
|
||||||
val vAdv = advancesFor(vLine)
|
|
||||||
// Advances are monotonic — binary-search the first edge >= x, then pick
|
|
||||||
// the nearer of it and its left neighbour (same "nearest left edge"
|
|
||||||
// semantics as the old linear scan).
|
|
||||||
var vLo = 0
|
|
||||||
var vHi = vAdv.size - 1
|
|
||||||
while (vLo < vHi) {
|
|
||||||
val vMid = (vLo + vHi) / 2
|
|
||||||
if (vAdv[vMid] < position.x) vLo = vMid + 1 else vHi = vMid
|
|
||||||
}
|
|
||||||
val vBest = if (vLo > 0 && (position.x - vAdv[vLo - 1]) <= (vAdv[vLo] - position.x)) vLo - 1 else vLo
|
|
||||||
return lineStarts.getOrElse(vLine) { 0 } + vBest
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCursorRect(offset: Int): Rect {
|
|
||||||
val vLine = getLineForOffset(offset)
|
|
||||||
val vX = getHorizontalPosition(offset, true)
|
|
||||||
return Rect(vX, getLineTop(vLine), vX + 1f, getLineBottom(vLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getBoundingBox(offset: Int): Rect {
|
|
||||||
val vLine = getLineForOffset(offset)
|
|
||||||
val vX0 = getHorizontalPosition(offset, true)
|
|
||||||
val vX1 = getHorizontalPosition(offset + 1, true)
|
|
||||||
return Rect(vX0, getLineTop(vLine), max(vX1, vX0), getLineBottom(vLine))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun fillBoundingBoxes(range: TextRange, array: FloatArray, arrayStart: Int) {
|
|
||||||
var vI = arrayStart
|
|
||||||
for (o in range.min until range.max) {
|
|
||||||
val vBox = getBoundingBox(o)
|
|
||||||
if (vI + 3 < array.size) {
|
|
||||||
array[vI] = vBox.left; array[vI + 1] = vBox.top
|
|
||||||
array[vI + 2] = vBox.right; array[vI + 3] = vBox.bottom
|
|
||||||
}
|
|
||||||
vI += 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getWordBoundary(offset: Int): TextRange {
|
|
||||||
if (text.isEmpty()) return TextRange.Zero
|
|
||||||
val vAt = offset.coerceIn(0, text.length - 1)
|
|
||||||
if (text[vAt].isWhitespace()) return TextRange(offset, offset)
|
|
||||||
var vStart = vAt
|
|
||||||
while (vStart > 0 && !text[vStart - 1].isWhitespace()) vStart--
|
|
||||||
var vEnd = vAt
|
|
||||||
while (vEnd < text.length && !text[vEnd].isWhitespace()) vEnd++
|
|
||||||
return TextRange(vStart, vEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getParagraphDirection(offset: Int): ResolvedTextDirection = ResolvedTextDirection.Ltr
|
|
||||||
override fun getBidiRunDirection(offset: Int): ResolvedTextDirection = ResolvedTextDirection.Ltr
|
|
||||||
|
|
||||||
override fun getPathForRange(start: Int, end: Int): Path {
|
|
||||||
// Selection highlight is rendered by TextFieldDelegate.draw via
|
|
||||||
// `textLayoutResult.getPathForRange(start, end)` + `canvas.drawPath(path, paint)`.
|
|
||||||
// Empty path here meant Shift+arrow updated the value's selection state, but the
|
|
||||||
// blue selection band never painted. Build a rectangle per line in the range,
|
|
||||||
// using the same wrap output the paint path already uses so bounds stay aligned
|
|
||||||
// to what's on screen.
|
|
||||||
val vPath = Path()
|
|
||||||
if (start >= end || text.isEmpty()) return vPath
|
|
||||||
val vFrom = start.coerceIn(0, text.length)
|
|
||||||
val vTo = end.coerceIn(0, text.length)
|
|
||||||
if (vFrom == vTo) return vPath
|
|
||||||
val vFirstLine = getLineForOffset(vFrom)
|
|
||||||
val vLastLine = getLineForOffset(vTo).coerceAtMost(lineCount - 1)
|
|
||||||
for (vLine in vFirstLine..vLastLine) {
|
|
||||||
val vLineStart = lineStarts.getOrElse(vLine) { 0 }
|
|
||||||
val vLineEnd = vLineStart + (allLines.getOrElse(vLine) { "" }.length)
|
|
||||||
val vRangeStart = maxOf(vFrom, vLineStart)
|
|
||||||
val vRangeEnd = minOf(vTo, vLineEnd)
|
|
||||||
val vLeftPx = getHorizontalPosition(vRangeStart, true)
|
|
||||||
val vRightPx =
|
|
||||||
if (vRangeEnd < vLineEnd || vLine == vLastLine) getHorizontalPosition(vRangeEnd, true)
|
|
||||||
else lineWidths.getOrElse(vLine) { 0f } // selection extends past newline → paint to line's end
|
|
||||||
if (vRightPx > vLeftPx) {
|
|
||||||
vPath.addRect(Rect(vLeftPx, getLineTop(vLine), vRightPx, getLineBottom(vLine)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return vPath
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRangeForRect(
|
|
||||||
rect: Rect,
|
|
||||||
granularity: TextGranularity,
|
|
||||||
inclusionStrategy: TextInclusionStrategy,
|
|
||||||
): TextRange = TextRange.Zero
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Paint — Phase 2. Route through NativeTextCanvas.drawNativeText using the
|
|
||||||
// paragraph's already-computed wrap so no re-measure happens at draw time.
|
|
||||||
// textDecoration (paint param merged with the style's own) + the style's
|
|
||||||
// italic reach the renderer; per-run span styles ride in via inSpans.
|
|
||||||
// Shadow / drawStyle / blendMode aren't wired (accept-and-ignore).
|
|
||||||
|
|
||||||
private fun paintCore(
|
|
||||||
inCanvas: Canvas,
|
|
||||||
inColor: Color,
|
|
||||||
@Suppress("UNUSED_PARAMETER") inShadow: Shadow?,
|
|
||||||
inDecoration: TextDecoration?,
|
|
||||||
@Suppress("UNUSED_PARAMETER") inDrawStyle: DrawStyle?,
|
|
||||||
@Suppress("UNUSED_PARAMETER") inBlendMode: BlendMode,
|
|
||||||
) {
|
|
||||||
val vNative = inCanvas as? NativeTextCanvas ?: return
|
|
||||||
val vEffectiveColor = if (inColor == Color.Unspecified) (style.color.takeIf { it != Color.Unspecified } ?: Color.Black) else inColor
|
|
||||||
val vAlign = style.textAlign ?: TextAlign.Start
|
|
||||||
vNative.drawNativeText(
|
|
||||||
inText = text,
|
|
||||||
inSpans = spanStyles.takeIf { it.isNotEmpty() },
|
|
||||||
inX = 0f,
|
|
||||||
inY = 0f,
|
|
||||||
inBoxWidth = width,
|
|
||||||
inBoxHeight = height,
|
|
||||||
inColor = vEffectiveColor,
|
|
||||||
inFontSizePx = fontPx,
|
|
||||||
inTextAlign = vAlign,
|
|
||||||
inSoftWrap = maxWidthPx != Int.MAX_VALUE,
|
|
||||||
inFontFamily = family,
|
|
||||||
inFontVariations = variations,
|
|
||||||
inBaseItalic = style.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic,
|
|
||||||
inTextDecoration = inDecoration ?: style.textDecoration,
|
|
||||||
inLineHeightPx = lh,
|
|
||||||
inTrimFirstLine = !uniformBands || styleLineHeightPx == null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Use the new paint function that takes canvas as the only required parameter.", level = DeprecationLevel.HIDDEN)
|
|
||||||
override fun paint(canvas: Canvas, color: Color, shadow: Shadow?, textDecoration: TextDecoration?) {
|
|
||||||
paintCore(canvas, color, shadow, textDecoration, null, BlendMode.SrcOver)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun paint(
|
|
||||||
canvas: Canvas,
|
|
||||||
color: Color,
|
|
||||||
shadow: Shadow?,
|
|
||||||
textDecoration: TextDecoration?,
|
|
||||||
drawStyle: DrawStyle?,
|
|
||||||
blendMode: BlendMode,
|
|
||||||
) {
|
|
||||||
paintCore(canvas, color, shadow, textDecoration, drawStyle, blendMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun paint(
|
|
||||||
canvas: Canvas,
|
|
||||||
brush: Brush,
|
|
||||||
alpha: Float,
|
|
||||||
shadow: Shadow?,
|
|
||||||
textDecoration: TextDecoration?,
|
|
||||||
drawStyle: DrawStyle?,
|
|
||||||
blendMode: BlendMode,
|
|
||||||
) {
|
|
||||||
// Reduce brush to the SolidColor case (SDL text path is solid-color only);
|
|
||||||
// non-SolidColor brushes fall back to the style's color.
|
|
||||||
val vColor = (brush as? SolidColor)?.value?.let {
|
|
||||||
if (alpha.isFinite()) it.copy(alpha = it.alpha * alpha) else it
|
|
||||||
} ?: style.color
|
|
||||||
paintCore(canvas, vColor, shadow, textDecoration, drawStyle, blendMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package androidx.compose.ui.text
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Canvas
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawStyle
|
||||||
|
import androidx.compose.ui.text.style.ResolvedTextDirection
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: SkiaParagraph — nativeMain Paragraph over a skiko-backed ops seam
|
||||||
|
// ==================
|
||||||
|
//
|
||||||
|
// `Paragraph` is a sealed interface whose actual is declared in nativeMain, so
|
||||||
|
// its implementers must live here (skiko-tied code in the child skikoRenderer
|
||||||
|
// source set counts as a "different module" for the sealed check). This class
|
||||||
|
// therefore holds no skiko types: it drives the real skiko skparagraph through
|
||||||
|
// [NativeParagraphOps] (impl in skikoRendererMain), exchanging plain data
|
||||||
|
// ([TextBoxData] / [LineMetricData] / [RectData]). Cursor/selection/line-metric
|
||||||
|
// math is adapted from upstream `SkiaParagraph.skiko.kt` (@ CMP core v1.12.x).
|
||||||
|
|
||||||
|
/** Plain mirror of a skiko `TextBox` (a glyph-run rect + its direction). */
|
||||||
|
internal class TextBoxData(
|
||||||
|
val left: Float, val top: Float, val right: Float, val bottom: Float, val isRtl: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Plain mirror of a skiko paragraph `LineMetrics`. */
|
||||||
|
internal class LineMetricData(
|
||||||
|
val startIndex: Int,
|
||||||
|
val endIndex: Int,
|
||||||
|
val endExcludingWhitespaces: Int,
|
||||||
|
val endIncludingNewline: Int,
|
||||||
|
val isHardBreak: Boolean,
|
||||||
|
val ascent: Double,
|
||||||
|
val descent: Double,
|
||||||
|
val baseline: Double,
|
||||||
|
val left: Double,
|
||||||
|
val right: Double,
|
||||||
|
val width: Double,
|
||||||
|
val height: Double,
|
||||||
|
val lineNumber: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Plain mirror of a Compose/skiko rect. */
|
||||||
|
internal class RectData(val left: Float, val top: Float, val right: Float, val bottom: Float) {
|
||||||
|
fun toComposeRect() = Rect(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skiko-free view of one laid-out skiko paragraph. Implemented in
|
||||||
|
* skikoRendererMain (SkiaParagraphOps) where skiko is on the classpath; all
|
||||||
|
* exchange types are plain so this interface can live in nativeMain beside the
|
||||||
|
* sealed [Paragraph]. [rebuildAndPaint] re-lays-out with paint-time overrides
|
||||||
|
* (Compose paints text with the resolved colour/shadow/decoration) then draws.
|
||||||
|
*/
|
||||||
|
internal interface NativeParagraphOps {
|
||||||
|
val height: Float
|
||||||
|
val lineNumber: Int
|
||||||
|
val minIntrinsicWidth: Float
|
||||||
|
val maxIntrinsicWidth: Float
|
||||||
|
val didExceedMaxLines: Boolean
|
||||||
|
val alphabeticBaseline: Float
|
||||||
|
val defaultAscentPx: Float // positive: -fontMetrics.ascent
|
||||||
|
val defaultDescentPx: Float
|
||||||
|
|
||||||
|
fun lineMetrics(): List<LineMetricData>
|
||||||
|
fun placeholderRects(): List<RectData?>
|
||||||
|
fun getRectsForRange(start: Int, end: Int, useMaxHeight: Boolean): List<TextBoxData>
|
||||||
|
fun glyphPositionAtCoordinate(x: Float, y: Float): Int
|
||||||
|
fun wordBoundary(offset: Int): IntArray // [start, end]
|
||||||
|
|
||||||
|
fun rebuildAndPaint(canvas: Canvas, color: Color, shadow: Shadow?, decoration: TextDecoration?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bridge to the skiko ops impl (actual in skikoRendererMain). */
|
||||||
|
internal expect fun buildParagraphOps(
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
width: Float,
|
||||||
|
maxLines: Int,
|
||||||
|
ellipsize: Boolean,
|
||||||
|
density: Float,
|
||||||
|
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
|
||||||
|
): NativeParagraphOps
|
||||||
|
|
||||||
|
internal class SkiaParagraph(
|
||||||
|
private val text: String,
|
||||||
|
private val style: TextStyle,
|
||||||
|
private val widthConstraint: Float,
|
||||||
|
private val ops: NativeParagraphOps,
|
||||||
|
) : Paragraph {
|
||||||
|
|
||||||
|
private val textDirection: ResolvedTextDirection = ResolvedTextDirection.Ltr
|
||||||
|
|
||||||
|
override val width: Float
|
||||||
|
get() = if (widthConstraint.isFinite()) widthConstraint else ops.maxIntrinsicWidth
|
||||||
|
override val height: Float get() = ops.height
|
||||||
|
override val minIntrinsicWidth: Float get() = ops.minIntrinsicWidth
|
||||||
|
override val maxIntrinsicWidth: Float get() = ops.maxIntrinsicWidth
|
||||||
|
override val firstBaseline: Float get() = lineMetrics.firstOrNull()?.baseline?.toFloat() ?: 0f
|
||||||
|
override val lastBaseline: Float get() = lineMetrics.lastOrNull()?.baseline?.toFloat() ?: 0f
|
||||||
|
override val didExceedMaxLines: Boolean get() = ops.didExceedMaxLines
|
||||||
|
|
||||||
|
override val lineCount: Int
|
||||||
|
get() = if (text == "" || ops.lineNumber < 1) 1 else ops.lineNumber
|
||||||
|
|
||||||
|
override val placeholderRects: List<Rect?>
|
||||||
|
get() = ops.placeholderRects().map { it?.toComposeRect() }
|
||||||
|
|
||||||
|
override fun getPathForRange(start: Int, end: Int): Path {
|
||||||
|
val path = Path()
|
||||||
|
for (b in ops.getRectsForRange(start, end, useMaxHeight = true)) {
|
||||||
|
path.addRect(Rect(b.left, b.top, b.right, b.bottom))
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCursorRect(offset: Int): Rect {
|
||||||
|
val horizontal = getHorizontalPosition(offset, true)
|
||||||
|
val line = lineMetricsForOffset(offset) ?: return Rect(horizontal, 0f, horizontal, ops.defaultDescentPx)
|
||||||
|
val isNewEmptyLine = offset - 1 == line.startIndex && offset == text.length
|
||||||
|
val asc = line.ascent.let { if (isNewEmptyLine) it.coerceAtMost(ops.defaultAscentPx.toDouble()) else it }
|
||||||
|
val desc = line.descent.let { if (isNewEmptyLine) it.coerceAtMost(ops.defaultDescentPx.toDouble()) else it }
|
||||||
|
return Rect(horizontal, (line.baseline - asc).toFloat(), horizontal, (line.baseline + desc).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLineLeft(lineIndex: Int): Float = lineMetrics.getOrNull(lineIndex)?.left?.toFloat() ?: 0f
|
||||||
|
override fun getLineRight(lineIndex: Int): Float = lineMetrics.getOrNull(lineIndex)?.right?.toFloat() ?: 0f
|
||||||
|
override fun getLineTop(lineIndex: Int): Float =
|
||||||
|
lineMetrics.getOrNull(lineIndex)?.let { floor((it.baseline - it.ascent).toFloat()) } ?: 0f
|
||||||
|
override fun getLineBottom(lineIndex: Int): Float =
|
||||||
|
lineMetrics.getOrNull(lineIndex)?.let { floor((it.baseline + it.descent).toFloat()) } ?: 0f
|
||||||
|
override fun getLineBaseline(lineIndex: Int): Float = lineMetrics.getOrNull(lineIndex)?.baseline?.toFloat() ?: 0f
|
||||||
|
override fun getLineHeight(lineIndex: Int): Float = lineMetrics.getOrNull(lineIndex)?.height?.toFloat() ?: 0f
|
||||||
|
override fun getLineWidth(lineIndex: Int): Float = lineMetrics.getOrNull(lineIndex)?.width?.toFloat() ?: 0f
|
||||||
|
override fun getLineStart(lineIndex: Int): Int = lineMetrics.getOrNull(lineIndex)?.startIndex ?: 0
|
||||||
|
|
||||||
|
override fun getLineEnd(lineIndex: Int, visibleEnd: Boolean): Int {
|
||||||
|
val metrics = lineMetrics.getOrNull(lineIndex) ?: return 0
|
||||||
|
return if (visibleEnd) {
|
||||||
|
if (lineIndex > 0 && metrics.startIndex < lineMetrics[lineIndex - 1].endIndex) metrics.endIndex
|
||||||
|
else if (metrics.startIndex < text.length && text[metrics.startIndex] == '\n') metrics.startIndex
|
||||||
|
else metrics.endExcludingWhitespaces
|
||||||
|
} else metrics.endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isLineEllipsized(lineIndex: Int): Boolean = false
|
||||||
|
|
||||||
|
override fun getLineForOffset(offset: Int): Int = when {
|
||||||
|
offset < 0 -> 0
|
||||||
|
offset > text.length -> lineCount - 1
|
||||||
|
else -> lineMetricsForOffset(offset)?.lineNumber ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLineForVerticalPosition(vertical: Float): Int =
|
||||||
|
lineMetricsForVerticalPosition(vertical)?.lineNumber ?: 0
|
||||||
|
|
||||||
|
override fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float {
|
||||||
|
val prevBox = getBoxBackwardByOffset(offset)
|
||||||
|
val nextBox = getBoxForwardByOffset(offset)
|
||||||
|
val isRtl = textDirection == ResolvedTextDirection.Rtl
|
||||||
|
return when {
|
||||||
|
prevBox == null && nextBox == null -> getAlignedStartingPosition(isRtl)
|
||||||
|
prevBox == null -> nextBox!!.cursorHorizontalPosition(true)
|
||||||
|
nextBox == null -> prevBox.cursorHorizontalPosition()
|
||||||
|
nextBox.isRtl == prevBox.isRtl -> nextBox.cursorHorizontalPosition(true)
|
||||||
|
!isRtl && !prevBox.isRtl -> nextBox.cursorHorizontalPosition(opposite = true)
|
||||||
|
isRtl && prevBox.isRtl -> nextBox.cursorHorizontalPosition(opposite = true)
|
||||||
|
usePrimaryDirection -> prevBox.cursorHorizontalPosition()
|
||||||
|
else -> nextBox.cursorHorizontalPosition(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAlignedStartingPosition(isRtl: Boolean): Float = when (style.textAlign) {
|
||||||
|
TextAlign.Left -> 0f
|
||||||
|
TextAlign.Right -> width
|
||||||
|
TextAlign.Center -> width / 2
|
||||||
|
TextAlign.Start -> if (isRtl) width else 0f
|
||||||
|
TextAlign.End -> if (isRtl) 0f else width
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getParagraphDirection(offset: Int): ResolvedTextDirection = textDirection
|
||||||
|
|
||||||
|
override fun getBidiRunDirection(offset: Int): ResolvedTextDirection =
|
||||||
|
if (getBoxForwardByOffset(offset)?.isRtl == true) ResolvedTextDirection.Rtl else ResolvedTextDirection.Ltr
|
||||||
|
|
||||||
|
override fun getOffsetForPosition(position: Offset): Int {
|
||||||
|
val glyphPosition = ops.glyphPositionAtCoordinate(position.x, position.y)
|
||||||
|
val expectedLine = lineMetricsForVerticalPosition(position.y) ?: return glyphPosition
|
||||||
|
if (position.x > expectedLine.left && position.x < expectedLine.right) return glyphPosition
|
||||||
|
val isNotEmptyLine = expectedLine.startIndex < expectedLine.endIndex
|
||||||
|
val rects = if (isNotEmptyLine) ops.getRectsForRange(
|
||||||
|
expectedLine.startIndex,
|
||||||
|
if (expectedLine.isHardBreak) expectedLine.endIndex else expectedLine.endIndex - 1,
|
||||||
|
useMaxHeight = false,
|
||||||
|
) else null
|
||||||
|
val leftX = rects?.firstOrNull()?.left ?: expectedLine.left.toFloat()
|
||||||
|
val rightX = rects?.lastOrNull()?.right ?: expectedLine.right.toFloat()
|
||||||
|
if (leftX == rightX) return glyphPosition
|
||||||
|
return when {
|
||||||
|
position.x <= leftX -> ops.glyphPositionAtCoordinate(leftX + 1f, position.y)
|
||||||
|
position.x >= rightX -> ops.glyphPositionAtCoordinate(rightX - 1f, position.y)
|
||||||
|
else -> glyphPosition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRangeForRect(rect: Rect, granularity: TextGranularity, inclusionStrategy: TextInclusionStrategy): TextRange =
|
||||||
|
TextRange.Zero
|
||||||
|
|
||||||
|
override fun getBoundingBox(offset: Int): Rect {
|
||||||
|
val box = getBoxForwardByOffset(offset) ?: getBoxBackwardByOffset(offset, text.length)
|
||||||
|
return box?.let { Rect(it.left, it.top, it.right, it.bottom) } ?: Rect(0f, 0f, 0f, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fillBoundingBoxes(range: TextRange, array: FloatArray, arrayStart: Int) {
|
||||||
|
// TODO(CMP-720): not supported by skiko paragraph yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getWordBoundary(offset: Int): TextRange {
|
||||||
|
if (offset < text.length && text[offset].isWhitespace() || offset == text.length) {
|
||||||
|
return if (offset > 0 && !text[offset - 1].isWhitespace()) {
|
||||||
|
val b = ops.wordBoundary(offset - 1); TextRange(b[0], b[1])
|
||||||
|
} else TextRange(offset, offset)
|
||||||
|
}
|
||||||
|
val b = ops.wordBoundary(offset)
|
||||||
|
return TextRange(b[0], b[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun paint(canvas: Canvas, color: Color, shadow: Shadow?, textDecoration: TextDecoration?) {
|
||||||
|
ops.rebuildAndPaint(canvas, color, shadow ?: style.shadow, textDecoration ?: style.textDecoration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun paint(
|
||||||
|
canvas: Canvas, color: Color, shadow: Shadow?, textDecoration: TextDecoration?,
|
||||||
|
drawStyle: DrawStyle?, blendMode: BlendMode,
|
||||||
|
) {
|
||||||
|
ops.rebuildAndPaint(canvas, color, shadow ?: style.shadow, textDecoration ?: style.textDecoration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun paint(
|
||||||
|
canvas: Canvas, brush: Brush, alpha: Float, shadow: Shadow?, textDecoration: TextDecoration?,
|
||||||
|
drawStyle: DrawStyle?, blendMode: BlendMode,
|
||||||
|
) {
|
||||||
|
val color = (brush as? SolidColor)?.value?.let {
|
||||||
|
if (alpha.isFinite()) it.copy(alpha = it.alpha * alpha) else it
|
||||||
|
} ?: style.color
|
||||||
|
ops.rebuildAndPaint(canvas, color, shadow ?: style.shadow, textDecoration ?: style.textDecoration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Line-metric helpers
|
||||||
|
|
||||||
|
private val lineMetrics: List<LineMetricData> by lazy { ops.lineMetrics() }
|
||||||
|
|
||||||
|
private fun lineMetricsForOffset(offset: Int): LineMetricData? =
|
||||||
|
if (offset in 0..text.length) lineMetrics.binarySearchFirstMatchingOrLast { offset < it.endIncludingNewline } else null
|
||||||
|
|
||||||
|
private fun lineMetricsForVerticalPosition(vertical: Float): LineMetricData? =
|
||||||
|
lineMetrics.binarySearchFirstMatchingOrLast { vertical < it.baseline + it.descent }
|
||||||
|
|
||||||
|
private fun getBoxForwardByOffset(offset: Int): TextBoxData? {
|
||||||
|
if (offset !in 0..text.length) return null
|
||||||
|
var to = offset + 1
|
||||||
|
while (to <= text.length) {
|
||||||
|
ops.getRectsForRange(offset, to, useMaxHeight = false).firstOrNull()?.let { return it }
|
||||||
|
to += 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBoxBackwardByOffset(offset: Int, end: Int = offset): TextBoxData? {
|
||||||
|
if (offset !in 0..text.length) return null
|
||||||
|
var from = offset - 1
|
||||||
|
while (from >= 0) {
|
||||||
|
val box = ops.getRectsForRange(from, end, useMaxHeight = false).firstOrNull()
|
||||||
|
when {
|
||||||
|
box == null -> from -= 1
|
||||||
|
text[from] == '\n' -> {
|
||||||
|
val bottom = box.bottom + box.bottom - box.top
|
||||||
|
return TextBoxData(0f, box.bottom, 0f, bottom, box.isRtl)
|
||||||
|
}
|
||||||
|
else -> return box
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TextBoxData.cursorHorizontalPosition(opposite: Boolean = false): Float =
|
||||||
|
if (isRtl) (if (opposite) right else left) else (if (opposite) left else right)
|
||||||
|
|
||||||
|
private inline fun <T> List<T>.binarySearchFirstMatchingOrLast(crossinline predicate: (T) -> Boolean): T? {
|
||||||
|
if (isEmpty()) return null
|
||||||
|
val index = binarySearch { if (predicate(it)) 1 else -1 }
|
||||||
|
return this[(-index - 1).coerceAtMost(lastIndex)]
|
||||||
|
}
|
||||||
@@ -64,21 +64,12 @@ class ComposeNativeWindow constructor(
|
|||||||
val fps: Int get() = fFps
|
val fps: Int get() = fFps
|
||||||
|
|
||||||
/** Human-readable name of the rendering pipeline:
|
/** Human-readable name of the rendering pipeline:
|
||||||
"Skia / Metal", "Skia / OpenGL", "Skia / CPU"
|
"Skia / Metal", "Skia / OpenGL", "Skia / CPU raster". */
|
||||||
"SDL3 / metal", "SDL3 / opengl", "SDL3 / direct3d11", …
|
|
||||||
For SDL3 we ask the live SDL_Renderer what driver it actually
|
|
||||||
picked (Sdl3.Auto can resolve to different drivers per platform). */
|
|
||||||
val rendererName: String
|
val rendererName: String
|
||||||
get() = when (val vMode = gpuMode) {
|
get() = when (gpuMode) {
|
||||||
is GpuMode.Skia.Metal -> "Skia / Metal"
|
is GpuMode.Skia.Metal -> "Skia / Metal"
|
||||||
is GpuMode.Skia.OpenGL -> "Skia / OpenGL"
|
is GpuMode.Skia.OpenGL -> "Skia / OpenGL"
|
||||||
is GpuMode.Software -> "Skia / CPU raster"
|
is GpuMode.Software -> "Skia / CPU raster"
|
||||||
is GpuMode.Sdl3 -> {
|
|
||||||
val vDriver = backend.renderer?.let {
|
|
||||||
SDL_GetRendererName(it.reinterpret())?.toKString()
|
|
||||||
} ?: vMode.driverHint ?: "auto"
|
|
||||||
"SDL3 / $vDriver"
|
|
||||||
}
|
|
||||||
is GpuMode.Auto -> "Auto (unresolved)"
|
is GpuMode.Auto -> "Auto (unresolved)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,55 +4,28 @@ package com.compose.sdl
|
|||||||
// MARK: GpuMode
|
// MARK: GpuMode
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/** Which renderer backend (Skia or SDL3) and which GPU driver to use.
|
/** Which GPU path the Skia renderer uses.
|
||||||
Sealed hierarchy so callers can pick a generic mode (Auto, None) or
|
*
|
||||||
refine all the way down to a specific driver inside SDL3 / Skia.
|
* ┌─ Auto — pick the best for this platform (Metal on macOS, OpenGL on
|
||||||
|
* │ Linux, OpenGL on Windows). If the GPU context/bridge can't be
|
||||||
┌─ Auto — pick the best available for this platform/build
|
* │ created, ComposeWindow falls back to Software.
|
||||||
├─ None — Skia CPU raster (no GPU); host buffer uploaded via SDL_Texture
|
* ├─ Software — Skia CPU raster: paints a host pixel buffer that SDL_Renderer
|
||||||
├─ Skia.* — Skia GPU bridges (macOS / Linux only; needs Skiko klib)
|
* │ uploads as a texture each frame. No GPU context needed.
|
||||||
│ ├─ OpenGL — Skia + SDL3 OpenGL context
|
* └─ Skia.* — Skia GPU bridges
|
||||||
│ └─ Metal — Skia + CAMetalLayer via SDL_Metal_CreateView (macOS only)
|
* ├─ OpenGL — Skia GL backend on an SDL OpenGL (WGL/GLX) context
|
||||||
└─ Sdl3.* — pure SDL3 renderer + SDL3_ttf
|
* └─ Metal — Skia Metal on a CAMetalLayer via SDL_Metal_CreateView (macOS)
|
||||||
├─ Auto — let SDL pick the best driver for this OS
|
*
|
||||||
├─ Software, OpenGL, Metal, Vulkan, D3D11, D3D12 — force a driver
|
* AUTO resolves via rendererPreferredGpuMode() per target, at composeWindow entry. */
|
||||||
|
|
||||||
Each native target only knows about a subset:
|
|
||||||
- macOS / Linux default build: Auto / None / Skia.*; Sdl3.* needs the
|
|
||||||
-Prenderer=sdl3 build flag, otherwise composeWindow errors out.
|
|
||||||
- mingwX64: Auto / None / Sdl3.*; Skia.* always errors (no Skiko klib).
|
|
||||||
|
|
||||||
AUTO resolves via preferredGpuMode() per-target. The factory in
|
|
||||||
each RenderBackend implementation refuses unknown-for-platform modes
|
|
||||||
with a clear error so callers get a real failure instead of a silent
|
|
||||||
fallback. */
|
|
||||||
sealed class GpuMode {
|
sealed class GpuMode {
|
||||||
/** Let the platform pick: Skia.Metal on macOS, Skia.OpenGL on Linux,
|
/** Let the platform pick: Skia.Metal on macOS, Skia.OpenGL on Linux/Windows. */
|
||||||
Sdl3.Auto on mingwX64. Resolved at composeWindow entry. */
|
|
||||||
object Auto : GpuMode() { override fun toString() = "Auto" }
|
object Auto : GpuMode() { override fun toString() = "Auto" }
|
||||||
|
|
||||||
/** Skia CPU raster — paints into a host pixel buffer that
|
/** Skia CPU raster — a host pixel buffer uploaded via SDL_Renderer each frame. */
|
||||||
SDL_Renderer uploads as a texture each frame. */
|
|
||||||
object Software : GpuMode() { override fun toString() = "Software" }
|
object Software : GpuMode() { override fun toString() = "Software" }
|
||||||
|
|
||||||
/** Skia GPU bridges. Available on macOS / Linux only. */
|
/** Skia GPU bridges. */
|
||||||
sealed class Skia : GpuMode() {
|
sealed class Skia : GpuMode() {
|
||||||
object OpenGL : Skia() { override fun toString() = "Skia.OpenGL" }
|
object OpenGL : Skia() { override fun toString() = "Skia.OpenGL" }
|
||||||
object Metal : Skia() { override fun toString() = "Skia.Metal" }
|
object Metal : Skia() { override fun toString() = "Skia.Metal" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure-SDL3 renderer with an optional driver pin. AUTO lets SDL
|
|
||||||
choose; the rest map to SDL_HINT_RENDER_DRIVER strings. */
|
|
||||||
sealed class Sdl3 : GpuMode() {
|
|
||||||
/** SDL_HINT_RENDER_DRIVER value, or null for SDL's default pick. */
|
|
||||||
abstract val driverHint: String?
|
|
||||||
|
|
||||||
object Auto : Sdl3() { override val driverHint: String? = null; override fun toString() = "Sdl3.Auto" }
|
|
||||||
object Software : Sdl3() { override val driverHint: String = "software"; override fun toString() = "Sdl3.Software" }
|
|
||||||
object OpenGL : Sdl3() { override val driverHint: String = "opengl"; override fun toString() = "Sdl3.OpenGL" }
|
|
||||||
object Metal : Sdl3() { override val driverHint: String = "metal"; override fun toString() = "Sdl3.Metal" }
|
|
||||||
object Vulkan : Sdl3() { override val driverHint: String = "vulkan"; override fun toString() = "Sdl3.Vulkan" }
|
|
||||||
object D3D11 : Sdl3() { override val driverHint: String = "direct3d11"; override fun toString() = "Sdl3.D3D11" }
|
|
||||||
object D3D12 : Sdl3() { override val driverHint: String = "direct3d12"; override fun toString() = "Sdl3.D3D12" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
package com.compose.sdl
|
package com.compose.sdl
|
||||||
|
|
||||||
import com.compose.sdl.res.ImageLoader
|
import com.compose.sdl.res.ImageLoader
|
||||||
import com.compose.sdl.text.TextMeasurer
|
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
// MARK: RenderBackend
|
// MARK: RenderBackend
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/** Per-frame rendering interface that ComposeWindow uses. Hides whether
|
/** Per-frame rendering interface that ComposeWindow uses. Skia everywhere
|
||||||
we're drawing through Skia (macOS / Linux) or pure SDL3 primitives
|
(official Skiko on macOS/Linux, the fork on mingwX64); text measurement +
|
||||||
(mingwX64). Each implementation owns its own measurement strategy
|
drawing go through the skiko paragraph engine (androidx.compose.ui.text
|
||||||
exposed via textMeasurer so the common layout pass agrees with what
|
Paragraph actuals), so there is no renderer-owned measurer here. */
|
||||||
the renderer paints. */
|
|
||||||
interface RenderBackend {
|
interface RenderBackend {
|
||||||
/** Measurer the shared TextMeasurePolicy plugs into to keep layout
|
|
||||||
widths in sync with what the renderer actually draws. */
|
|
||||||
val textMeasurer: TextMeasurer
|
|
||||||
|
|
||||||
/** Loader the shared ImageMeasurePolicy / Res.readBytes plug into. Backed
|
/** Loader the shared ImageMeasurePolicy / Res.readBytes plug into. Backed
|
||||||
by the same decode cache the renderer uses to paint images, so a
|
by the same decode cache the renderer uses to paint images, so a
|
||||||
resource is decoded once and shared between measure and draw. */
|
resource is decoded once and shared between measure and draw. */
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ class SDL3Backend(
|
|||||||
is GpuMode.Skia.OpenGL -> SDL_WINDOW_OPENGL
|
is GpuMode.Skia.OpenGL -> SDL_WINDOW_OPENGL
|
||||||
is GpuMode.Skia.Metal -> SDL_WINDOW_METAL
|
is GpuMode.Skia.Metal -> SDL_WINDOW_METAL
|
||||||
is GpuMode.Software -> 0UL
|
is GpuMode.Software -> 0UL
|
||||||
is GpuMode.Sdl3 -> 0UL
|
|
||||||
is GpuMode.Auto -> error("unreachable")
|
is GpuMode.Auto -> error("unreachable")
|
||||||
}
|
}
|
||||||
window = SDL_CreateWindow(title, width, height, flags)
|
window = SDL_CreateWindow(title, width, height, flags)
|
||||||
@@ -94,14 +93,9 @@ class SDL3Backend(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is GpuMode.Software, is GpuMode.Sdl3 -> {
|
is GpuMode.Software -> {
|
||||||
// For Sdl3.* with a driver pin we steer SDL_CreateRenderer
|
// Skia CPU raster: SkiaSurfaceBridge paints a host buffer that
|
||||||
// via SDL_HINT_RENDER_DRIVER. SDL3 looks the hint up the
|
// this SDL_Renderer uploads as a texture each frame.
|
||||||
// moment the renderer is created.
|
|
||||||
val vDriverHint = (gpuMode as? GpuMode.Sdl3)?.driverHint
|
|
||||||
if (vDriverHint != null) {
|
|
||||||
SDL_SetHint("SDL_RENDER_DRIVER", vDriverHint)
|
|
||||||
}
|
|
||||||
renderer = SDL_CreateRenderer(window?.reinterpret(), null)
|
renderer = SDL_CreateRenderer(window?.reinterpret(), null)
|
||||||
if (renderer == null) {
|
if (renderer == null) {
|
||||||
println("SDL_CreateRenderer failed: ${SDL_GetError()?.toKString()}")
|
println("SDL_CreateRenderer failed: ${SDL_GetError()?.toKString()}")
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
// Actual for the BlendMode.isSupported() expect in the vendored
|
|
||||||
// commonMain/Color/BlendMode set. Upstream's skiko actual lives in
|
|
||||||
// SkiaBackedPaint.skiko.kt mixed in with a pile of Skia-tied actuals we
|
|
||||||
// don't want to pull in wholesale; here we just say "yes, supported"
|
|
||||||
// the same way upstream does, since both renderer backends accept the
|
|
||||||
// full BlendMode enum (Skia maps it directly, SDL3 ignores blend mode
|
|
||||||
// — it's accept-and-ignore there).
|
|
||||||
actual fun BlendMode.isSupported(): Boolean = true
|
|
||||||
-173
@@ -1,173 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Rect
|
|
||||||
import androidx.compose.ui.graphics.colorspace.ColorSpace
|
|
||||||
import androidx.compose.ui.graphics.colorspace.ColorSpaces
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import com.compose.sdl.graphics.ProjectCanvas
|
|
||||||
import com.compose.sdl.graphics.ProjectImageBitmap
|
|
||||||
import com.compose.sdl.graphics.ProjectPaint
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Canvas/Paint native actuals
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
// Thin actuals for the vendored Paint / Shader / ColorFilter / PathEffect /
|
|
||||||
// ImageBitmap / Canvas factories + opaque platform types. The concrete impls
|
|
||||||
// (where any) live in com.compose.sdl.graphics per FIDELITY
|
|
||||||
// relocate rule; the project pipeline does not currently use these types
|
|
||||||
// (renderers go through Brush / DrawScope), so the actuals are stubs that
|
|
||||||
// satisfy the expect contracts and let upstream-shaped consumers compile.
|
|
||||||
// When a real Paint / Canvas-based code path lands, replace the stubs here.
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Paint
|
|
||||||
// Typealias to Any matching skiko pattern (upstream desktop maps to
|
|
||||||
// org.jetbrains.skia.Paint; we don't have a single backend type since
|
|
||||||
// Skia and SDL3 renderers each carry their own paint state).
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION_ERROR")
|
|
||||||
actual typealias NativePaint = Any
|
|
||||||
actual fun Paint(): Paint = ProjectPaint()
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Shader — placeholder actuals. Skia's SkiaShader.skiko.kt is blocked
|
|
||||||
// because it uses SkiaImageAsset.asSkiaBitmap (SkiaImageAsset is blocked
|
|
||||||
// by K2's expect+actual same-source-set rule for
|
|
||||||
// `internal expect fun ByteArray.putBytesInto`). SDL3 has no shader
|
|
||||||
// pipeline — gradient rasterisation goes through
|
|
||||||
// com.compose.sdl.graphics.GradientBridge directly, not Shader.
|
|
||||||
|
|
||||||
// SDL3 has no GPU shader stage — it samples gradients per-vertex straight from a
|
|
||||||
// Brush (see Sdl3DrawScope.samplerFor). ShaderBrush.applyTo only ever leaves a
|
|
||||||
// Shader on the Paint (and forces color = Black), so the gradient descriptor
|
|
||||||
// would otherwise be lost and Modifier.background(Brush.linearGradient(...)) would
|
|
||||||
// paint solid black. We stash the reconstructed gradient Brush here (its anchor
|
|
||||||
// points are already resolved against the draw size by createShader) so
|
|
||||||
// Sdl3Canvas.brushFor can recover it. Null for non-gradient shaders.
|
|
||||||
actual class Shader internal constructor() {
|
|
||||||
internal var brush: Brush? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
internal actual class TransformShader actual constructor() {
|
|
||||||
actual var shader: Shader? = null
|
|
||||||
actual fun transform(matrix: Matrix?) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the gradient stops as (position, color) pairs when explicit stops were
|
|
||||||
// supplied; otherwise fall back to the evenly-distributed colors overload.
|
|
||||||
private fun gradientStops(colors: List<Color>, colorStops: List<Float>?): Array<Pair<Float, Color>>? =
|
|
||||||
if (colorStops != null && colorStops.size == colors.size)
|
|
||||||
Array(colors.size) { colorStops[it] to colors[it] }
|
|
||||||
else null
|
|
||||||
|
|
||||||
internal actual fun ActualLinearGradientShader(
|
|
||||||
from: Offset,
|
|
||||||
to: Offset,
|
|
||||||
colors: List<Color>,
|
|
||||||
colorStops: List<Float>?,
|
|
||||||
tileMode: TileMode,
|
|
||||||
): Shader = Shader().also {
|
|
||||||
val stops = gradientStops(colors, colorStops)
|
|
||||||
it.brush = if (stops != null)
|
|
||||||
Brush.linearGradient(*stops, start = from, end = to, tileMode = tileMode)
|
|
||||||
else
|
|
||||||
Brush.linearGradient(colors = colors, start = from, end = to, tileMode = tileMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal actual fun ActualRadialGradientShader(
|
|
||||||
center: Offset,
|
|
||||||
radius: Float,
|
|
||||||
colors: List<Color>,
|
|
||||||
colorStops: List<Float>?,
|
|
||||||
tileMode: TileMode,
|
|
||||||
): Shader = Shader().also {
|
|
||||||
val stops = gradientStops(colors, colorStops)
|
|
||||||
it.brush = if (stops != null)
|
|
||||||
Brush.radialGradient(*stops, center = center, radius = radius, tileMode = tileMode)
|
|
||||||
else
|
|
||||||
Brush.radialGradient(colors = colors, center = center, radius = radius, tileMode = tileMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal actual fun ActualSweepGradientShader(
|
|
||||||
center: Offset,
|
|
||||||
colors: List<Color>,
|
|
||||||
colorStops: List<Float>?,
|
|
||||||
): Shader = Shader().also {
|
|
||||||
val stops = gradientStops(colors, colorStops)
|
|
||||||
it.brush = if (stops != null)
|
|
||||||
Brush.sweepGradient(*stops, center = center)
|
|
||||||
else
|
|
||||||
Brush.sweepGradient(colors = colors, center = center)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal actual fun ActualImageShader(
|
|
||||||
image: ImageBitmap,
|
|
||||||
tileModeX: TileMode,
|
|
||||||
tileModeY: TileMode,
|
|
||||||
): Shader = Shader()
|
|
||||||
|
|
||||||
internal actual fun ActualCompositeShader(dst: Shader, src: Shader, blendMode: BlendMode): Shader =
|
|
||||||
Shader()
|
|
||||||
|
|
||||||
// NativeColorFilter + actualTintColorFilter / actualColorMatrixColorFilter /
|
|
||||||
// actualLightingColorFilter / actualColorMatrixFromFilter actuals are now
|
|
||||||
// split per renderer:
|
|
||||||
// - Skia path: vendored from upstream SkiaColorFilter.skiko.kt into
|
|
||||||
// core/src/vendor/skikoRenderer/.../graphics/ — uses real
|
|
||||||
// org.jetbrains.skia.ColorFilter.
|
|
||||||
// - SDL3 path: stubs in core/src/sdlRendererMain/.../graphics/ColorFilter.sdl.kt.
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// PathEffect actuals live per-renderer:
|
|
||||||
// * skikoRenderer: vendored SkiaBackedPathEffect.skiko.kt (Skia-backed).
|
|
||||||
// * sdlRenderer: SdlPathEffect.sdl.kt (no-op; SDL3 has no path-effect
|
|
||||||
// pipeline). Both provide the four `internal actual
|
|
||||||
// fun actual{Corner,Dash,Chain,Stamped}PathEffect`.
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// ImageBitmap — placeholder actuals. SkiaImageAsset.skiko.kt (real Skia
|
|
||||||
// backing) is blocked on the same-source-set expect+actual K2 rule for
|
|
||||||
// `ByteArray.putBytesInto`. Both renderers use ProjectImageBitmap for now.
|
|
||||||
|
|
||||||
// Delegate to the renderer's offscreen support when present (the SDL renderer
|
|
||||||
// registers a render-to-texture impl so vendored VectorPainter / DrawCache — hence
|
|
||||||
// material3's ImageVector icons — actually render). Falls back to the stub when no
|
|
||||||
// renderer registered one (nothing renders offscreen, same as before).
|
|
||||||
internal actual fun ActualImageBitmap(
|
|
||||||
width: Int,
|
|
||||||
height: Int,
|
|
||||||
config: ImageBitmapConfig,
|
|
||||||
hasAlpha: Boolean,
|
|
||||||
colorSpace: ColorSpace,
|
|
||||||
): ImageBitmap =
|
|
||||||
com.compose.sdl.graphics.offscreenRenderer
|
|
||||||
?.createImageBitmap(width, height, config, hasAlpha, colorSpace)
|
|
||||||
?: ProjectImageBitmap(width, height, config, hasAlpha, colorSpace)
|
|
||||||
|
|
||||||
// ByteArray.decodeToImageBitmap() -> decode encoded image bytes (png/jpg/bmp/gif/
|
|
||||||
// webp) via SDL3_image, the same decoder Res.painter uses (Sdl3EncodedImageDecoder).
|
|
||||||
internal actual fun createImageBitmap(bytes: ByteArray): ImageBitmap =
|
|
||||||
com.compose.sdl.graphics.decodeEncodedImageBitmap(bytes)
|
|
||||||
?: throw IllegalArgumentException(
|
|
||||||
"createImageBitmap: could not decode ${bytes.size} bytes as an image (png/jpg/bmp/gif/webp)",
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Canvas — typealias matching skiko pattern.
|
|
||||||
|
|
||||||
actual typealias NativeCanvas = Any
|
|
||||||
|
|
||||||
internal actual fun ActualCanvas(image: ImageBitmap): Canvas =
|
|
||||||
com.compose.sdl.graphics.offscreenRenderer?.createCanvas(image) ?: ProjectCanvas()
|
|
||||||
|
|
||||||
// PathMeasure actuals live per-renderer:
|
|
||||||
// * skikoRenderer: vendored SkiaBackedPathMeasure.skiko.kt.
|
|
||||||
// * sdlRenderer: PathMeasure.sdl.kt (no-op — SDL3 has no path-measure).
|
|
||||||
|
|
||||||
// RenderEffect / BlurEffect / OffsetEffect actuals live per-renderer:
|
|
||||||
// * skikoRenderer: vendored SkiaBackedRenderEffect.skiko.kt.
|
|
||||||
// * sdlRenderer: RenderEffect.sdl.kt (no-op).
|
|
||||||
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
// SDL3-path actuals for the vendored ui.graphics.ColorFilter expects.
|
|
||||||
// The public ColorFilter subclasses (BlendModeColorFilter / ColorMatrixColorFilter /
|
|
||||||
// LightingColorFilter) already carry their parameters, so the SDL renderer reads
|
|
||||||
// the filter straight off the Paint at draw time (Sdl3DrawScope.applyColorFilter).
|
|
||||||
// NativeColorFilter only needs to round-trip the ColorMatrix back out for
|
|
||||||
// ColorMatrixColorFilter.copyColorMatrix() when a filter was built from a native
|
|
||||||
// handle. The matching real-Skia actuals live in
|
|
||||||
// core/src/vendor/skikoRenderer/.../graphics/SkiaColorFilter.skiko.kt.
|
|
||||||
// Param names match the upstream `expect` declarations exactly — required
|
|
||||||
// by Kotlin's actual-resolution rule.
|
|
||||||
|
|
||||||
internal actual class NativeColorFilter(val colorMatrix: ColorMatrix? = null)
|
|
||||||
|
|
||||||
internal actual fun actualTintColorFilter(color: Color, blendMode: BlendMode): NativeColorFilter =
|
|
||||||
NativeColorFilter()
|
|
||||||
|
|
||||||
internal actual fun actualColorMatrixColorFilter(colorMatrix: ColorMatrix): NativeColorFilter =
|
|
||||||
NativeColorFilter(colorMatrix)
|
|
||||||
|
|
||||||
internal actual fun actualLightingColorFilter(multiply: Color, add: Color): NativeColorFilter =
|
|
||||||
NativeColorFilter()
|
|
||||||
|
|
||||||
internal actual fun actualColorMatrixFromFilter(filter: NativeColorFilter): ColorMatrix =
|
|
||||||
filter.colorMatrix ?: ColorMatrix()
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
import com.compose.sdl.graphics.ProjectPath
|
|
||||||
import com.compose.sdl.graphics.ProjectPathIterator
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Path + PathIterator — SDL3 renderer actuals
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SDL3 counterpart of upstream `SkiaBackedPath.skiko.kt`. Vendored Skia path
|
|
||||||
* is backed by `org.jetbrains.skia.Path`; SDL3 has no native path type, so
|
|
||||||
* the vendored `expect fun Path()` and `expect fun PathIterator(...)`
|
|
||||||
* factories return project-side types from `com.compose.sdl.graphics`
|
|
||||||
* (a PathCommand-list impl the SDL3 renderer walks).
|
|
||||||
*/
|
|
||||||
actual fun Path(): Path = ProjectPath()
|
|
||||||
|
|
||||||
actual fun PathIterator(
|
|
||||||
path: Path,
|
|
||||||
conicEvaluation: PathIterator.ConicEvaluation,
|
|
||||||
tolerance: Float,
|
|
||||||
): PathIterator = ProjectPathIterator(path as? ProjectPath ?: ProjectPath(), conicEvaluation)
|
|
||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: PathEffect — SDL3 renderer no-op actuals
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SDL3 counterpart of upstream `SkiaBackedPathEffect.skiko.kt` (vendored into
|
|
||||||
* `vendor/skikoRenderer/`). SDL3 has no path-effect pipeline, so corner/chain/
|
|
||||||
* stamped return a shared no-op marker. Dash IS honoured: it carries its pattern
|
|
||||||
* so the stroke tessellator (Sdl3DrawScope) can split geometry into dashes.
|
|
||||||
*/
|
|
||||||
private object NoOpPathEffect : PathEffect
|
|
||||||
|
|
||||||
// Dash pattern (on/off run lengths + phase) read by Sdl3DrawScope.dashPolyline.
|
|
||||||
internal class DashPathEffect(val intervals: FloatArray, val phase: Float) : PathEffect
|
|
||||||
|
|
||||||
internal actual fun actualCornerPathEffect(radius: Float): PathEffect = NoOpPathEffect
|
|
||||||
|
|
||||||
internal actual fun actualDashPathEffect(intervals: FloatArray, phase: Float): PathEffect =
|
|
||||||
// Skia requires an even, positive-sum interval list; otherwise it's a no-op stroke.
|
|
||||||
if (intervals.isNotEmpty() && intervals.all { it >= 0f } && intervals.sum() > 0f) {
|
|
||||||
DashPathEffect(intervals, phase)
|
|
||||||
} else NoOpPathEffect
|
|
||||||
|
|
||||||
internal actual fun actualChainPathEffect(outer: PathEffect, inner: PathEffect): PathEffect =
|
|
||||||
NoOpPathEffect
|
|
||||||
|
|
||||||
internal actual fun actualStampedPathEffect(
|
|
||||||
shape: Path,
|
|
||||||
advance: Float,
|
|
||||||
phase: Float,
|
|
||||||
style: StampedPathEffectStyle,
|
|
||||||
): PathEffect = NoOpPathEffect
|
|
||||||
-161
@@ -1,161 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import com.compose.sdl.graphics.PathCommand
|
|
||||||
import com.compose.sdl.graphics.ProjectPath
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: PathMeasure — SDL3 renderer actual
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SDL3 counterpart of upstream `SkiaBackedPathMeasure.skiko.kt`, implemented
|
|
||||||
* over ProjectPath's command list: the FIRST contour is flattened to a
|
|
||||||
* polyline (same curve sampling density as the tessellator) with cumulative
|
|
||||||
* arc lengths, then length / getSegment / getPosition / getTangent interpolate
|
|
||||||
* on it. Material3 draws the Checkbox checkmark (and other progress-along-path
|
|
||||||
* effects) through getSegment — a no-op here means no checkmark glyph.
|
|
||||||
*
|
|
||||||
* Skia semantics notes: setPath measures one contour at a time (we take the
|
|
||||||
* first); getSegment APPENDS to the destination path (m3 resets it first).
|
|
||||||
*/
|
|
||||||
private class ProjectPathMeasure : PathMeasure {
|
|
||||||
|
|
||||||
// Flattened first contour: interleaved x,y pairs + cumulative length per point.
|
|
||||||
private var fPts: FloatArray = FloatArray(0)
|
|
||||||
private var fCum: FloatArray = FloatArray(0)
|
|
||||||
private var fTotal: Float = 0f
|
|
||||||
|
|
||||||
override val length: Float get() = fTotal
|
|
||||||
|
|
||||||
override fun setPath(path: Path?, forceClosed: Boolean) {
|
|
||||||
fPts = FloatArray(0); fCum = FloatArray(0); fTotal = 0f
|
|
||||||
val vCommands = (path as? ProjectPath)?.commands ?: return
|
|
||||||
val vPts = ArrayList<Float>(32)
|
|
||||||
var vCx = 0f; var vCy = 0f
|
|
||||||
var vStartX = 0f; var vStartY = 0f
|
|
||||||
var vStarted = false
|
|
||||||
var vClosed = false
|
|
||||||
|
|
||||||
fun add(inX: Float, inY: Float) { vPts.add(inX); vPts.add(inY) }
|
|
||||||
|
|
||||||
run {
|
|
||||||
for (vCmd in vCommands) when (vCmd) {
|
|
||||||
is PathCommand.MoveTo -> {
|
|
||||||
if (vStarted) return@run // first contour only (Skia semantics)
|
|
||||||
vStarted = true
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
vStartX = vCx; vStartY = vCy
|
|
||||||
add(vCx, vCy)
|
|
||||||
}
|
|
||||||
is PathCommand.LineTo -> {
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
add(vCx, vCy)
|
|
||||||
}
|
|
||||||
is PathCommand.QuadTo -> {
|
|
||||||
val vN = 12
|
|
||||||
for (vI in 1..vN) {
|
|
||||||
val vT = vI.toFloat() / vN
|
|
||||||
val vOne = 1f - vT
|
|
||||||
add(
|
|
||||||
vOne * vOne * vCx + 2f * vOne * vT * vCmd.cx + vT * vT * vCmd.x,
|
|
||||||
vOne * vOne * vCy + 2f * vOne * vT * vCmd.cy + vT * vT * vCmd.y,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
}
|
|
||||||
is PathCommand.CubicTo -> {
|
|
||||||
val vN = 16
|
|
||||||
for (vI in 1..vN) {
|
|
||||||
val vT = vI.toFloat() / vN
|
|
||||||
val vOne = 1f - vT
|
|
||||||
add(
|
|
||||||
vOne * vOne * vOne * vCx + 3f * vOne * vOne * vT * vCmd.c1x +
|
|
||||||
3f * vOne * vT * vT * vCmd.c2x + vT * vT * vT * vCmd.x,
|
|
||||||
vOne * vOne * vOne * vCy + 3f * vOne * vOne * vT * vCmd.c1y +
|
|
||||||
3f * vOne * vT * vT * vCmd.c2y + vT * vT * vT * vCmd.y,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
}
|
|
||||||
PathCommand.Close -> {
|
|
||||||
add(vStartX, vStartY)
|
|
||||||
vClosed = true
|
|
||||||
return@run
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (forceClosed && vStarted && !vClosed && vPts.size >= 4) add(vStartX, vStartY)
|
|
||||||
|
|
||||||
val vCount = vPts.size / 2
|
|
||||||
if (vCount < 2) return
|
|
||||||
fPts = FloatArray(vPts.size)
|
|
||||||
for (vI in vPts.indices) fPts[vI] = vPts[vI]
|
|
||||||
fCum = FloatArray(vCount)
|
|
||||||
var vAcc = 0f
|
|
||||||
for (vI in 1 until vCount) {
|
|
||||||
val vDx = fPts[vI * 2] - fPts[(vI - 1) * 2]
|
|
||||||
val vDy = fPts[vI * 2 + 1] - fPts[(vI - 1) * 2 + 1]
|
|
||||||
vAcc += sqrt(vDx * vDx + vDy * vDy)
|
|
||||||
fCum[vI] = vAcc
|
|
||||||
}
|
|
||||||
fTotal = vAcc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interpolated point at arc distance inD (clamped). Requires ≥2 points.
|
|
||||||
private fun pointAt(inD: Float): Offset {
|
|
||||||
val vD = inD.coerceIn(0f, fTotal)
|
|
||||||
var vI = 1
|
|
||||||
while (vI < fCum.size && fCum[vI] < vD) vI++
|
|
||||||
if (vI >= fCum.size) return Offset(fPts[fPts.size - 2], fPts[fPts.size - 1])
|
|
||||||
val vSeg = (fCum[vI] - fCum[vI - 1]).coerceAtLeast(1e-6f)
|
|
||||||
val vT = (vD - fCum[vI - 1]) / vSeg
|
|
||||||
return Offset(
|
|
||||||
fPts[(vI - 1) * 2] + (fPts[vI * 2] - fPts[(vI - 1) * 2]) * vT,
|
|
||||||
fPts[(vI - 1) * 2 + 1] + (fPts[vI * 2 + 1] - fPts[(vI - 1) * 2 + 1]) * vT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSegment(
|
|
||||||
startDistance: Float,
|
|
||||||
stopDistance: Float,
|
|
||||||
destination: Path,
|
|
||||||
startWithMoveTo: Boolean,
|
|
||||||
): Boolean {
|
|
||||||
if (fPts.size < 4 || fTotal <= 0f) return false
|
|
||||||
val vStart = startDistance.coerceIn(0f, fTotal)
|
|
||||||
val vStop = stopDistance.coerceIn(0f, fTotal)
|
|
||||||
if (vStop <= vStart) return false
|
|
||||||
|
|
||||||
val vFirst = pointAt(vStart)
|
|
||||||
if (startWithMoveTo) destination.moveTo(vFirst.x, vFirst.y)
|
|
||||||
else destination.lineTo(vFirst.x, vFirst.y)
|
|
||||||
// Interior polyline points strictly inside (start, stop).
|
|
||||||
for (vI in 1 until fCum.size) {
|
|
||||||
if (fCum[vI] <= vStart) continue
|
|
||||||
if (fCum[vI] >= vStop) break
|
|
||||||
destination.lineTo(fPts[vI * 2], fPts[vI * 2 + 1])
|
|
||||||
}
|
|
||||||
val vLast = pointAt(vStop)
|
|
||||||
destination.lineTo(vLast.x, vLast.y)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPosition(distance: Float): Offset =
|
|
||||||
if (fPts.size < 4) Offset.Unspecified else pointAt(distance)
|
|
||||||
|
|
||||||
override fun getTangent(distance: Float): Offset {
|
|
||||||
if (fPts.size < 4 || fTotal <= 0f) return Offset.Unspecified
|
|
||||||
val vD = distance.coerceIn(0f, fTotal)
|
|
||||||
var vI = 1
|
|
||||||
while (vI < fCum.size && fCum[vI] < vD) vI++
|
|
||||||
if (vI >= fCum.size) vI = fCum.size - 1
|
|
||||||
val vDx = fPts[vI * 2] - fPts[(vI - 1) * 2]
|
|
||||||
val vDy = fPts[vI * 2 + 1] - fPts[(vI - 1) * 2 + 1]
|
|
||||||
val vLen = sqrt(vDx * vDx + vDy * vDy)
|
|
||||||
return if (vLen < 1e-6f) Offset.Unspecified else Offset(vDx / vLen, vDy / vLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun PathMeasure(): PathMeasure = ProjectPathMeasure()
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: RenderEffect / BlurEffect / OffsetEffect — SDL3 renderer actuals
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SDL3 counterpart of upstream `SkiaBackedRenderEffect.skiko.kt`. SDL3 has
|
|
||||||
* no render-effect / blur / offset filter pipeline (that would require an
|
|
||||||
* offscreen framebuffer + shader chain), so `isSupported()` always returns
|
|
||||||
* false and the concrete BlurEffect / OffsetEffect are inert data holders.
|
|
||||||
*/
|
|
||||||
actual sealed class RenderEffect actual constructor() {
|
|
||||||
actual open fun isSupported(): Boolean = false
|
|
||||||
}
|
|
||||||
|
|
||||||
actual class BlurEffect actual constructor(
|
|
||||||
renderEffect: RenderEffect?,
|
|
||||||
radiusX: Float,
|
|
||||||
radiusY: Float,
|
|
||||||
edgeTreatment: TileMode,
|
|
||||||
) : RenderEffect()
|
|
||||||
|
|
||||||
actual class OffsetEffect actual constructor(
|
|
||||||
renderEffect: RenderEffect?,
|
|
||||||
offset: Offset,
|
|
||||||
) : RenderEffect()
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics
|
|
||||||
|
|
||||||
// Native actual for the vendored TileMode.isSupported() expect. The upstream
|
|
||||||
// actuals are platform-specific (Skiko / Android); our renderers map every
|
|
||||||
// TileMode themselves, so all modes are reported supported (same as Skiko's).
|
|
||||||
actual fun TileMode.isSupported(): Boolean = true
|
|
||||||
-321
@@ -1,321 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
package androidx.compose.ui.graphics.shadow
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Paint
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Blur — native actuals (no-op)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actuals for the vendored commonMain `Blur.kt` (`internal expect fun
|
|
||||||
* BlurFilter(radius)`, `internal expect class BlurFilter`, `internal expect
|
|
||||||
* fun Paint.setBlurFilter`). Upstream's Skia actual routes to
|
|
||||||
* `MaskFilter.makeBlur`; on SDL3 we have no software blur pipeline yet, so
|
|
||||||
* `BlurFilter` is a plain radius holder and `Paint.setBlurFilter` is a no-op.
|
|
||||||
*
|
|
||||||
* Downstream Shadow.kt / DropShadowPainter / InnerShadowPainter aren't
|
|
||||||
* vendored (blocked separately on GraphicsLayerModifier). Once they land,
|
|
||||||
* revisit — shadow rendering will be visually silent until a real blur is
|
|
||||||
* wired.
|
|
||||||
*
|
|
||||||
* The primary constructor takes zero args to avoid clashing with the
|
|
||||||
* top-level factory `fun BlurFilter(radius)` (Kotlin considers a class
|
|
||||||
* with a single-Float ctor and a top-level fn with a single-Float arg to
|
|
||||||
* have the same signature).
|
|
||||||
*/
|
|
||||||
internal actual class BlurFilter internal constructor() {
|
|
||||||
internal var radius: Float = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
internal actual fun BlurFilter(radius: Float): BlurFilter =
|
|
||||||
BlurFilter().apply { this.radius = radius }
|
|
||||||
|
|
||||||
internal actual fun Paint.setBlurFilter(blur: BlurFilter?) {
|
|
||||||
// no-op — SDL3 has no MaskFilter; Skia renderer would wire skiaPaint.maskFilter.
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package com.compose.sdl
|
|
||||||
|
|
||||||
import kotlin.experimental.ExperimentalNativeApi
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: SDL3 renderer per-OS default
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
actual fun rendererPreferredGpuMode(): GpuMode {
|
|
||||||
@OptIn(ExperimentalNativeApi::class)
|
|
||||||
return when (Platform.osFamily) {
|
|
||||||
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
|
|
||||||
OsFamily.LINUX -> GpuMode.Sdl3.Auto
|
|
||||||
OsFamily.WINDOWS -> GpuMode.Sdl3.Auto
|
|
||||||
else -> GpuMode.Sdl3.Auto
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.compose.sdl
|
|
||||||
|
|
||||||
import com.compose.sdl.renderer.sdl.Sdl3RenderBackend
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: createRenderBackend (SDL3 renderer module entry)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** Public factory the :window module calls (its per-target makeRenderBackend
|
|
||||||
actual delegates here when this module is the selected renderer). Both
|
|
||||||
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. */
|
|
||||||
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. " +
|
|
||||||
"Rerun without -Prenderer=sdl3 (on macOS / Linux) to use Skia, " +
|
|
||||||
"or pick a GpuMode.Sdl3.* / GpuMode.Software.")
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
Sdl3RenderBackend(inSdl)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
println("makeRenderBackend (sdl3) failed: ${t.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-195
@@ -1,195 +0,0 @@
|
|||||||
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 uncached fallback [NativeRenderNode] (CDN_LAYERCACHE=off): [record] stores
|
|
||||||
the draw block; [drawInto] REPLAYS it against the target canvas under the node's
|
|
||||||
transform / clip / shadow / alpha every frame. Always correct, never stale —
|
|
||||||
the reference the caching nodes (SdlDisplayListRenderNode / SdlRenderNode) are
|
|
||||||
verified against.
|
|
||||||
|
|
||||||
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).
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
// Fully transparent layer → skip drawing entirely (matches the caching nodes
|
|
||||||
// and upstream skiko's invisible-layer skip).
|
|
||||||
if (alpha <= 0.003f) 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)
|
|
||||||
|
|
||||||
// Alpha propagates as a per-op multiplier through the canvas's saveLayer
|
|
||||||
// (SDL has no offscreen layer compositing; see Sdl3Canvas.saveLayer).
|
|
||||||
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
@@ -1,11 +0,0 @@
|
|||||||
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
@@ -1,14 +0,0 @@
|
|||||||
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
@@ -1,105 +0,0 @@
|
|||||||
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
@@ -1,37 +0,0 @@
|
|||||||
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: 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
@@ -1,18 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
-503
@@ -1,503 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
|
||||||
import com.compose.sdl.icons.IconFont
|
|
||||||
import freetype.*
|
|
||||||
import kotlinx.cinterop.*
|
|
||||||
import sdl3.*
|
|
||||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
|
||||||
import com.compose.sdl.graphics.r8
|
|
||||||
import com.compose.sdl.graphics.g8
|
|
||||||
import com.compose.sdl.graphics.b8
|
|
||||||
import com.compose.sdl.graphics.a8
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: FreeTypeIcons
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
// Glyph texture cache cap — (icon × size × axis-set) entries; textures are
|
|
||||||
// small (icon-sized), the cap just bounds pathological churn.
|
|
||||||
private const val kGlyphCacheMax: Int = 512
|
|
||||||
|
|
||||||
/** Variable-font icon rasterisation for the SDL3 renderer. SDL3_ttf 3.2's
|
|
||||||
public API has no axis-set, so we go directly to FreeType for icon-font
|
|
||||||
families — FT_Set_Var_Design_Coordinates drives the OpenType FILL /
|
|
||||||
wght / GRAD / opsz axes per face.
|
|
||||||
|
|
||||||
Lifetime:
|
|
||||||
- One FT_Library for the whole renderer (init / destroy).
|
|
||||||
- One "base" FT_Face per family (used only for axis-metadata queries).
|
|
||||||
- One "variant" FT_Face per (family, normalised-variations-string),
|
|
||||||
pre-set to the requested design coordinates. Each variant carries the
|
|
||||||
last pixel size it was set to so consecutive same-size draws don't
|
|
||||||
re-call FT_Set_Pixel_Sizes.
|
|
||||||
- One SDL_Texture per (family, variations, codepoint, pixel size, tint
|
|
||||||
colour), cached on first render and re-blitted thereafter.
|
|
||||||
|
|
||||||
Each cached entry holds a copy of the font bytes on the native heap so
|
|
||||||
FT_New_Memory_Face's memory stays valid for the face's lifetime
|
|
||||||
(FT_Done_Face frees the face but does NOT free the buffer we provided —
|
|
||||||
that's our responsibility). */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
internal class FreeTypeIcons {
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Config
|
|
||||||
|
|
||||||
/** Supersample factor: glyphs are rendered at this multiple of the
|
|
||||||
requested physical pixel size, then downsampled with a kSS×kSS box
|
|
||||||
filter on the way into the SDL surface. 2 = 4 samples per output
|
|
||||||
pixel — enough to merge the inner-ring gaps that variable-font
|
|
||||||
interpolation produces at extreme axis combinations, without making
|
|
||||||
first-render cost prohibitive. */
|
|
||||||
private val kSupersampleFactor: Int = 2
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// State
|
|
||||||
|
|
||||||
private var fLibrary: FT_Library? = null
|
|
||||||
|
|
||||||
private class FaceBytes(val mem: CPointer<ByteVar>, val size: Int)
|
|
||||||
|
|
||||||
private class FamilyState(
|
|
||||||
val baseFace: CPointer<FT_FaceRec>,
|
|
||||||
val bytes: FaceBytes,
|
|
||||||
/** axis index keyed by 4-char OpenType tag (e.g. "wght" → 1). */
|
|
||||||
val axisIndexByTag: Map<String, Int>,
|
|
||||||
/** 16.16-fixed-point default coords for every axis, in font order. */
|
|
||||||
val defaultCoords: LongArray,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val fFamilies = mutableMapOf<String, FamilyState?>()
|
|
||||||
|
|
||||||
private class Variant(
|
|
||||||
val face: CPointer<FT_FaceRec>,
|
|
||||||
var pixelSize: Int = -1,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class VariantKey(val family: String, val variationsKey: String)
|
|
||||||
|
|
||||||
private val fVariants = mutableMapOf<VariantKey, Variant>()
|
|
||||||
|
|
||||||
// No tint in the key — glyphs are rasterised white (RGB=255, A=coverage)
|
|
||||||
// and tinted at blit time via SDL texture colour/alpha modulation, so a
|
|
||||||
// colour or alpha animation reuses one texture instead of caching one per
|
|
||||||
// colour step.
|
|
||||||
private data class GlyphKey(
|
|
||||||
val family: String,
|
|
||||||
val variationsKey: String,
|
|
||||||
val codepoint: Int,
|
|
||||||
val pixelSize: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
private class CachedGlyph(
|
|
||||||
val tex: COpaquePointer,
|
|
||||||
val w: Int,
|
|
||||||
val h: Int,
|
|
||||||
// pen offsets in pixels — top-left of the bitmap relative to the
|
|
||||||
// glyph's pen origin; FreeType returns these in face.glyph.bitmap_left
|
|
||||||
// (always positive offset to the right) and bitmap_top (positive
|
|
||||||
// offset upward from baseline).
|
|
||||||
val bearingLeft: Int,
|
|
||||||
val bearingTop: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
// LRU-capped (entries can be null = cached "no glyph" miss); eviction
|
|
||||||
// destroys the SDL texture so icon-heavy sessions can't grow VRAM forever.
|
|
||||||
private val fGlyphs = LruCache<GlyphKey, CachedGlyph?>(kGlyphCacheMax) { vGlyph ->
|
|
||||||
vGlyph?.tex?.let { SDL_DestroyTexture(it.reinterpret()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Init / destroy
|
|
||||||
|
|
||||||
fun init(): Boolean {
|
|
||||||
if (fLibrary != null) return true
|
|
||||||
memScoped {
|
|
||||||
val vLibPtr = alloc<FT_LibraryVar>()
|
|
||||||
val vErr = FT_Init_FreeType(vLibPtr.ptr)
|
|
||||||
if (vErr.toInt() != 0) {
|
|
||||||
println("FreeTypeIcons: FT_Init_FreeType failed ($vErr)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
fLibrary = vLibPtr.value
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() {
|
|
||||||
fGlyphs.clear() // onEvict destroys the SDL textures
|
|
||||||
|
|
||||||
for (vVariant in fVariants.values) {
|
|
||||||
FT_Done_Face(vVariant.face)
|
|
||||||
}
|
|
||||||
fVariants.clear()
|
|
||||||
|
|
||||||
for (vFam in fFamilies.values) {
|
|
||||||
if (vFam != null) {
|
|
||||||
FT_Done_Face(vFam.baseFace)
|
|
||||||
nativeHeap.free(vFam.bytes.mem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fFamilies.clear()
|
|
||||||
|
|
||||||
fLibrary?.let { FT_Done_FreeType(it) }
|
|
||||||
fLibrary = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Public API
|
|
||||||
|
|
||||||
/** Paints a single-codepoint icon at the given pen position with the
|
|
||||||
requested tint. Returns false (without drawing) if FreeType isn't
|
|
||||||
initialised, the family isn't registered with IconFont, or the
|
|
||||||
codepoint has no glyph in the font. Callers that get false can fall
|
|
||||||
back to the SDL3_ttf path. */
|
|
||||||
fun drawGlyph(
|
|
||||||
inSdlRenderer: COpaquePointer,
|
|
||||||
inFamily: String,
|
|
||||||
inCodepoint: Int,
|
|
||||||
inPixelSize: Int,
|
|
||||||
inColor: ComposeColor,
|
|
||||||
inVariations: List<FontVariation.Setting>,
|
|
||||||
inBoxX: Int,
|
|
||||||
inBoxY: Int,
|
|
||||||
inBoxW: Int,
|
|
||||||
inBoxH: Int,
|
|
||||||
inDpr: Float,
|
|
||||||
): Boolean {
|
|
||||||
if (fLibrary == null && !init()) return false
|
|
||||||
|
|
||||||
val vVariationsKey = variationsKey(inVariations)
|
|
||||||
// Rasterise at PHYSICAL pixels so the bitmap matches the back-buffer
|
|
||||||
// resolution (SDL_SetRenderScale stretches the dst rect by DPR). Keep
|
|
||||||
// the cache key on logical size + DPR so a 2x context and 1x context
|
|
||||||
// don't clobber each other.
|
|
||||||
val vPhys = (inPixelSize * inDpr).toInt().coerceAtLeast(1)
|
|
||||||
val vKey = GlyphKey(inFamily, vVariationsKey, inCodepoint, vPhys)
|
|
||||||
val vGlyph: CachedGlyph? = if (fGlyphs.containsKey(vKey)) {
|
|
||||||
fGlyphs[vKey]
|
|
||||||
} else {
|
|
||||||
val vNew = rasterise(inSdlRenderer, inFamily, inCodepoint, vPhys, inVariations, vVariationsKey)
|
|
||||||
fGlyphs[vKey] = vNew
|
|
||||||
vNew
|
|
||||||
}
|
|
||||||
if (vGlyph == null) return false
|
|
||||||
|
|
||||||
// Tint at blit time — white texture × colour mod ≡ the old colour bake.
|
|
||||||
SDL_SetTextureColorMod(vGlyph.tex.reinterpret(), inColor.r8.toUByte(), inColor.g8.toUByte(), inColor.b8.toUByte())
|
|
||||||
SDL_SetTextureAlphaMod(vGlyph.tex.reinterpret(), inColor.a8.toUByte())
|
|
||||||
|
|
||||||
// Glyph bitmap is in physical pixels; the SDL renderer scale brings
|
|
||||||
// it back to logical when we blit. Centre in the box, then SNAP the
|
|
||||||
// top-left to a whole device pixel — a fractional device offset makes
|
|
||||||
// SDL bilinear-sample the texture (the main cause of blurry / fringed
|
|
||||||
// icons). vLogW * dpr == vGlyph.w exactly, so the size already maps 1:1.
|
|
||||||
val vLogW = vGlyph.w / inDpr
|
|
||||||
val vLogH = vGlyph.h / inDpr
|
|
||||||
val vDstX = kotlin.math.round((inBoxX + (inBoxW - vLogW) / 2f) * inDpr) / inDpr
|
|
||||||
val vDstY = kotlin.math.round((inBoxY + (inBoxH - vLogH) / 2f) * inDpr) / inDpr
|
|
||||||
blit(inSdlRenderer, vGlyph, vDstX, vDstY, vLogW, vLogH)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasFamily(inFamily: String): Boolean = IconFont.isIconFamily(inFamily)
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Family / variant / glyph resolution
|
|
||||||
|
|
||||||
private fun resolveFamily(inFamily: String): FamilyState? {
|
|
||||||
fFamilies[inFamily]?.let { return it }
|
|
||||||
if (fFamilies.containsKey(inFamily)) return null // cached miss
|
|
||||||
|
|
||||||
val vBytes = IconFont.bytesFor(inFamily)
|
|
||||||
if (vBytes == null) {
|
|
||||||
fFamilies[inFamily] = null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val vLib = fLibrary
|
|
||||||
if (vLib == null) {
|
|
||||||
fFamilies[inFamily] = null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy bytes to native heap — FreeType requires the buffer to live
|
|
||||||
// for the face's lifetime.
|
|
||||||
val vMem = nativeHeap.allocArray<ByteVar>(vBytes.size)
|
|
||||||
vBytes.usePinned { vPinned ->
|
|
||||||
platform.posix.memcpy(vMem, vPinned.addressOf(0), vBytes.size.convert())
|
|
||||||
}
|
|
||||||
|
|
||||||
val vBaseFace: CPointer<FT_FaceRec> = memScoped {
|
|
||||||
val vSlot = alloc<FT_FaceVar>()
|
|
||||||
val vErr = FT_New_Memory_Face(vLib, vMem.reinterpret(), vBytes.size.convert(), 0, vSlot.ptr)
|
|
||||||
if (vErr.toInt() != 0 || vSlot.value == null) {
|
|
||||||
println("FreeTypeIcons: FT_New_Memory_Face failed for '$inFamily' ($vErr)")
|
|
||||||
nativeHeap.free(vMem)
|
|
||||||
fFamilies[inFamily] = null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
vSlot.value!!
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read variable-font axis metadata: tag → index map + defaults.
|
|
||||||
val vAxisIndex = mutableMapOf<String, Int>()
|
|
||||||
val vDefaults: LongArray = memScoped {
|
|
||||||
val vMmPtr = alloc<CPointerVar<FT_MM_Var>>()
|
|
||||||
val vErr = FT_Get_MM_Var(vBaseFace, vMmPtr.ptr)
|
|
||||||
if (vErr.toInt() != 0 || vMmPtr.value == null) {
|
|
||||||
println("FreeTypeIcons: '$inFamily' is not a variable font (FT_Get_MM_Var → $vErr)")
|
|
||||||
return@memScoped LongArray(0)
|
|
||||||
}
|
|
||||||
val vMm = vMmPtr.value!!.pointed
|
|
||||||
val vCount = vMm.num_axis.toInt()
|
|
||||||
val vAxes = vMm.axis!!
|
|
||||||
val vDefs = LongArray(vCount)
|
|
||||||
for (i in 0 until vCount) {
|
|
||||||
val vAxis = vAxes[i]
|
|
||||||
val vTagInt = vAxis.tag.toLong() and 0xFFFFFFFFL
|
|
||||||
val vTag = tagFromInt(vTagInt.toInt())
|
|
||||||
vAxisIndex[vTag] = i
|
|
||||||
vDefs[i] = vAxis.def.toLong()
|
|
||||||
}
|
|
||||||
FT_Done_MM_Var(vLib, vMmPtr.value)
|
|
||||||
vDefs
|
|
||||||
}
|
|
||||||
|
|
||||||
val vState = FamilyState(vBaseFace, FaceBytes(vMem, vBytes.size), vAxisIndex, vDefaults)
|
|
||||||
fFamilies[inFamily] = vState
|
|
||||||
return vState
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getOrCreateVariant(
|
|
||||||
inFamily: String,
|
|
||||||
inFamilyState: FamilyState,
|
|
||||||
inVariations: List<FontVariation.Setting>,
|
|
||||||
inVariationsKey: String,
|
|
||||||
): Variant? {
|
|
||||||
val vKey = VariantKey(inFamily, inVariationsKey)
|
|
||||||
fVariants[vKey]?.let { return it }
|
|
||||||
|
|
||||||
val vLib = fLibrary ?: return null
|
|
||||||
val vBytes = inFamilyState.bytes
|
|
||||||
|
|
||||||
// Open a fresh face from the same bytes; design coords mutate the
|
|
||||||
// face, so siblings can't share one.
|
|
||||||
val vFace: CPointer<FT_FaceRec> = memScoped {
|
|
||||||
val vSlot = alloc<FT_FaceVar>()
|
|
||||||
val vErr = FT_New_Memory_Face(vLib, vBytes.mem.reinterpret(), vBytes.size.convert(), 0, vSlot.ptr)
|
|
||||||
if (vErr.toInt() != 0 || vSlot.value == null) {
|
|
||||||
println("FreeTypeIcons: variant FT_New_Memory_Face failed ($vErr)")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
vSlot.value!!
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build full coords array: start from defaults, overlay requested.
|
|
||||||
if (inFamilyState.defaultCoords.isNotEmpty()) {
|
|
||||||
memScoped {
|
|
||||||
val vCount = inFamilyState.defaultCoords.size
|
|
||||||
val vCoords = allocArray<FT_FixedVar>(vCount)
|
|
||||||
for (i in 0 until vCount) vCoords[i] = inFamilyState.defaultCoords[i].convert()
|
|
||||||
for (vVar in inVariations) {
|
|
||||||
val vIdx = inFamilyState.axisIndexByTag[vVar.axisName] ?: continue
|
|
||||||
vCoords[vIdx] = (vVar.toVariationValue(null).toDouble() * 65536.0).toLong().convert()
|
|
||||||
}
|
|
||||||
FT_Set_Var_Design_Coordinates(vFace, vCount.convert(), vCoords)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val vVariant = Variant(vFace)
|
|
||||||
fVariants[vKey] = vVariant
|
|
||||||
return vVariant
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rasterise(
|
|
||||||
inSdlRenderer: COpaquePointer,
|
|
||||||
inFamily: String,
|
|
||||||
inCodepoint: Int,
|
|
||||||
inPixelSize: Int,
|
|
||||||
inVariations: List<FontVariation.Setting>,
|
|
||||||
inVariationsKey: String,
|
|
||||||
): CachedGlyph? {
|
|
||||||
val vFamily = resolveFamily(inFamily) ?: return null
|
|
||||||
val vVariant = getOrCreateVariant(inFamily, vFamily, inVariations, inVariationsKey) ?: return null
|
|
||||||
|
|
||||||
// Only the FILL axis interpolation produces the overlapping / near-
|
|
||||||
// touching contours that need supersampling + embolden to render
|
|
||||||
// cleanly. For the common case (no fill) render at the EXACT target
|
|
||||||
// size with FreeType's own anti-aliasing — far crisper at UI sizes
|
|
||||||
// than a 2× render boxed back down. Filled icons keep the 2× path.
|
|
||||||
val vFilled = inVariations.any { it.axisName == "FILL" && it.toVariationValue(null) > 0f }
|
|
||||||
val vSS = if (vFilled) kSupersampleFactor else 1
|
|
||||||
val vRenderPx = inPixelSize * vSS
|
|
||||||
if (vVariant.pixelSize != vRenderPx) {
|
|
||||||
FT_Set_Pixel_Sizes(vVariant.face, 0u, vRenderPx.convert())
|
|
||||||
vVariant.pixelSize = vRenderPx
|
|
||||||
}
|
|
||||||
|
|
||||||
val vGlyphIdx = FT_Get_Char_Index(vVariant.face, inCodepoint.convert())
|
|
||||||
if (vGlyphIdx.toInt() == 0) return null
|
|
||||||
// Load outline (no render yet); we need to flip on FT_OUTLINE_OVERLAP
|
|
||||||
// before rasterising. Material Symbols' FILL axis interpolates
|
|
||||||
// between an outlined and a filled design — at intermediate axis
|
|
||||||
// values (and combinations like fill=1 + wght=100) the resulting
|
|
||||||
// outline has overlapping subpaths, which the rasterizer renders as
|
|
||||||
// "holes" under the default nonzero winding unless we explicitly
|
|
||||||
// tell it to handle overlaps.
|
|
||||||
// Load without hinting — TT hinting quantises subpixel positions
|
|
||||||
// onto integer pixel grids, which interacts badly with the
|
|
||||||
// interpolated outlines variable fonts produce at non-master axis
|
|
||||||
// combinations (the rounding leaves thin gaps between contours that
|
|
||||||
// then show as visible "inner rings" after rasterisation).
|
|
||||||
val vLoadFlags = FT_LOAD_DEFAULT.toInt() or FT_LOAD_NO_HINTING.toInt()
|
|
||||||
val vErr = FT_Load_Glyph(vVariant.face, vGlyphIdx, vLoadFlags)
|
|
||||||
if (vErr.toInt() != 0) return null
|
|
||||||
|
|
||||||
val vSlotPtr = vVariant.face.pointed.glyph ?: return null
|
|
||||||
val vSlot = vSlotPtr.pointed
|
|
||||||
// OVERLAP + HIGH_PRECISION cover most cases; the supersample step
|
|
||||||
// in uploadBitmap handles the pixel-level anti-aliasing. What's
|
|
||||||
// left at extreme axis combinations (e.g. fill=1 + GRAD=200) is a
|
|
||||||
// real geometric gap between the outer outline and the inner fill
|
|
||||||
// contour — supersampling alone can't close it. We Embolden the
|
|
||||||
// outline by a sub-pixel amount so the two contours overlap
|
|
||||||
// instead of nearly-touching; the box filter then merges them
|
|
||||||
// into one solid mass.
|
|
||||||
vSlot.outline.flags = vSlot.outline.flags or
|
|
||||||
FT_OUTLINE_OVERLAP.toInt() or
|
|
||||||
FT_OUTLINE_HIGH_PRECISION.toInt()
|
|
||||||
// Embolden ONLY the filled path, to close the geometric gap between
|
|
||||||
// the outer outline and the inner fill contour. On plain outlined
|
|
||||||
// icons it just fattens strokes and merges fine detail (gear teeth,
|
|
||||||
// the trash-can bars), so skip it there. Strength is 26.6 fixed-point
|
|
||||||
// font units; at the 2× fill render that is two source pixels.
|
|
||||||
if (vFilled) {
|
|
||||||
FT_Outline_Embolden(vSlot.outline.ptr, (2L * 64L).convert())
|
|
||||||
}
|
|
||||||
|
|
||||||
val vRenderErr = FT_Render_Glyph(vSlotPtr, FT_RENDER_MODE_NORMAL)
|
|
||||||
if (vRenderErr.toInt() != 0) return null
|
|
||||||
|
|
||||||
val vBitmap = vSlot.bitmap
|
|
||||||
val vSrcW = vBitmap.width.toInt()
|
|
||||||
val vSrcH = vBitmap.rows.toInt()
|
|
||||||
if (vSrcW <= 0 || vSrcH <= 0) return null
|
|
||||||
|
|
||||||
val vTex = uploadBitmap(inSdlRenderer, vBitmap, vSS) ?: return null
|
|
||||||
// CachedGlyph dimensions are POST-downsample (texture size), so the
|
|
||||||
// later blit math gives the right logical size.
|
|
||||||
return CachedGlyph(
|
|
||||||
tex = vTex,
|
|
||||||
w = (vSrcW + vSS - 1) / vSS,
|
|
||||||
h = (vSrcH + vSS - 1) / vSS,
|
|
||||||
bearingLeft = vSlot.bitmap_left / vSS,
|
|
||||||
bearingTop = vSlot.bitmap_top / vSS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Bitmap → tinted ARGB texture
|
|
||||||
|
|
||||||
/** FreeType's normal-mode bitmap is 8-bit grayscale (FT_PIXEL_MODE_GRAY,
|
|
||||||
coverage 0..255) at kSupersampleFactor× the target size. We
|
|
||||||
downsample with a kSupersampleFactor² box filter on the way into an
|
|
||||||
ARGB8888 SDL_Surface where RGB = white (tint applied per-blit via
|
|
||||||
texture colour mod), A = downsampled coverage. Pitch can be negative
|
|
||||||
(top-down) — we walk source rows by sign. The box-filter step is what
|
|
||||||
fuses the thin inner-ring gaps produced by FreeType's interpolation
|
|
||||||
of overlapping subpaths. */
|
|
||||||
private fun uploadBitmap(
|
|
||||||
inSdlRenderer: COpaquePointer,
|
|
||||||
inBitmap: FT_Bitmap,
|
|
||||||
inSS: Int,
|
|
||||||
): COpaquePointer? {
|
|
||||||
val vSrcW = inBitmap.width.toInt()
|
|
||||||
val vSrcH = inBitmap.rows.toInt()
|
|
||||||
val vPitch = inBitmap.pitch
|
|
||||||
val vBuf = inBitmap.buffer ?: return null
|
|
||||||
val vSS = inSS
|
|
||||||
|
|
||||||
// Output is the downsampled size — round up so a 49×49 source at 2×
|
|
||||||
// gives a 25×25 dst (last row/col samples partial cells).
|
|
||||||
val vDstW = (vSrcW + vSS - 1) / vSS
|
|
||||||
val vDstH = (vSrcH + vSS - 1) / vSS
|
|
||||||
|
|
||||||
val vSurface = SDL_CreateSurface(vDstW, vDstH, SDL_PIXELFORMAT_ARGB8888) ?: return null
|
|
||||||
val vDst = vSurface.pointed.pixels?.reinterpret<UByteVar>() ?: run {
|
|
||||||
SDL_DestroySurface(vSurface)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val vDstPitch = vSurface.pointed.pitch
|
|
||||||
val vWhite: UByte = 255u
|
|
||||||
|
|
||||||
val vNorm = vSS * vSS
|
|
||||||
for (vDy in 0 until vDstH) {
|
|
||||||
val vDstRowBase = vDy * vDstPitch
|
|
||||||
val vSyStart = vDy * vSS
|
|
||||||
for (vDx in 0 until vDstW) {
|
|
||||||
val vSxStart = vDx * vSS
|
|
||||||
// Sum coverage over the kSupersampleFactor×kSupersampleFactor
|
|
||||||
// source cell; cells that fall off the edge contribute 0.
|
|
||||||
var vSum = 0
|
|
||||||
for (vSy in 0 until vSS) {
|
|
||||||
val vY = vSyStart + vSy
|
|
||||||
if (vY >= vSrcH) break
|
|
||||||
val vSrcRow = if (vPitch >= 0) vY * vPitch else (vSrcH - 1 - vY) * (-vPitch)
|
|
||||||
for (vSx in 0 until vSS) {
|
|
||||||
val vX = vSxStart + vSx
|
|
||||||
if (vX >= vSrcW) break
|
|
||||||
vSum += vBuf[vSrcRow + vX].toInt() and 0xFF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val vCov = (vSum / vNorm).coerceIn(0, 255)
|
|
||||||
val vBase = vDstRowBase + vDx * 4
|
|
||||||
// ARGB8888 in little-endian memory is laid out [B, G, R, A].
|
|
||||||
vDst[vBase + 0] = vWhite
|
|
||||||
vDst[vBase + 1] = vWhite
|
|
||||||
vDst[vBase + 2] = vWhite
|
|
||||||
vDst[vBase + 3] = vCov.toUByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val vTexture = SDL_CreateTextureFromSurface(inSdlRenderer.reinterpret(), vSurface)
|
|
||||||
SDL_DestroySurface(vSurface)
|
|
||||||
return vTexture
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun blit(
|
|
||||||
inSdlRenderer: COpaquePointer,
|
|
||||||
inGlyph: CachedGlyph,
|
|
||||||
inDstX: Float,
|
|
||||||
inDstY: Float,
|
|
||||||
inDstW: Float,
|
|
||||||
inDstH: Float,
|
|
||||||
) {
|
|
||||||
memScoped {
|
|
||||||
val vDst = alloc<SDL_FRect>()
|
|
||||||
vDst.x = inDstX
|
|
||||||
vDst.y = inDstY
|
|
||||||
vDst.w = inDstW
|
|
||||||
vDst.h = inDstH
|
|
||||||
SDL_RenderTexture(inSdlRenderer.reinterpret(), inGlyph.tex.reinterpret(), null, vDst.ptr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Helpers
|
|
||||||
|
|
||||||
private fun variationsKey(inV: List<FontVariation.Setting>): String {
|
|
||||||
if (inV.isEmpty()) return ""
|
|
||||||
return inV.sortedBy { it.axisName }.joinToString(",") { "${it.axisName}=${it.toVariationValue(null)}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unpacks a packed 32-bit OpenType tag into its 4-char ASCII string. */
|
|
||||||
private fun tagFromInt(inTag: Int): String {
|
|
||||||
val vChars = CharArray(4)
|
|
||||||
vChars[0] = ((inTag shr 24) and 0xFF).toChar()
|
|
||||||
vChars[1] = ((inTag shr 16) and 0xFF).toChar()
|
|
||||||
vChars[2] = ((inTag shr 8) and 0xFF).toChar()
|
|
||||||
vChars[3] = (inTag and 0xFF).toChar()
|
|
||||||
return vChars.concatToString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: LruCache
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** Minimal insertion-order LRU for renderer-side caches (glyph textures, icon
|
|
||||||
bitmaps). Backed by a LinkedHashMap; a hit re-inserts the entry so eviction
|
|
||||||
always removes the least-recently-used one. onEvict runs for every value
|
|
||||||
dropped (capacity eviction, overwrite, clear) — used to SDL_DestroyTexture
|
|
||||||
GPU-side resources so a long session can't grow VRAM without bound. */
|
|
||||||
internal class LruCache<K, V>(
|
|
||||||
private val fMaxSize: Int,
|
|
||||||
private val fOnEvict: (V) -> Unit = {},
|
|
||||||
) {
|
|
||||||
private val fMap = LinkedHashMap<K, V>()
|
|
||||||
|
|
||||||
val size: Int get() = fMap.size
|
|
||||||
val values: Collection<V> get() = fMap.values
|
|
||||||
|
|
||||||
operator fun get(inKey: K): V? {
|
|
||||||
val vValue = fMap.remove(inKey) ?: return null
|
|
||||||
fMap[inKey] = vValue
|
|
||||||
return vValue
|
|
||||||
}
|
|
||||||
|
|
||||||
fun containsKey(inKey: K): Boolean = fMap.containsKey(inKey)
|
|
||||||
|
|
||||||
operator fun set(inKey: K, inValue: V) {
|
|
||||||
val vOld = fMap.remove(inKey)
|
|
||||||
if (vOld != null && vOld !== inValue) fOnEvict(vOld)
|
|
||||||
fMap[inKey] = inValue
|
|
||||||
while (fMap.size > fMaxSize) {
|
|
||||||
val vEldest = fMap.keys.first()
|
|
||||||
fMap.remove(vEldest)?.let(fOnEvict)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
for (vValue in fMap.values) fOnEvict(vValue)
|
|
||||||
fMap.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
-66
@@ -1,66 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import kotlinx.cinterop.*
|
|
||||||
import sdl3.*
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Sdl3ClipTargets
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** Persistent pool of offscreen render-target textures used by Sdl3Canvas to
|
|
||||||
implement rounded-shape clipping (SDL has only a rectangular render clip, so
|
|
||||||
a rounded / circular clip is done by drawing the clipped subtree into an
|
|
||||||
offscreen target, zeroing the corners outside the rounded outline, then
|
|
||||||
compositing that target back).
|
|
||||||
|
|
||||||
Sdl3Canvas is recreated every frame, but GPU render targets are expensive to
|
|
||||||
allocate, so this pool lives on Sdl3RenderBackend and is reused frame to
|
|
||||||
frame. One target per clip-nesting depth (grown on demand); every target is
|
|
||||||
sized to the full render output and recreated when the output size changes.
|
|
||||||
|
|
||||||
Targets carry PREMULTIPLIED-alpha blend for the composite step. Content is
|
|
||||||
drawn into a target with ordinary BLEND over a transparent (zeroed) surface,
|
|
||||||
which leaves the target holding premultiplied colours (RGB already scaled by
|
|
||||||
coverage, coverage in alpha). Compositing that back with plain BLEND would
|
|
||||||
multiply by alpha a SECOND time — thinning/fading text and other partial-alpha
|
|
||||||
content; BLEND_PREMULTIPLIED (dstRGBA = srcRGBA + dst*(1-srcA)) composites it
|
|
||||||
correctly. */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
internal class Sdl3ClipTargets(private val fRenderer: COpaquePointer) {
|
|
||||||
|
|
||||||
private var fWidth = 0
|
|
||||||
private var fHeight = 0
|
|
||||||
private val fTargets = mutableListOf<COpaquePointer>()
|
|
||||||
|
|
||||||
/** Returns a render-target texture for clip nesting [inDepth], sized to
|
|
||||||
(inWidth, inHeight). Recreates the whole pool if the output size changed.
|
|
||||||
Returns null if the texture could not be created (caller falls back to a
|
|
||||||
plain rectangular clip). */
|
|
||||||
fun target(inDepth: Int, inWidth: Int, inHeight: Int): COpaquePointer? {
|
|
||||||
if (inWidth <= 0 || inHeight <= 0) return null
|
|
||||||
if (inWidth != fWidth || inHeight != fHeight) {
|
|
||||||
releaseAll()
|
|
||||||
fWidth = inWidth
|
|
||||||
fHeight = inHeight
|
|
||||||
}
|
|
||||||
while (fTargets.size <= inDepth) {
|
|
||||||
val vTex = SDL_CreateTexture(
|
|
||||||
fRenderer.reinterpret(),
|
|
||||||
SDL_PIXELFORMAT_RGBA32,
|
|
||||||
SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET,
|
|
||||||
fWidth,
|
|
||||||
fHeight,
|
|
||||||
) ?: return null
|
|
||||||
SDL_SetTextureBlendMode(vTex.reinterpret(), SDL_BLENDMODE_BLEND_PREMULTIPLIED)
|
|
||||||
fTargets.add(vTex)
|
|
||||||
}
|
|
||||||
return fTargets[inDepth]
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseAll() {
|
|
||||||
for (vTex in fTargets) SDL_DestroyTexture(vTex.reinterpret())
|
|
||||||
fTargets.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() = releaseAll()
|
|
||||||
}
|
|
||||||
-1547
File diff suppressed because it is too large
Load Diff
-177
@@ -1,177 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import com.compose.sdl.*
|
|
||||||
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import com.compose.sdl.res.AndroidVectorToSvg
|
|
||||||
import com.compose.sdl.res.ResourceKind
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import kotlinx.cinterop.*
|
|
||||||
import sdl3.*
|
|
||||||
import sdl3_image.IMG_Load_IO
|
|
||||||
import sdl3_image.IMG_LoadSVG_IO
|
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Sdl3ImageCache
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** Decodes bundled image resources to SDL_Textures via SDL3_image and caches
|
|
||||||
them by relative path. Backs both the layout pass (intrinsicSize) and the
|
|
||||||
renderer's draw — a resource is decoded exactly once.
|
|
||||||
|
|
||||||
Raster (png/jpg/…) and .svg are loaded straight from the bundled file path
|
|
||||||
with IMG_Load (auto-detecting the format). Android <vector> XML is read,
|
|
||||||
converted to an SVG string, and rasterised from memory with IMG_LoadSVG_IO.
|
|
||||||
Raw resources aren't drawable (use Res.readBytes). */
|
|
||||||
internal class Sdl3ImageCache(private val backend: SDL3Backend) {
|
|
||||||
|
|
||||||
private class Cached(val tex: COpaquePointer, val w: Int, val h: Int)
|
|
||||||
|
|
||||||
// Value is null when a decode failed — cached so we don't retry every frame.
|
|
||||||
private val fCache = HashMap<String, Cached?>()
|
|
||||||
|
|
||||||
fun intrinsicSize(inPath: String, inKind: ResourceKind): androidx.compose.ui.geometry.Size {
|
|
||||||
val vCached = get(inPath, inKind) ?: return androidx.compose.ui.geometry.Size.Unspecified
|
|
||||||
return androidx.compose.ui.geometry.Size(vCached.w.toFloat(), vCached.h.toFloat())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun get(inPath: String, inKind: ResourceKind): Cached? {
|
|
||||||
if (fCache.containsKey(inPath)) return fCache[inPath]
|
|
||||||
val vResult = decodeToTexture(inPath, inKind)
|
|
||||||
fCache[inPath] = vResult
|
|
||||||
if (vResult == null) println("Sdl3ImageCache: failed to decode $inPath: ${SDL_GetError()?.toKString()}")
|
|
||||||
return vResult
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Decode
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
private fun decodeToTexture(inPath: String, inKind: ResourceKind): Cached? {
|
|
||||||
val vRenderer = backend.renderer?.reinterpret<cnames.structs.SDL_Renderer>() ?: return null
|
|
||||||
|
|
||||||
val vSurface = when (inKind) {
|
|
||||||
ResourceKind.Raster, ResourceKind.Svg -> {
|
|
||||||
val vBytes = loadComposeResourceBytes(inPath) ?: return null
|
|
||||||
loadEncodedSurface(vBytes)
|
|
||||||
}
|
|
||||||
ResourceKind.AndroidVector -> {
|
|
||||||
val vXml = loadComposeResourceBytes(inPath)?.decodeToString() ?: return null
|
|
||||||
loadSvgSurface(AndroidVectorToSvg.convert(vXml))
|
|
||||||
}
|
|
||||||
ResourceKind.Raw -> return null
|
|
||||||
} ?: return null
|
|
||||||
|
|
||||||
// sdl3 and sdl3_image both declare SDL_Surface — same C struct, distinct
|
|
||||||
// Kotlin types — so bridge with reinterpret (as Sdl3TextRenderer does).
|
|
||||||
val vSdlSurface = vSurface.reinterpret<sdl3.SDL_Surface>()
|
|
||||||
val vW = vSdlSurface.pointed.w
|
|
||||||
val vH = vSdlSurface.pointed.h
|
|
||||||
val vTex = SDL_CreateTextureFromSurface(vRenderer, vSdlSurface)
|
|
||||||
SDL_DestroySurface(vSdlSurface)
|
|
||||||
if (vTex == null) return null
|
|
||||||
return Cached(vTex, vW, vH)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decodes any IMG_-supported raster or SVG container from raw bytes
|
|
||||||
(png/jpg/bmp/gif/webp/svg). IMG_Load_IO peeks the bytes to auto-detect
|
|
||||||
the format; closeio = true so SDL closes its IOStream wrapper. The
|
|
||||||
underlying mem only needs to outlive the call (the surface owns its
|
|
||||||
pixels). */
|
|
||||||
private fun loadEncodedSurface(inBytes: ByteArray): CPointer<sdl3.SDL_Surface>? {
|
|
||||||
if (inBytes.isEmpty()) return null
|
|
||||||
return inBytes.usePinned { vPinned ->
|
|
||||||
val vIo = SDL_IOFromConstMem(vPinned.addressOf(0), inBytes.size.convert())
|
|
||||||
?: return@usePinned null
|
|
||||||
IMG_Load_IO(vIo.reinterpret(), true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rasterises an SVG string from memory. */
|
|
||||||
private fun loadSvgSurface(inSvg: String): CPointer<sdl3.SDL_Surface>? {
|
|
||||||
val vBytes = inSvg.encodeToByteArray()
|
|
||||||
if (vBytes.isEmpty()) return null
|
|
||||||
return vBytes.usePinned { vPinned ->
|
|
||||||
val vIo = SDL_IOFromConstMem(vPinned.addressOf(0), vBytes.size.convert()) ?: return@usePinned null
|
|
||||||
val vSurf = IMG_LoadSVG_IO(vIo.reinterpret())
|
|
||||||
SDL_CloseIO(vIo)
|
|
||||||
vSurf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Draw
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** Paints the resource into (inX, inY, inW, inH) logical points applying
|
|
||||||
contentScale + alpha. No-op if the resource isn't decodable. */
|
|
||||||
fun draw(
|
|
||||||
inPath: String,
|
|
||||||
inKind: ResourceKind,
|
|
||||||
inX: Float,
|
|
||||||
inY: Float,
|
|
||||||
inW: Float,
|
|
||||||
inH: Float,
|
|
||||||
inScale: ContentScale,
|
|
||||||
inAlpha: Float,
|
|
||||||
) {
|
|
||||||
if (inW <= 0f || inH <= 0f) return
|
|
||||||
val vCached = get(inPath, inKind) ?: return
|
|
||||||
val vRenderer = backend.renderer?.reinterpret<cnames.structs.SDL_Renderer>() ?: return
|
|
||||||
val vIw = vCached.w.toFloat()
|
|
||||||
val vIh = vCached.h.toFloat()
|
|
||||||
if (vIw <= 0f || vIh <= 0f) return
|
|
||||||
|
|
||||||
// SDL_Texture is a public struct in SDL3; let .reinterpret() infer its
|
|
||||||
// type from each SDL call's parameter (as Sdl3TextRenderer does).
|
|
||||||
SDL_SetTextureAlphaMod(vCached.tex.reinterpret(), (inAlpha * 255f).toInt().coerceIn(0, 255).toUByte())
|
|
||||||
|
|
||||||
memScoped {
|
|
||||||
val vDst = alloc<SDL_FRect>()
|
|
||||||
when (inScale) {
|
|
||||||
ContentScale.FillBounds -> {
|
|
||||||
vDst.x = inX; vDst.y = inY; vDst.w = inW; vDst.h = inH
|
|
||||||
SDL_RenderTexture(vRenderer, vCached.tex.reinterpret(), null, vDst.ptr)
|
|
||||||
}
|
|
||||||
ContentScale.Crop -> {
|
|
||||||
// Scale so the image fills the box, then show only the
|
|
||||||
// centred sub-rectangle of the source that maps to it.
|
|
||||||
val vScale = max(inW / vIw, inH / vIh)
|
|
||||||
val vSrcW = inW / vScale
|
|
||||||
val vSrcH = inH / vScale
|
|
||||||
val vSrc = alloc<SDL_FRect>()
|
|
||||||
vSrc.x = (vIw - vSrcW) / 2f
|
|
||||||
vSrc.y = (vIh - vSrcH) / 2f
|
|
||||||
vSrc.w = vSrcW
|
|
||||||
vSrc.h = vSrcH
|
|
||||||
vDst.x = inX; vDst.y = inY; vDst.w = inW; vDst.h = inH
|
|
||||||
SDL_RenderTexture(vRenderer, vCached.tex.reinterpret(), vSrc.ptr, vDst.ptr)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// Fit / Inside / None: uniform scale, centred, full source.
|
|
||||||
val vScale = when (inScale) {
|
|
||||||
ContentScale.Fit -> min(inW / vIw, inH / vIh)
|
|
||||||
ContentScale.Inside -> min(1f, min(inW / vIw, inH / vIh))
|
|
||||||
else -> 1f // None
|
|
||||||
}
|
|
||||||
val vDw = vIw * vScale
|
|
||||||
val vDh = vIh * vScale
|
|
||||||
vDst.x = inX + (inW - vDw) / 2f
|
|
||||||
vDst.y = inY + (inH - vDh) / 2f
|
|
||||||
vDst.w = vDw
|
|
||||||
vDst.h = vDh
|
|
||||||
SDL_RenderTexture(vRenderer, vCached.tex.reinterpret(), null, vDst.ptr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() {
|
|
||||||
for (vCached in fCache.values) {
|
|
||||||
if (vCached != null) SDL_DestroyTexture(vCached.tex.reinterpret())
|
|
||||||
}
|
|
||||||
fCache.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-214
@@ -1,214 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Canvas
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmapConfig
|
|
||||||
import androidx.compose.ui.graphics.colorspace.ColorSpace
|
|
||||||
import com.compose.sdl.graphics.OffscreenRenderer
|
|
||||||
import kotlinx.cinterop.ExperimentalForeignApi
|
|
||||||
import kotlinx.cinterop.COpaquePointer
|
|
||||||
import kotlinx.cinterop.CPointer
|
|
||||||
import kotlinx.cinterop.addressOf
|
|
||||||
import kotlinx.cinterop.convert
|
|
||||||
import kotlinx.cinterop.pointed
|
|
||||||
import kotlinx.cinterop.usePinned
|
|
||||||
import kotlinx.cinterop.reinterpret
|
|
||||||
import sdl3.*
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: SDL offscreen bitmap support
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
// The main-frame Sdl3Canvas currently drawing. An offscreen ImageBitmap render
|
|
||||||
// (a vector icon) flushes it before switching the render target, so the icon
|
|
||||||
// composites on top of everything drawn so far (z-order).
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
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(
|
|
||||||
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 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).
|
|
||||||
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,
|
|
||||||
startX: Int,
|
|
||||||
startY: Int,
|
|
||||||
width: Int,
|
|
||||||
height: Int,
|
|
||||||
bufferOffset: Int,
|
|
||||||
stride: Int,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun prepareToDraw() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registered on Sdl3RenderBackend; hands the vendored DrawCache a real offscreen.
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
internal class Sdl3OffscreenRenderer(
|
|
||||||
private val fRenderer: COpaquePointer,
|
|
||||||
private val fTextRenderer: Sdl3TextRenderer?,
|
|
||||||
// Passed through to offscreen canvases so content drawn into an ImageBitmap
|
|
||||||
// (VectorPainter / DrawCache, and the shape-clip texture cache in
|
|
||||||
// SdlDisplayListRenderNode) can realize drop shadows the same way the frame
|
|
||||||
// canvas does (a pure texture LRU — safe to share). Rounded-clip masks are NOT
|
|
||||||
// shared: each offscreen canvas builds its own bitmap-sized pool on demand —
|
|
||||||
// the frame pool destroys ALL its targets on any size change, so a mid-frame
|
|
||||||
// bake requesting bitmap-sized targets would kill frame-sized targets the main
|
|
||||||
// canvas is actively drawing into (whole subtrees vanished — the missing
|
|
||||||
// apidemo header/menu bug). See Sdl3Canvas.clipTargets().
|
|
||||||
private val fShadowCache: Sdl3ShadowCache? = null,
|
|
||||||
) : OffscreenRenderer {
|
|
||||||
|
|
||||||
override fun createImageBitmap(
|
|
||||||
width: Int,
|
|
||||||
height: Int,
|
|
||||||
config: ImageBitmapConfig,
|
|
||||||
hasAlpha: Boolean,
|
|
||||||
colorSpace: ColorSpace,
|
|
||||||
): ImageBitmap = SdlImageBitmap(fRenderer, width, height, config, hasAlpha, colorSpace)
|
|
||||||
|
|
||||||
override fun createCanvas(image: ImageBitmap): Canvas? {
|
|
||||||
val vBmp = image as? SdlImageBitmap ?: return null
|
|
||||||
val vTex = vBmp.texture ?: return null
|
|
||||||
return Sdl3Canvas(
|
|
||||||
fRenderer,
|
|
||||||
Size(vBmp.width.toFloat(), vBmp.height.toFloat()),
|
|
||||||
fTextRenderer,
|
|
||||||
fShadowCache = fShadowCache,
|
|
||||||
fOffscreenTexture = vTex,
|
|
||||||
// A single-colour icon is rasterised as an Alpha8 mask: draw it WHITE so
|
|
||||||
// the tint applied on blit (SDL_SetTextureColorMod, a multiply) yields the
|
|
||||||
// tint colour where covered instead of multiplying a black icon to black.
|
|
||||||
fForceWhite = vBmp.config == ImageBitmapConfig.Alpha8,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Sdl3EncodedImageDecoder — encoded bytes → drawable ImageBitmap
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** Registered on the com.compose.sdl.graphics decode hook; backs
|
|
||||||
:components-resources' painterResource / SVG path on the SDL renderer.
|
|
||||||
IMG_Load_IO auto-detects the container (png/jpg/bmp/gif/webp/svg — SVG is
|
|
||||||
rasterised at its intrinsic size). Decoded surfaces carry STRAIGHT alpha,
|
|
||||||
so the texture gets the ordinary BLEND mode (unlike the premultiplied
|
|
||||||
render-target path above). */
|
|
||||||
@OptIn(ExperimentalForeignApi::class)
|
|
||||||
internal class Sdl3EncodedImageDecoder(
|
|
||||||
private val fRenderer: COpaquePointer,
|
|
||||||
) : com.compose.sdl.graphics.EncodedImageDecoder {
|
|
||||||
|
|
||||||
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>()
|
|
||||||
return SdlImageBitmap(
|
|
||||||
fRenderer, vSdlSurface.pointed.w, vSdlSurface.pointed.h,
|
|
||||||
config = ImageBitmapConfig.Argb8888,
|
|
||||||
hasAlpha = true,
|
|
||||||
colorSpace = androidx.compose.ui.graphics.colorspace.ColorSpaces.Srgb,
|
|
||||||
fDecodedSurface = vSdlSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-162
@@ -1,162 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import com.compose.sdl.res.ImageLoader
|
|
||||||
import com.compose.sdl.res.ResourceKind
|
|
||||||
import com.compose.sdl.text.TextMeasurer
|
|
||||||
import com.compose.sdl.*
|
|
||||||
import kotlinx.cinterop.*
|
|
||||||
import sdl3.*
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Sdl3RenderBackend
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** RenderBackend that uses only SDL3 primitives + SDL3_ttf — no Skia,
|
|
||||||
no Skiko. The only renderer on mingwX64. Constructed by the
|
|
||||||
makeRenderBackend actual in the core package.
|
|
||||||
|
|
||||||
Per-frame flow:
|
|
||||||
beginFrame → SDL_SetRenderScale to apply the DPR
|
|
||||||
drawRoot → drive the upstream LayoutNode tree through Sdl3Canvas
|
|
||||||
endFrame → SDL_RenderPresent
|
|
||||||
*/
|
|
||||||
internal class Sdl3RenderBackend(private val backend: SDL3Backend) : RenderBackend {
|
|
||||||
|
|
||||||
private val fTextRenderer = Sdl3TextRenderer(backend)
|
|
||||||
private val fImageCache = Sdl3ImageCache(backend)
|
|
||||||
// Persistent offscreen render targets for rounded-shape clipping. Created
|
|
||||||
// lazily on the first drawRoot (the renderer must exist first) and reused
|
|
||||||
// across frames — allocated here, not per-frame Sdl3Canvas.
|
|
||||||
private var fClipTargets: Sdl3ClipTargets? = null
|
|
||||||
private var fShadowCache: Sdl3ShadowCache? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!fTextRenderer.init()) {
|
|
||||||
error("Sdl3RenderBackend: SDL3_ttf failed to init")
|
|
||||||
}
|
|
||||||
// Encoded-image decode (painterResource / SVG in :components-resources).
|
|
||||||
// Registered at CONSTRUCTION, not first frame: the official resources
|
|
||||||
// pipeline decodes during COMPOSITION, which runs before beginFrame.
|
|
||||||
backend.renderer?.let { vRenderer ->
|
|
||||||
if (com.compose.sdl.graphics.encodedImageDecoder == null) {
|
|
||||||
com.compose.sdl.graphics.encodedImageDecoder = Sdl3EncodedImageDecoder(vRenderer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override val textMeasurer: TextMeasurer
|
|
||||||
get() = fTextRenderer.textMeasurer
|
|
||||||
|
|
||||||
override val imageLoader: ImageLoader = object : ImageLoader {
|
|
||||||
override fun intrinsicSize(inPath: String, inKind: ResourceKind): Size =
|
|
||||||
fImageCache.intrinsicSize(inPath, inKind)
|
|
||||||
override fun readBytes(inPath: String): ByteArray? =
|
|
||||||
loadComposeResourceBytes(inPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun ensureSize(inPixelWidth: Int, inPixelHeight: Int): Boolean {
|
|
||||||
// SDL_Renderer auto-resizes with the window — nothing to do.
|
|
||||||
return inPixelWidth > 0 && inPixelHeight > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun beginFrame(inDpr: Float) {
|
|
||||||
val r = backend.renderer?.reinterpret<cnames.structs.SDL_Renderer>() ?: return
|
|
||||||
// Clear the frame to Material's dark background (0x121212). Without this
|
|
||||||
// the SDL back buffer holds whatever was previously in GPU memory —
|
|
||||||
// uncovered regions of the composition (e.g. apidemo's transparent
|
|
||||||
// panels) show garbage / pink on macOS Metal.
|
|
||||||
//
|
|
||||||
// Order matters: clear at physical pixel scale (scale=1) so the clear
|
|
||||||
// covers the FULL back buffer, THEN apply DPR scale for subsequent draws.
|
|
||||||
// Otherwise on retina we clear only 1/DPR of the target and the rest
|
|
||||||
// shows garbage. Also reset any stray clip left by the previous frame.
|
|
||||||
SDL_SetRenderScale(r, 1f, 1f)
|
|
||||||
SDL_SetRenderClipRect(r, null)
|
|
||||||
SDL_SetRenderDrawColor(r, 0x12u, 0x12u, 0x12u, 0xFFu)
|
|
||||||
SDL_RenderClear(r)
|
|
||||||
// SDL_SetRenderScale stretches every render coord by (dpr, dpr) so
|
|
||||||
// logical-point geometry lands at the right physical pixel. Text
|
|
||||||
// textures are rasterised at the DPR-scaled font size and drawn
|
|
||||||
// at logical-size dst rects — the stretch then maps them 1:1 to
|
|
||||||
// physical pixels instead of upscaling a half-resolution glyph.
|
|
||||||
SDL_SetRenderScale(r, inDpr, inDpr)
|
|
||||||
fTextRenderer.setDpr(inDpr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paint the upstream LayoutNode tree through the vendored pipeline.
|
|
||||||
// inHost.rootNode.draw → NodeCoordinator.draw → DrawModifierNode → CanvasDrawScope
|
|
||||||
// → Sdl3Canvas → SDL_RenderGeometry.
|
|
||||||
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 }
|
|
||||||
// Register the offscreen (ImageBitmap) render path so the vendored VectorPainter /
|
|
||||||
// DrawCache pipeline — hence material3's ImageVector icons — renders. Idempotent.
|
|
||||||
if (com.compose.sdl.graphics.offscreenRenderer == null) {
|
|
||||||
com.compose.sdl.graphics.offscreenRenderer =
|
|
||||||
Sdl3OffscreenRenderer(vRenderer, fTextRenderer, vShadowCache)
|
|
||||||
}
|
|
||||||
val vCanvas = Sdl3Canvas(
|
|
||||||
vRenderer,
|
|
||||||
androidx.compose.ui.geometry.Size(backend.pixelWidth.toFloat(), backend.pixelHeight.toFloat()),
|
|
||||||
fTextRenderer,
|
|
||||||
fImageCache,
|
|
||||||
vClipTargets,
|
|
||||||
vShadowCache,
|
|
||||||
)
|
|
||||||
// Expose the frame canvas so an offscreen render can flush it before borrowing
|
|
||||||
// the render target (z-order).
|
|
||||||
currentMainCanvas = vCanvas
|
|
||||||
inDraw(vCanvas)
|
|
||||||
vCanvas.finish()
|
|
||||||
currentMainCanvas = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun endFrame() {
|
|
||||||
val r = backend.renderer?.reinterpret<cnames.structs.SDL_Renderer>() ?: return
|
|
||||||
SDL_RenderPresent(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read the renderer output into a host BGRA byte array. The renderer's
|
|
||||||
native pixel format is platform-dependent (Metal on macOS uses BGRA,
|
|
||||||
OpenGL on Linux uses RGBA, etc.), so we explicitly convert the
|
|
||||||
returned surface to BGRA32 to share one byte order with the BMP
|
|
||||||
writer / the Skia bridge. */
|
|
||||||
override fun snapshotBgra(): Triple<Int, Int, ByteArray>? {
|
|
||||||
val r = backend.renderer?.reinterpret<cnames.structs.SDL_Renderer>() ?: return null
|
|
||||||
val vRaw = SDL_RenderReadPixels(r, null) ?: return null
|
|
||||||
// SDL_PIXELFORMAT_BGRA32 = 376840196 in SDL3 (kept literal to avoid a
|
|
||||||
// brittle enum lookup that drifts across cinterop versions).
|
|
||||||
val vConverted = SDL_ConvertSurface(vRaw.reinterpret(), SDL_PIXELFORMAT_BGRA32) ?: run {
|
|
||||||
SDL_DestroySurface(vRaw.reinterpret())
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
SDL_DestroySurface(vRaw.reinterpret())
|
|
||||||
try {
|
|
||||||
val s = vConverted.reinterpret<SDL_Surface>().pointed
|
|
||||||
val w = s.w
|
|
||||||
val h = s.h
|
|
||||||
val pitch = s.pitch
|
|
||||||
val pixels = s.pixels?.reinterpret<UByteVar>() ?: return null
|
|
||||||
val out = ByteArray(w * h * 4)
|
|
||||||
for (y in 0 until h) {
|
|
||||||
val srcRow = y * pitch
|
|
||||||
val dstRow = y * w * 4
|
|
||||||
for (x in 0 until w * 4) {
|
|
||||||
out[dstRow + x] = pixels[srcRow + x].toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Triple(w, h, out)
|
|
||||||
} finally {
|
|
||||||
SDL_DestroySurface(vConverted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun destroy() {
|
|
||||||
fShadowCache?.destroy()
|
|
||||||
fClipTargets?.destroy()
|
|
||||||
fImageCache.destroy()
|
|
||||||
fTextRenderer.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-323
@@ -1,323 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import kotlinx.cinterop.*
|
|
||||||
import sdl3.*
|
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
import kotlin.math.tanh
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: Sdl3ShadowCache
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/** Cached drop-shadow tiles for the SDL renderer.
|
|
||||||
|
|
||||||
A shadow's falloff only depends on (corner radius, blur radius) — never on
|
|
||||||
the shape's overall size — so one small canonical tile per (radius, blur)
|
|
||||||
pair serves every shadow via SDL_RenderTexture9Grid: the four corner
|
|
||||||
quadrants stay fixed, the 1px-wide centre cross stretches. Compared to the
|
|
||||||
tessellated ring stack this turns a shadow from up to 12 rounded-rect fills
|
|
||||||
PER FRAME into a single 9-slice blit of a ≤ ~100×100 texture.
|
|
||||||
|
|
||||||
The tile is computed on the CPU once per key: per-pixel signed distance to
|
|
||||||
the canonical rounded rect, pushed through a Gaussian-CDF-shaped falloff
|
|
||||||
(0.5·(1 − tanh(k·d/blur)) — tanh ≈ erf), which is smoother than any ring
|
|
||||||
count. Pixels are WHITE with the falloff in alpha, so the blit applies the
|
|
||||||
tint via texture colour/alpha mod and colour never enters the cache key. */
|
|
||||||
internal class Sdl3ShadowCache(private val fRenderer: COpaquePointer) {
|
|
||||||
|
|
||||||
internal class Entry(
|
|
||||||
val tex: COpaquePointer, // white ARGB tile, falloff in alpha (0..255 = 0..1)
|
|
||||||
val corner: Int, // non-stretchable corner size (radius + blur) in px
|
|
||||||
val tile: Int, // full tile edge = corner * 2 + 1
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class Key(val radius: Int, val blur: Int)
|
|
||||||
|
|
||||||
private val fCache = LruCache<Key, Entry>(64) { vEntry ->
|
|
||||||
SDL_DestroyTexture(vEntry.tex.reinterpret())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun get(inRadiusPx: Int, inBlurPx: Int): Entry? {
|
|
||||||
val vKey = Key(inRadiusPx, inBlurPx)
|
|
||||||
fCache[vKey]?.let { return it }
|
|
||||||
val vEntry = build(inRadiusPx, inBlurPx) ?: return null
|
|
||||||
fCache[vKey] = vEntry
|
|
||||||
return vEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() {
|
|
||||||
fCache.clear()
|
|
||||||
fGenericCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// Generic-path shadows — CutCornerShape, GenericShape, … resolve to a
|
|
||||||
// path Outline, which doesn't 9-slice (arbitrary geometry doesn't
|
|
||||||
// stretch). Instead: flatten the path, rasterise a filled mask at shape
|
|
||||||
// size + blur padding, box-blur the alpha 3× (≈ Gaussian), upload once.
|
|
||||||
// Keyed by (path-geometry hash, blur); the mask is white-with-alpha so
|
|
||||||
// tint/alpha still ride the blit's colour/alpha mod.
|
|
||||||
|
|
||||||
internal class GenericEntry(
|
|
||||||
val tex: COpaquePointer,
|
|
||||||
val w: Int, // texture size in px
|
|
||||||
val h: Int,
|
|
||||||
val originX: Float, // texture (0,0) in path space = bounds.min - padding
|
|
||||||
val originY: Float,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class GenericKey(val geometry: Long, val blur: Int)
|
|
||||||
|
|
||||||
private val fGenericCache = LruCache<GenericKey, GenericEntry>(32) { vEntry ->
|
|
||||||
SDL_DestroyTexture(vEntry.tex.reinterpret())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getGeneric(inPath: androidx.compose.ui.graphics.Path, inBlurPx: Int): GenericEntry? {
|
|
||||||
val vProject = inPath as? com.compose.sdl.graphics.ProjectPath ?: return null
|
|
||||||
val vKey = GenericKey(geometryHash(vProject), inBlurPx)
|
|
||||||
fGenericCache[vKey]?.let { return it }
|
|
||||||
val vEntry = buildGeneric(vProject, inBlurPx) ?: return null
|
|
||||||
fGenericCache[vKey] = vEntry
|
|
||||||
return vEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Order-sensitive fold of every command coordinate — two paths with the
|
|
||||||
same geometry hash the same regardless of the Path INSTANCE (outlines
|
|
||||||
are re-created per layer update, so identity can't key the cache). */
|
|
||||||
private fun geometryHash(inPath: com.compose.sdl.graphics.ProjectPath): Long {
|
|
||||||
var vHash = 1125899906842597L
|
|
||||||
fun fold(inV: Float) { vHash = vHash * 31 + inV.toRawBits() }
|
|
||||||
for (vCmd in inPath.commands) when (vCmd) {
|
|
||||||
is com.compose.sdl.graphics.PathCommand.MoveTo -> { fold(1f); fold(vCmd.x); fold(vCmd.y) }
|
|
||||||
is com.compose.sdl.graphics.PathCommand.LineTo -> { fold(2f); fold(vCmd.x); fold(vCmd.y) }
|
|
||||||
is com.compose.sdl.graphics.PathCommand.QuadTo -> { fold(3f); fold(vCmd.cx); fold(vCmd.cy); fold(vCmd.x); fold(vCmd.y) }
|
|
||||||
is com.compose.sdl.graphics.PathCommand.CubicTo -> { fold(4f); fold(vCmd.c1x); fold(vCmd.c1y); fold(vCmd.c2x); fold(vCmd.c2y); fold(vCmd.x); fold(vCmd.y) }
|
|
||||||
com.compose.sdl.graphics.PathCommand.Close -> fold(5f)
|
|
||||||
}
|
|
||||||
return vHash
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildGeneric(inPath: com.compose.sdl.graphics.ProjectPath, inBlur: Int): GenericEntry? {
|
|
||||||
// Flatten to polyline contours (same curve sampling as the tessellator).
|
|
||||||
val vContours = flattenPath(inPath)
|
|
||||||
if (vContours.isEmpty()) return null
|
|
||||||
var vMinX = Float.MAX_VALUE; var vMinY = Float.MAX_VALUE
|
|
||||||
var vMaxX = -Float.MAX_VALUE; var vMaxY = -Float.MAX_VALUE
|
|
||||||
for (vC in vContours) {
|
|
||||||
var vI = 0
|
|
||||||
while (vI < vC.size) {
|
|
||||||
val vX = vC[vI]; val vY = vC[vI + 1]
|
|
||||||
if (vX < vMinX) vMinX = vX; if (vX > vMaxX) vMaxX = vX
|
|
||||||
if (vY < vMinY) vMinY = vY; if (vY > vMaxY) vMaxY = vY
|
|
||||||
vI += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vMaxX <= vMinX || vMaxY <= vMinY) return null
|
|
||||||
val vPad = inBlur + 1
|
|
||||||
val vW = (vMaxX - vMinX + 2 * vPad).toInt() + 1
|
|
||||||
val vH = (vMaxY - vMinY + 2 * vPad).toInt() + 1
|
|
||||||
if (vW <= 0 || vH <= 0 || vW > 2048 || vH > 2048) return null
|
|
||||||
|
|
||||||
// Nonzero-winding scanline fill into an alpha mask.
|
|
||||||
val vMask = ByteArray(vW * vH)
|
|
||||||
for (vRow in 0 until vH) {
|
|
||||||
val vSampleY = vRow + 0.5f - vPad + vMinY
|
|
||||||
// Collect signed crossings of this scanline.
|
|
||||||
val vXs = ArrayList<Pair<Float, Int>>(8)
|
|
||||||
for (vC in vContours) {
|
|
||||||
var vI = 0
|
|
||||||
val vN = vC.size
|
|
||||||
while (vI < vN) {
|
|
||||||
val vX0 = vC[vI]; val vY0 = vC[vI + 1]
|
|
||||||
val vJ = (vI + 2) % vN
|
|
||||||
val vX1 = vC[vJ]; val vY1 = vC[vJ + 1]
|
|
||||||
if ((vY0 <= vSampleY && vY1 > vSampleY) || (vY1 <= vSampleY && vY0 > vSampleY)) {
|
|
||||||
val vT = (vSampleY - vY0) / (vY1 - vY0)
|
|
||||||
vXs.add((vX0 + vT * (vX1 - vX0)) to (if (vY1 > vY0) 1 else -1))
|
|
||||||
}
|
|
||||||
vI += 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vXs.isEmpty()) continue
|
|
||||||
vXs.sortBy { it.first }
|
|
||||||
var vWinding = 0
|
|
||||||
var vSpanStart = 0f
|
|
||||||
for ((vX, vDir) in vXs) {
|
|
||||||
val vWas = vWinding
|
|
||||||
vWinding += vDir
|
|
||||||
if (vWas == 0 && vWinding != 0) {
|
|
||||||
vSpanStart = vX
|
|
||||||
} else if (vWas != 0 && vWinding == 0) {
|
|
||||||
val vFrom = (vSpanStart - vMinX + vPad).toInt().coerceIn(0, vW - 1)
|
|
||||||
val vTo = (vX - vMinX + vPad).toInt().coerceIn(0, vW - 1)
|
|
||||||
for (vCol in vFrom..vTo) vMask[vRow * vW + vCol] = -1 // 0xFF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3 separable box blurs ≈ Gaussian with σ ≈ blur/2; the blur also
|
|
||||||
// anti-aliases the hard scanline edges.
|
|
||||||
val vBoxR = (inBlur / 2).coerceAtLeast(1)
|
|
||||||
repeat(3) {
|
|
||||||
boxBlurH(vMask, vW, vH, vBoxR)
|
|
||||||
boxBlurV(vMask, vW, vH, vBoxR)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload as white ARGB with the mask in alpha.
|
|
||||||
val vSurface = SDL_CreateSurface(vW, vH, SDL_PIXELFORMAT_ARGB8888) ?: return null
|
|
||||||
val vPixels = vSurface.pointed.pixels?.reinterpret<UByteVar>() ?: run {
|
|
||||||
SDL_DestroySurface(vSurface)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val vPitch = vSurface.pointed.pitch
|
|
||||||
for (vRow in 0 until vH) {
|
|
||||||
val vDstRow = vRow * vPitch
|
|
||||||
val vSrcRow = vRow * vW
|
|
||||||
for (vCol in 0 until vW) {
|
|
||||||
val vBase = vDstRow + vCol * 4
|
|
||||||
vPixels[vBase + 0] = 255u
|
|
||||||
vPixels[vBase + 1] = 255u
|
|
||||||
vPixels[vBase + 2] = 255u
|
|
||||||
vPixels[vBase + 3] = vMask[vSrcRow + vCol].toUByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val vTexture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
|
|
||||||
SDL_DestroySurface(vSurface)
|
|
||||||
if (vTexture == null) return null
|
|
||||||
return GenericEntry(vTexture, vW, vH, vMinX - vPad, vMinY - vPad)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flattens the command list into closed polyline contours (x,y pairs). */
|
|
||||||
private fun flattenPath(inPath: com.compose.sdl.graphics.ProjectPath): List<FloatArray> {
|
|
||||||
val vContours = ArrayList<FloatArray>()
|
|
||||||
var vCurrent = ArrayList<Float>()
|
|
||||||
var vCx = 0f; var vCy = 0f
|
|
||||||
fun closeCurrent() {
|
|
||||||
if (vCurrent.size >= 6) vContours.add(vCurrent.toFloatArray())
|
|
||||||
vCurrent = ArrayList()
|
|
||||||
}
|
|
||||||
for (vCmd in inPath.commands) when (vCmd) {
|
|
||||||
is com.compose.sdl.graphics.PathCommand.MoveTo -> {
|
|
||||||
closeCurrent()
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
vCurrent.add(vCx); vCurrent.add(vCy)
|
|
||||||
}
|
|
||||||
is com.compose.sdl.graphics.PathCommand.LineTo -> {
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
vCurrent.add(vCx); vCurrent.add(vCy)
|
|
||||||
}
|
|
||||||
is com.compose.sdl.graphics.PathCommand.QuadTo -> {
|
|
||||||
for (vI in 1..12) {
|
|
||||||
val vT = vI / 12f; val vOne = 1f - vT
|
|
||||||
vCurrent.add(vOne * vOne * vCx + 2f * vOne * vT * vCmd.cx + vT * vT * vCmd.x)
|
|
||||||
vCurrent.add(vOne * vOne * vCy + 2f * vOne * vT * vCmd.cy + vT * vT * vCmd.y)
|
|
||||||
}
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
}
|
|
||||||
is com.compose.sdl.graphics.PathCommand.CubicTo -> {
|
|
||||||
for (vI in 1..16) {
|
|
||||||
val vT = vI / 16f; val vOne = 1f - vT
|
|
||||||
vCurrent.add(
|
|
||||||
vOne * vOne * vOne * vCx + 3f * vOne * vOne * vT * vCmd.c1x +
|
|
||||||
3f * vOne * vT * vT * vCmd.c2x + vT * vT * vT * vCmd.x,
|
|
||||||
)
|
|
||||||
vCurrent.add(
|
|
||||||
vOne * vOne * vOne * vCy + 3f * vOne * vOne * vT * vCmd.c1y +
|
|
||||||
3f * vOne * vT * vT * vCmd.c2y + vT * vT * vT * vCmd.y,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
vCx = vCmd.x; vCy = vCmd.y
|
|
||||||
}
|
|
||||||
com.compose.sdl.graphics.PathCommand.Close -> closeCurrent()
|
|
||||||
}
|
|
||||||
closeCurrent()
|
|
||||||
return vContours
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-place horizontal box blur on the alpha mask (sliding window sum). */
|
|
||||||
private fun boxBlurH(inMask: ByteArray, inW: Int, inH: Int, inR: Int) {
|
|
||||||
val vLine = IntArray(inW)
|
|
||||||
val vNorm = 2 * inR + 1
|
|
||||||
for (vRow in 0 until inH) {
|
|
||||||
val vBase = vRow * inW
|
|
||||||
var vSum = 0
|
|
||||||
for (vI in -inR..inR) vSum += inMask[vBase + vI.coerceIn(0, inW - 1)].toInt() and 0xFF
|
|
||||||
for (vCol in 0 until inW) {
|
|
||||||
vLine[vCol] = vSum / vNorm
|
|
||||||
val vAdd = (vCol + inR + 1).coerceAtMost(inW - 1)
|
|
||||||
val vSub = (vCol - inR).coerceAtLeast(0)
|
|
||||||
vSum += (inMask[vBase + vAdd].toInt() and 0xFF) - (inMask[vBase + vSub].toInt() and 0xFF)
|
|
||||||
}
|
|
||||||
for (vCol in 0 until inW) inMask[vBase + vCol] = vLine[vCol].toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-place vertical box blur on the alpha mask. */
|
|
||||||
private fun boxBlurV(inMask: ByteArray, inW: Int, inH: Int, inR: Int) {
|
|
||||||
val vLine = IntArray(inH)
|
|
||||||
val vNorm = 2 * inR + 1
|
|
||||||
for (vCol in 0 until inW) {
|
|
||||||
var vSum = 0
|
|
||||||
for (vI in -inR..inR) vSum += inMask[vI.coerceIn(0, inH - 1) * inW + vCol].toInt() and 0xFF
|
|
||||||
for (vRow in 0 until inH) {
|
|
||||||
vLine[vRow] = vSum / vNorm
|
|
||||||
val vAdd = (vRow + inR + 1).coerceAtMost(inH - 1)
|
|
||||||
val vSub = (vRow - inR).coerceAtLeast(0)
|
|
||||||
vSum += (inMask[vAdd * inW + vCol].toInt() and 0xFF) - (inMask[vSub * inW + vCol].toInt() and 0xFF)
|
|
||||||
}
|
|
||||||
for (vRow in 0 until inH) inMask[vRow * inW + vCol] = vLine[vRow].toByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rasterises the canonical tile: a rounded rect of corner radius `inRadius`
|
|
||||||
whose edge sits `inBlur` px inside the tile border, alpha = falloff of
|
|
||||||
the signed distance to that edge. */
|
|
||||||
private fun build(inRadius: Int, inBlur: Int): Entry? {
|
|
||||||
val vCorner = inRadius + inBlur
|
|
||||||
val vTile = vCorner * 2 + 1
|
|
||||||
val vSurface = SDL_CreateSurface(vTile, vTile, SDL_PIXELFORMAT_ARGB8888) ?: return null
|
|
||||||
val vPixels = vSurface.pointed.pixels?.reinterpret<UByteVar>() ?: run {
|
|
||||||
SDL_DestroySurface(vSurface)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val vPitch = vSurface.pointed.pitch
|
|
||||||
|
|
||||||
// Shape half-extent: edge inset by blur from the tile border; the
|
|
||||||
// centre pixel is the stretchable 9-grid cross.
|
|
||||||
val vHalf = inRadius + 0.5f
|
|
||||||
val vCentre = vCorner + 0.5f // tile centre in pixel coords
|
|
||||||
val vR = inRadius.toFloat()
|
|
||||||
val vBlurF = max(1f, inBlur.toFloat())
|
|
||||||
val vSteep = 1.8f // tanh(1.8·d/blur): ~erf of a σ≈blur/2 Gaussian
|
|
||||||
|
|
||||||
for (vY in 0 until vTile) {
|
|
||||||
val vRow = vY * vPitch
|
|
||||||
for (vX in 0 until vTile) {
|
|
||||||
// Signed distance from the pixel centre to the rounded rect
|
|
||||||
// (standard rounded-box SDF, evaluated in |quadrant| space).
|
|
||||||
val vPx = kotlin.math.abs(vX + 0.5f - vCentre) - (vHalf - vR)
|
|
||||||
val vPy = kotlin.math.abs(vY + 0.5f - vCentre) - (vHalf - vR)
|
|
||||||
val vQx = max(vPx, 0f)
|
|
||||||
val vQy = max(vPy, 0f)
|
|
||||||
val vDist = sqrt(vQx * vQx + vQy * vQy) + min(max(vPx, vPy), 0f) - vR
|
|
||||||
// Gaussian-CDF-shaped falloff: 1 deep inside, 0.5 at the edge,
|
|
||||||
// → 0 at edge + blur.
|
|
||||||
val vAlpha = 0.5f * (1f - tanh(vSteep * vDist / vBlurF))
|
|
||||||
val vA = (vAlpha * 255f + 0.5f).toInt().coerceIn(0, 255)
|
|
||||||
val vBase = vRow + vX * 4
|
|
||||||
// ARGB8888 little-endian memory layout: [B, G, R, A].
|
|
||||||
vPixels[vBase + 0] = 255u
|
|
||||||
vPixels[vBase + 1] = 255u
|
|
||||||
vPixels[vBase + 2] = 255u
|
|
||||||
vPixels[vBase + 3] = vA.toUByte()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val vTexture = SDL_CreateTextureFromSurface(fRenderer.reinterpret(), vSurface)
|
|
||||||
SDL_DestroySurface(vSurface)
|
|
||||||
if (vTexture == null) return null
|
|
||||||
return Entry(vTexture, vCorner, vTile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-938
@@ -1,938 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import com.compose.sdl.*
|
|
||||||
import com.compose.sdl.icons.IconFont
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
|
||||||
import com.compose.sdl.graphics.r8
|
|
||||||
import com.compose.sdl.graphics.g8
|
|
||||||
import com.compose.sdl.graphics.b8
|
|
||||||
import com.compose.sdl.graphics.a8
|
|
||||||
import com.compose.sdl.text.TextMeasurer
|
|
||||||
import com.compose.sdl.text.TextRendererCapabilities
|
|
||||||
import com.compose.sdl.text.WrappedText
|
|
||||||
import androidx.compose.ui.text.AnnotatedString.Range
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import com.compose.sdl.text.ColorRun
|
|
||||||
import com.compose.sdl.text.lineColorRuns
|
|
||||||
import com.compose.sdl.text.resolveRunPx
|
|
||||||
import com.compose.sdl.text.runVariations
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import kotlinx.cinterop.*
|
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import sdl3.SDL_ConvertSurface
|
|
||||||
import sdl3.SDL_CreateTextureFromSurface
|
|
||||||
import sdl3.SDL_DestroyTexture
|
|
||||||
import sdl3.SDL_FRect
|
|
||||||
import sdl3.SDL_GetError
|
|
||||||
import sdl3.SDL_GetTextureSize
|
|
||||||
import sdl3.SDL_PIXELFORMAT_ARGB8888
|
|
||||||
import sdl3.SDL_RenderTexture
|
|
||||||
import sdl3.SDL_DestroySurface
|
|
||||||
import sdl3.SDL_Color
|
|
||||||
import sdl3.SDL_SetTextureAlphaMod
|
|
||||||
import sdl3.SDL_SetTextureColorMod
|
|
||||||
import sdl3.SDL_BLENDMODE_BLEND
|
|
||||||
import sdl3.SDL_RenderFillRect
|
|
||||||
import sdl3.SDL_SetRenderDrawBlendMode
|
|
||||||
import sdl3.SDL_SetRenderDrawColor
|
|
||||||
import sdl3_ttf.TTF_CloseFont
|
|
||||||
import sdl3_ttf.TTF_GetFontAscent
|
|
||||||
import sdl3_ttf.TTF_GetFontHeight
|
|
||||||
import sdl3_ttf.TTF_GetStringSize
|
|
||||||
import sdl3_ttf.TTF_Init
|
|
||||||
import sdl3_ttf.TTF_OpenFont
|
|
||||||
import sdl3_ttf.TTF_OpenFontIO
|
|
||||||
import sdl3_ttf.TTF_Quit
|
|
||||||
import sdl3_ttf.TTF_RenderText_Blended
|
|
||||||
import sdl3_ttf.TTF_SetFontAxisValue
|
|
||||||
import sdl3_ttf.TTF_SetFontStyle
|
|
||||||
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)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
// Cache caps. Textures: ~a few hundred distinct visible strings is plenty for a
|
|
||||||
// screenful of UI; evicted entries destroy their GPU texture and re-rasterise on
|
|
||||||
// next use. Widths: pure ints, capped by clear (see fWidthCache).
|
|
||||||
private const val kTextureCacheMax: Int = 768
|
|
||||||
private const val kWidthCacheMax: Int = 16384
|
|
||||||
|
|
||||||
// SDL_ttf TTF_STYLE_* bits (values from SDL_ttf.h). Set per rasterise/measure
|
|
||||||
// call — synthetic italic and underline/strikethrough bake into the cached
|
|
||||||
// white-glyph texture (SDL_ttf computes the decoration metrics) and get tinted
|
|
||||||
// with the run colour at blit time like the glyphs themselves.
|
|
||||||
private const val kStyleItalic: Int = 0x02
|
|
||||||
private const val kStyleUnderline: Int = 0x04
|
|
||||||
private const val kStyleStrikethrough: Int = 0x08
|
|
||||||
|
|
||||||
/** Text via SDL3_ttf. Caches TTF_Font per size, and a per-(text, color,
|
|
||||||
fontSize) SDL_Texture so repeated frames don't re-rasterise the same
|
|
||||||
string. Implements TextMeasurer so the common layout pass agrees
|
|
||||||
with what we paint. */
|
|
||||||
internal class Sdl3TextRenderer(private val backend: SDL3Backend) {
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Regular text applies variable-font axes (wght, etc.) through the
|
|
||||||
// forked SDL3_ttf axis API (TTF_SetFontAxisValue). Icon-font draws still
|
|
||||||
// route through FreeType (FreeTypeIcons) for the full FILL / wght / GRAD
|
|
||||||
// / opsz set. Either way axes work, so the capability flag stays true
|
|
||||||
// (Material Symbols install() skips its "axes ignored" warning).
|
|
||||||
TextRendererCapabilities.supportsFontVariations = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val fFreeTypeIcons = FreeTypeIcons()
|
|
||||||
|
|
||||||
// Density for FontVariation.Setting.toVariationValue — only TextUnit axes
|
|
||||||
// (e.g. opsz in sp) consult it; wght and friends ignore it. Glyphs are
|
|
||||||
// rasterised at physical px already, so a 1:1 density is correct here.
|
|
||||||
private val fVarDensity = Density(1f)
|
|
||||||
|
|
||||||
// HiDPI scale. Fonts are opened at fontSize * DPR pixels so the
|
|
||||||
// rasterised glyph texture matches the physical pixel count of the
|
|
||||||
// logical-size dst rect after SDL_SetRenderScale stretches it.
|
|
||||||
// Measurement results are divided back by DPR so layout still works
|
|
||||||
// in logical points.
|
|
||||||
private var fDpr: Float = 1f
|
|
||||||
|
|
||||||
// Exposed for Sdl3Canvas — Sp-valued span sizes resolve through the same
|
|
||||||
// density the paragraph resolved its base size with (see resolveRunPx).
|
|
||||||
internal val dpr: Float get() = fDpr
|
|
||||||
|
|
||||||
fun setDpr(inDpr: Float) {
|
|
||||||
if (inDpr == fDpr) return
|
|
||||||
fDpr = inDpr
|
|
||||||
// Invalidate everything keyed off the old DPR-baked sizes.
|
|
||||||
fTextureCache.clear() // onEvict destroys the SDL textures
|
|
||||||
for (f in fFontCache.values) TTF_CloseFont(f.reinterpret())
|
|
||||||
fFontCache.clear()
|
|
||||||
fWidthCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached TTF_Font handles keyed by (family, logical pixel size, variations).
|
|
||||||
// family is null for the default; non-null for registered IconFont entries.
|
|
||||||
// Variations are baked into the handle (TTF_SetFontAxisValue mutates the
|
|
||||||
// font), so each distinct axis set gets its own handle.
|
|
||||||
private data class FontKey(val family: String?, val size: Int, val variations: String)
|
|
||||||
private val fFontCache = mutableMapOf<FontKey, COpaquePointer>()
|
|
||||||
|
|
||||||
/** A stable key for a variation set — also the value serialised for cache
|
|
||||||
lookups. Empty when there are no variations. */
|
|
||||||
private fun variationsKey(inVariations: List<FontVariation.Setting>?): String =
|
|
||||||
if (inVariations.isNullOrEmpty()) ""
|
|
||||||
else inVariations.joinToString(",") { "${it.axisName}=${it.toVariationValue(fVarDensity)}" }
|
|
||||||
|
|
||||||
/** Applies each variation axis to an open TTF_Font via the forked axis API.
|
|
||||||
TTF_StringToTag turns "wght" into the OpenType tag; unsupported axes are
|
|
||||||
ignored by SDL3_ttf (returns false), so passing e.g. wght to a static
|
|
||||||
font is a harmless no-op. */
|
|
||||||
private fun applyAxes(inFont: COpaquePointer, inVariations: List<FontVariation.Setting>?) {
|
|
||||||
if (inVariations.isNullOrEmpty()) return
|
|
||||||
for (vSetting in inVariations) {
|
|
||||||
TTF_SetFontAxisValue(
|
|
||||||
inFont.reinterpret(),
|
|
||||||
TTF_StringToTag(vSetting.axisName),
|
|
||||||
vSetting.toVariationValue(fVarDensity),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-family bundled font bytes — allocated on the native heap once,
|
|
||||||
// shared by every TTF_OpenFontIO opened from that family (the IO closes
|
|
||||||
// with the font but the underlying mem must outlive every font opened
|
|
||||||
// against it, so we keep them all until destroy()).
|
|
||||||
private val fFontMem = mutableMapOf<String?, Pair<CPointer<ByteVar>, Int>>()
|
|
||||||
// Families we've already tried and failed to resolve — cached so we don't
|
|
||||||
// keep retrying every frame.
|
|
||||||
private val fMissingFamilies = mutableSetOf<String>()
|
|
||||||
|
|
||||||
// Cached glyph textures: (family, text, fontSize, variations) → texture.
|
|
||||||
// Glyphs are rasterised WHITE (RGB=255, A=coverage); the tint colour and
|
|
||||||
// alpha are applied at blit time via SDL_SetTextureColorMod/AlphaMod, so
|
|
||||||
// colour/alpha animations don't grow the cache or re-rasterise.
|
|
||||||
private data class TextureKey(val family: String?, val text: String, val fontSize: Int, val variations: String, val style: Int)
|
|
||||||
private data class CachedTexture(val tex: COpaquePointer, val w: Int, val h: Int)
|
|
||||||
// LRU-capped: every unique string ever rendered used to keep a GPU texture
|
|
||||||
// alive for the whole session (TextField keystrokes, response bodies, …).
|
|
||||||
private val fTextureCache = LruCache<TextureKey, CachedTexture>(kTextureCacheMax) {
|
|
||||||
SDL_DestroyTexture(it.tex.reinterpret())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies the tint at blit time — the cached texture is white glyphs with
|
|
||||||
coverage alpha; modulation multiplies per-channel, exactly what baking
|
|
||||||
the colour into the surface used to do. */
|
|
||||||
private fun applyTint(inTex: COpaquePointer, inColor: ComposeColor) {
|
|
||||||
SDL_SetTextureColorMod(inTex.reinterpret(), inColor.r8.toUByte(), inColor.g8.toUByte(), inColor.b8.toUByte())
|
|
||||||
SDL_SetTextureAlphaMod(inTex.reinterpret(), inColor.a8.toUByte())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cached measured widths in LOGICAL points keyed by (family, text, size, variations).
|
|
||||||
// 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. */
|
|
||||||
fun init(): Boolean {
|
|
||||||
if (!TTF_Init()) {
|
|
||||||
println("TTF_Init failed: ${SDL_GetError()?.toKString()}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroy() {
|
|
||||||
fTextureCache.clear() // onEvict destroys the SDL textures
|
|
||||||
for (f in fFontCache.values) TTF_CloseFont(f.reinterpret())
|
|
||||||
fFontCache.clear()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
val textMeasurer: TextMeasurer = object : TextMeasurer {
|
|
||||||
// Measurement applies the same axes as paint (via getFont), so layout
|
|
||||||
// sees the weighted glyph widths and there's no right-edge clipping.
|
|
||||||
override fun measure(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String?, inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?): IntSize {
|
|
||||||
val vWrap = wrap(inText, inFontSize, inMaxWidth, inFontFamily, inFontVariations)
|
|
||||||
val vWidth = if (vWrap.lines.isEmpty()) 0
|
|
||||||
else vWrap.lines.maxOf { measureWidth(it, inFontSize, inFontFamily, inFontVariations) }
|
|
||||||
val vLine = lineHeight(inFontSize, inFontFamily, inFontVariations).toInt().coerceAtLeast(1)
|
|
||||||
return IntSize(vWidth, vLine * vWrap.lines.size.coerceAtLeast(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun wrap(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String?, inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>?): WrappedText =
|
|
||||||
wrapTextWithStarts(inText, inFontSize, inMaxWidth, inFontFamily, inFontVariations)
|
|
||||||
|
|
||||||
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. */
|
|
||||||
private fun wrapTextWithStarts(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String? = null, inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>? = null): WrappedText {
|
|
||||||
if (inText.isEmpty()) return WrappedText(listOf(""), intArrayOf(0))
|
|
||||||
val vLines = mutableListOf<String>()
|
|
||||||
val vStarts = mutableListOf<Int>()
|
|
||||||
val vUnbounded = inMaxWidth >= Int.MAX_VALUE / 2
|
|
||||||
|
|
||||||
var vHardStart = 0
|
|
||||||
while (vHardStart <= inText.length) {
|
|
||||||
val vNl = inText.indexOf('\n', vHardStart)
|
|
||||||
val vHardEnd = if (vNl < 0) inText.length else vNl
|
|
||||||
val vHard = inText.substring(vHardStart, vHardEnd)
|
|
||||||
if (vUnbounded || vHard.isEmpty() || measureWidth(vHard, inFontSize, inFontFamily, inFontVariations) <= inMaxWidth) {
|
|
||||||
vLines.add(vHard); vStarts.add(vHardStart)
|
|
||||||
} else {
|
|
||||||
wrapHardLine(vHard, inFontSize, inMaxWidth, vHardStart, vLines, vStarts, inFontFamily, inFontVariations)
|
|
||||||
}
|
|
||||||
if (vNl < 0) break
|
|
||||||
vHardStart = vNl + 1
|
|
||||||
}
|
|
||||||
return WrappedText(vLines, vStarts.toIntArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun wrapHardLine(
|
|
||||||
inLine: String,
|
|
||||||
inFontSize: Int,
|
|
||||||
inMaxWidth: Int,
|
|
||||||
inBaseOffset: Int,
|
|
||||||
outLines: MutableList<String>,
|
|
||||||
outStarts: MutableList<Int>,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>? = null,
|
|
||||||
) {
|
|
||||||
var vCurrent = StringBuilder()
|
|
||||||
var vLineStartInHard = 0
|
|
||||||
var i = 0
|
|
||||||
while (i < inLine.length) {
|
|
||||||
val vWordStart = i
|
|
||||||
while (i < inLine.length && !inLine[i].isWhitespace()) i++
|
|
||||||
while (i < inLine.length && inLine[i].isWhitespace()) i++
|
|
||||||
val vWord = inLine.substring(vWordStart, i)
|
|
||||||
val vCandidate = vCurrent.toString() + vWord
|
|
||||||
if (measureWidth(vCandidate, inFontSize, inFontFamily, inFontVariations) <= inMaxWidth) {
|
|
||||||
vCurrent.append(vWord)
|
|
||||||
} else {
|
|
||||||
if (vCurrent.isNotEmpty()) {
|
|
||||||
outLines.add(vCurrent.toString())
|
|
||||||
outStarts.add(inBaseOffset + vLineStartInHard)
|
|
||||||
vLineStartInHard += vCurrent.length
|
|
||||||
vCurrent = StringBuilder()
|
|
||||||
}
|
|
||||||
if (measureWidth(vWord, inFontSize, inFontFamily, inFontVariations) > inMaxWidth) {
|
|
||||||
val vSub = StringBuilder()
|
|
||||||
for (ch in vWord) {
|
|
||||||
if (measureWidth(vSub.toString() + ch, inFontSize, inFontFamily, inFontVariations) > inMaxWidth) {
|
|
||||||
if (vSub.isNotEmpty()) {
|
|
||||||
outLines.add(vSub.toString())
|
|
||||||
outStarts.add(inBaseOffset + vLineStartInHard)
|
|
||||||
vLineStartInHard += vSub.length
|
|
||||||
vSub.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vSub.append(ch)
|
|
||||||
}
|
|
||||||
vCurrent.append(vSub)
|
|
||||||
} else {
|
|
||||||
vCurrent.append(vWord)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vCurrent.isNotEmpty()) {
|
|
||||||
outLines.add(vCurrent.toString())
|
|
||||||
outStarts.add(inBaseOffset + vLineStartInHard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab stops are rendered as a fixed run of spaces so a literal '\t'
|
|
||||||
// (e.g. in the API body editor) shows as indentation instead of a
|
|
||||||
// font-dependent, often zero-width glyph. Expansion is width-only /
|
|
||||||
// draw-only — callers keep the original '\t' so the text's character
|
|
||||||
// indices (cursor / selection) stay correct.
|
|
||||||
private fun expandTabs(inText: String): String =
|
|
||||||
if ('\t' in inText) inText.replace("\t", " ".repeat(TextLayoutConfig.tabWidth)) else inText
|
|
||||||
|
|
||||||
/** Returns LOGICAL-point width. The font was opened at fontSize*DPR
|
|
||||||
so TTF_GetStringSize reports physical pixels — divide by DPR to
|
|
||||||
get back to logical. */
|
|
||||||
private fun measureWidth(
|
|
||||||
inText: String,
|
|
||||||
inFontSize: Int,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>? = null,
|
|
||||||
inStyle: Int = 0,
|
|
||||||
): Int {
|
|
||||||
if (inText.isEmpty()) return 0
|
|
||||||
// Tabs → spaces for measurement (width-only; original '\t' kept).
|
|
||||||
val vText = expandTabs(inText)
|
|
||||||
// Axes are baked into the font handle (getFont applies them), so
|
|
||||||
// TTF_GetStringSize reports the weighted width and layout matches paint.
|
|
||||||
// Key tab-containing text by the current tab width (see SkiaTextRenderer).
|
|
||||||
val vKeyText = if ('\t' in inText) "${TextLayoutConfig.tabWidth} $inText" else inText
|
|
||||||
val vKey = WidthKey(inFontFamily, vKeyText, inFontSize, variationsKey(inFontVariations), inStyle)
|
|
||||||
fWidthCache[vKey]?.let { return it }
|
|
||||||
val vFont = getFont(inFontFamily, inFontSize, inFontVariations) ?: run {
|
|
||||||
val vEst = (vText.length * inFontSize * 0.6f).toInt()
|
|
||||||
fWidthCache[vKey] = vEst
|
|
||||||
return vEst
|
|
||||||
}
|
|
||||||
var vPhys = 0
|
|
||||||
memScoped {
|
|
||||||
val vW = alloc<IntVar>()
|
|
||||||
val vH = alloc<IntVar>()
|
|
||||||
// Style set per call (handles are shared) — synthetic italic widens
|
|
||||||
// the reported box, so styled measure matches the styled texture.
|
|
||||||
TTF_SetFontStyle(vFont.reinterpret(), inStyle.toUInt())
|
|
||||||
// Length = 0 → SDL_ttf calls strlen on the UTF-8 string. Don't
|
|
||||||
// pass inText.length: that's UTF-16 code-unit count, but the C
|
|
||||||
// side wants byte count. Non-ASCII chars (e.g. em-dash → 3
|
|
||||||
// UTF-8 bytes) would otherwise truncate the tail of the string.
|
|
||||||
if (TTF_GetStringSize(vFont.reinterpret(), vText, 0u, vW.ptr, vH.ptr)) {
|
|
||||||
vPhys = vW.value
|
|
||||||
}
|
|
||||||
TTF_SetFontStyle(vFont.reinterpret(), 0u)
|
|
||||||
}
|
|
||||||
val vLogical = (vPhys / fDpr).toInt()
|
|
||||||
if (fWidthCache.size >= kWidthCacheMax) fWidthCache.clear()
|
|
||||||
fWidthCache[vKey] = vLogical
|
|
||||||
return vLogical
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ascent of the font at inFontSize in LOGICAL points — used to align
|
|
||||||
mixed-size runs on a common baseline. */
|
|
||||||
private fun fontAscent(
|
|
||||||
inFontSize: Int,
|
|
||||||
inFontFamily: String?,
|
|
||||||
inFontVariations: List<FontVariation.Setting>? = null,
|
|
||||||
): Float {
|
|
||||||
val vFont = getFont(inFontFamily, inFontSize, inFontVariations) ?: return inFontSize * 0.8f
|
|
||||||
return TTF_GetFontAscent(vFont.reinterpret()).toFloat() / fDpr
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Renders one already-wrapped line at (inX, inY) inside a box of
|
|
||||||
(inBoxWidth, inBoxHeight). Caches the rasterised texture so calling
|
|
||||||
this every frame for the same string is cheap. */
|
|
||||||
fun drawText(
|
|
||||||
inText: String,
|
|
||||||
inX: Int,
|
|
||||||
inY: Int,
|
|
||||||
inBoxWidth: Int,
|
|
||||||
inBoxHeight: Int,
|
|
||||||
inColor: ComposeColor,
|
|
||||||
inFontSize: Int,
|
|
||||||
inAlign: TextAlign,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<androidx.compose.ui.text.font.FontVariation.Setting>? = null,
|
|
||||||
inSpans: List<Range<SpanStyle>>? = null,
|
|
||||||
inTextStart: Int = 0,
|
|
||||||
inItalic: Boolean = false,
|
|
||||||
inUnderline: Boolean = false,
|
|
||||||
inLineThrough: Boolean = false,
|
|
||||||
// Layer-scale carried by the current canvas affine. Glyphs are rasterised at
|
|
||||||
// their LOGICAL size (stable hinting, one cache entry) and the blit is
|
|
||||||
// stretched by this — so text grows/shrinks smoothly with a scaled layer
|
|
||||||
// (menu/dropdown enter animation) the way Skia GPU-scales the layer, instead
|
|
||||||
// of the geometry scaling while the text stays a fixed size. 1f = no scale.
|
|
||||||
inScaleX: Float = 1f,
|
|
||||||
inScaleY: Float = 1f,
|
|
||||||
) {
|
|
||||||
if (inText.isEmpty()) return
|
|
||||||
val vRenderer = backend.renderer ?: return
|
|
||||||
// Paragraph-level style bits (TextStyle.fontStyle / paint textDecoration).
|
|
||||||
val vBaseStyle =
|
|
||||||
(if (inItalic) kStyleItalic else 0) or
|
|
||||||
(if (inUnderline) kStyleUnderline else 0) or
|
|
||||||
(if (inLineThrough) kStyleStrikethrough else 0)
|
|
||||||
|
|
||||||
// Icon-font path: anything registered in IconFont gets routed through
|
|
||||||
// FreeType so variable-font axes work. We treat the text as a single
|
|
||||||
// codepoint per icon (Material Symbols are all BMP single chars).
|
|
||||||
if (inFontFamily != null && fFreeTypeIcons.hasFamily(inFontFamily)) {
|
|
||||||
val vCodepoint = inText.codePointAtSafe(0)
|
|
||||||
val vDrew = fFreeTypeIcons.drawGlyph(
|
|
||||||
inSdlRenderer = vRenderer,
|
|
||||||
inFamily = inFontFamily,
|
|
||||||
inCodepoint = vCodepoint,
|
|
||||||
inPixelSize = inFontSize,
|
|
||||||
inColor = inColor,
|
|
||||||
inVariations = inFontVariations ?: emptyList(),
|
|
||||||
inBoxX = inX,
|
|
||||||
inBoxY = inY,
|
|
||||||
inBoxW = inBoxWidth,
|
|
||||||
inBoxH = inBoxHeight,
|
|
||||||
inDpr = fDpr,
|
|
||||||
)
|
|
||||||
if (vDrew) return
|
|
||||||
// Fall through to SDL3_ttf if the FreeType path couldn't draw
|
|
||||||
// (missing family, no glyph, etc.) so we at least show *something*.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tabs → spaces for non-icon text (draw-only; callers keep '\t').
|
|
||||||
val vText = expandTabs(inText)
|
|
||||||
|
|
||||||
// Per-span styles: split this (already-wrapped) line into same-style
|
|
||||||
// segments — mapped from original-text indices via inTextStart — and
|
|
||||||
// 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.
|
|
||||||
val vRuns = lineColorRuns(
|
|
||||||
inText, inTextStart, inSpans, inColor,
|
|
||||||
inBaseItalic = inItalic,
|
|
||||||
inBaseUnderline = inUnderline,
|
|
||||||
inBaseLineThrough = inLineThrough,
|
|
||||||
)
|
|
||||||
// Shared resolution helpers (ColorRun.kt) — the SAME code SdlParagraph
|
|
||||||
// measures layout with, so painted runs land inside the measured box.
|
|
||||||
fun runVars(inRun: ColorRun): List<androidx.compose.ui.text.font.FontVariation.Setting>? =
|
|
||||||
runVariations(inRun, inFontVariations)
|
|
||||||
fun runSeg(inRun: ColorRun): String = expandTabs(inText.substring(inRun.start, inRun.end))
|
|
||||||
fun runPx(inRun: ColorRun): Int = resolveRunPx(inRun, inFontSize, fDpr)
|
|
||||||
fun runStyle(inRun: ColorRun): Int =
|
|
||||||
(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()
|
|
||||||
// Alignment offsets are logical; scale them so the styled line lays out
|
|
||||||
// in the layer's scaled space (see inScaleX/inScaleY on the plain path).
|
|
||||||
val vScaled = inScaleX != 1f || inScaleY != 1f
|
|
||||||
val vPenX0 = when (inAlign) {
|
|
||||||
TextAlign.Start -> inX.toFloat()
|
|
||||||
TextAlign.Center -> inX + (inBoxWidth - vLineW) / 2f * inScaleX
|
|
||||||
TextAlign.End -> inX + (inBoxWidth - vLineW) * inScaleX
|
|
||||||
else -> inX.toFloat()
|
|
||||||
}
|
|
||||||
fun snap(inV: Float): Float = if (vScaled) inV else kotlin.math.round(inV * fDpr) / fDpr
|
|
||||||
// Common baseline: centre the line's TALLEST run cell in the line box
|
|
||||||
// (the box the canvas passes IS that tall for mixed-size lines), then
|
|
||||||
// sit every run's baseline on it. Without this, a bigger/smaller run
|
|
||||||
// would centre its own texture and float off the baseline. For
|
|
||||||
// uniform lines this reduces to centring the base cell — identical
|
|
||||||
// to the non-span path.
|
|
||||||
var vMaxCellH = textMeasurer.lineHeight(inFontSize, inFontFamily, inFontVariations)
|
|
||||||
var vMaxAscent = fontAscent(inFontSize, inFontFamily, inFontVariations)
|
|
||||||
for (vRun in vRuns) {
|
|
||||||
val vPx = runPx(vRun)
|
|
||||||
if (vPx != inFontSize) {
|
|
||||||
val vVars = runVars(vRun)
|
|
||||||
val vCell = textMeasurer.lineHeight(vPx, inFontFamily, vVars)
|
|
||||||
if (vCell > vMaxCellH) vMaxCellH = vCell
|
|
||||||
val vAsc = fontAscent(vPx, inFontFamily, vVars)
|
|
||||||
if (vAsc > vMaxAscent) vMaxAscent = vAsc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val vBaselineY = inY + ((inBoxHeight - vMaxCellH) / 2f + vMaxAscent) * inScaleY
|
|
||||||
// Walk runs left to right, advancing by each run's styled advance so
|
|
||||||
// a bold/resized run pushes the following runs over by the right amount.
|
|
||||||
var vRunX = vPenX0
|
|
||||||
for (vRun in vRuns) {
|
|
||||||
val vVars = runVars(vRun)
|
|
||||||
val vSeg = runSeg(vRun)
|
|
||||||
val vPx = runPx(vRun)
|
|
||||||
val vStyle = runStyle(vRun)
|
|
||||||
val vAdvance = measureWidth(vSeg, vPx, inFontFamily, vVars, vStyle).toFloat()
|
|
||||||
// SpanStyle.background — fill the run's slice of the line band
|
|
||||||
// behind the glyphs.
|
|
||||||
if (vRun.background != ComposeColor.Unspecified && vRun.background.alpha > 0f) {
|
|
||||||
memScoped {
|
|
||||||
val vRect = alloc<SDL_FRect>()
|
|
||||||
vRect.x = snap(vRunX)
|
|
||||||
vRect.y = inY.toFloat()
|
|
||||||
vRect.w = vAdvance * inScaleX
|
|
||||||
vRect.h = inBoxHeight.toFloat() * inScaleY
|
|
||||||
SDL_SetRenderDrawBlendMode(vRenderer.reinterpret(), SDL_BLENDMODE_BLEND)
|
|
||||||
SDL_SetRenderDrawColor(
|
|
||||||
vRenderer.reinterpret(),
|
|
||||||
vRun.background.r8.toUByte(), vRun.background.g8.toUByte(),
|
|
||||||
vRun.background.b8.toUByte(), vRun.background.a8.toUByte(),
|
|
||||||
)
|
|
||||||
SDL_RenderFillRect(vRenderer.reinterpret(), vRect.ptr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val vCachedSeg = getOrCreateTexture(inFontFamily, vSeg, vPx, vVars, vStyle)
|
|
||||||
if (vCachedSeg != null) {
|
|
||||||
val vLogW = vCachedSeg.w / fDpr
|
|
||||||
val vLogH = vCachedSeg.h / fDpr
|
|
||||||
// Ascent offset is logical → scale it onto the (device) baseline.
|
|
||||||
val vPenY = vBaselineY - fontAscent(vPx, inFontFamily, vVars) * inScaleY
|
|
||||||
if (vCapture != null) {
|
|
||||||
// Record params (not the texture pointer) — eviction-safe replay.
|
|
||||||
// Always identity at record time, so LOGICAL size is stored.
|
|
||||||
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 * inScaleX
|
|
||||||
vDst.h = vLogH * inScaleY
|
|
||||||
SDL_RenderTexture(vRenderer.reinterpret(), vCachedSeg.tex.reinterpret(), null, vDst.ptr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vRunX += vAdvance * inScaleX
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val vCached = getOrCreateTexture(inFontFamily, vText, inFontSize, inFontVariations, vBaseStyle) ?: return
|
|
||||||
applyTint(vCached.tex, inColor)
|
|
||||||
|
|
||||||
// Texture dimensions are physical pixels (rasterised at fontSize *
|
|
||||||
// DPR). Convert to logical for the dst rect so SDL_SetRenderScale's
|
|
||||||
// stretch brings it back to the same pixel size — i.e. 1:1, crisp.
|
|
||||||
val vLogW = vCached.w / fDpr
|
|
||||||
val vLogH = vCached.h / fDpr
|
|
||||||
// Blit size under the layer scale (see inScaleX/inScaleY). At scale 1 this
|
|
||||||
// is the logical size — the crisp 1:1 common case.
|
|
||||||
val vDstW = vLogW * inScaleX
|
|
||||||
val vDstH = vLogH * inScaleY
|
|
||||||
val vScaled = inScaleX != 1f || inScaleY != 1f
|
|
||||||
|
|
||||||
val vPenX = when (inAlign) {
|
|
||||||
TextAlign.Start -> inX.toFloat()
|
|
||||||
TextAlign.Center -> inX + (inBoxWidth * inScaleX - vDstW) / 2f
|
|
||||||
TextAlign.End -> inX + (inBoxWidth * inScaleX - vDstW)
|
|
||||||
else -> inX.toFloat()
|
|
||||||
}
|
|
||||||
// Vertically centre — matches the Skia path which cap-centres the
|
|
||||||
// glyphs inside the box.
|
|
||||||
val vPenY = inY + (inBoxHeight * inScaleY - vDstH) / 2f
|
|
||||||
|
|
||||||
// Snap the blit origin to the physical pixel grid. The glyph texture is
|
|
||||||
// rasterised once at physical size, so drawing it at a fractional
|
|
||||||
// physical position resamples (softens) it. Vertically-centred labels
|
|
||||||
// — e.g. the sidebar items in a 40dp box — land on a half-pixel when
|
|
||||||
// (boxHeight - textHeight) is odd, which is the main source of blur.
|
|
||||||
// round(v * dpr) / dpr keeps the blit 1:1, matching Skia's crispness.
|
|
||||||
// When the layer is scaled the blit is a resample anyway (and mid-animation
|
|
||||||
// positions are fractional by design), so snapping would only cause jitter —
|
|
||||||
// skip it.
|
|
||||||
fun snap(inV: Float): Float = if (vScaled) inV else 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 and
|
|
||||||
// applies the layer scale to the size. Capture always runs at identity
|
|
||||||
// (record-time) so vScaled is false here; store LOGICAL size.
|
|
||||||
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)
|
|
||||||
vDst.y = snap(vPenY)
|
|
||||||
vDst.w = vDstW
|
|
||||||
vDst.h = vDstH
|
|
||||||
SDL_RenderTexture(vRenderer.reinterpret(), vCached.tex.reinterpret(), null, vDst.ptr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
// Layer scale from the replay affine — blit-stretches the logical-size run so
|
|
||||||
// text tracks a scaled layer on the fast path (matches drawText's inScale*).
|
|
||||||
inScaleX: Float = 1f,
|
|
||||||
inScaleY: Float = 1f,
|
|
||||||
) {
|
|
||||||
val vRenderer = backend.renderer ?: return
|
|
||||||
val vCached = getOrCreateTexture(inFontFamily, inText, inFontSize, inFontVariations, inStyle) ?: return
|
|
||||||
applyTint(vCached.tex, inColor)
|
|
||||||
val vScaled = inScaleX != 1f || inScaleY != 1f
|
|
||||||
memScoped {
|
|
||||||
val vDst = alloc<SDL_FRect>()
|
|
||||||
vDst.x = if (vScaled) inDeviceX else kotlin.math.round(inDeviceX * fDpr) / fDpr
|
|
||||||
vDst.y = if (vScaled) inDeviceY else kotlin.math.round(inDeviceY * fDpr) / fDpr
|
|
||||||
vDst.w = inLogW * inScaleX
|
|
||||||
vDst.h = inLogH * inScaleY
|
|
||||||
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
|
|
||||||
// glyphs. Tune toward 1.0 to lighten, toward 0.6 to thicken further.
|
|
||||||
private val kTextGamma = 0.72f
|
|
||||||
private val fGammaLut = UByteArray(256) { i ->
|
|
||||||
(255f * (i / 255f).pow(kTextGamma)).roundToInt().coerceIn(0, 255).toUByte()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gamma-boosts the alpha (coverage) channel of an ARGB8888 glyph surface
|
|
||||||
in place. Runs once per cached string. */
|
|
||||||
private fun applyCoverageGamma(inSurface: CPointer<sdl3.SDL_Surface>) {
|
|
||||||
val vS = inSurface.pointed
|
|
||||||
val vPixels = vS.pixels?.reinterpret<UByteVar>() ?: return
|
|
||||||
val vW = vS.w
|
|
||||||
val vH = vS.h
|
|
||||||
val vPitch = vS.pitch
|
|
||||||
// ARGB8888 is 0xAARRGGBB; little-endian → alpha is byte 3 of each pixel.
|
|
||||||
for (y in 0 until vH) {
|
|
||||||
val vRow = y * vPitch
|
|
||||||
for (x in 0 until vW) {
|
|
||||||
val vAi = vRow + x * 4 + 3
|
|
||||||
vPixels[vAi] = fGammaLut[vPixels[vAi].toInt()]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getOrCreateTexture(
|
|
||||||
inFontFamily: String?,
|
|
||||||
inText: String,
|
|
||||||
inFontSize: Int,
|
|
||||||
inFontVariations: List<FontVariation.Setting>? = null,
|
|
||||||
inStyle: Int = 0,
|
|
||||||
): CachedTexture? {
|
|
||||||
val vKey = TextureKey(inFontFamily, inText, inFontSize, variationsKey(inFontVariations), inStyle)
|
|
||||||
fTextureCache[vKey]?.let { return it }
|
|
||||||
|
|
||||||
val vRenderer = backend.renderer ?: return null
|
|
||||||
val vFont = getFont(inFontFamily, inFontSize, inFontVariations) ?: return null
|
|
||||||
val vTex = memScoped {
|
|
||||||
// White glyphs — tint applied per-blit via texture colour/alpha mod.
|
|
||||||
val vColor = alloc<SDL_Color>()
|
|
||||||
vColor.r = 255u
|
|
||||||
vColor.g = 255u
|
|
||||||
vColor.b = 255u
|
|
||||||
vColor.a = 255u
|
|
||||||
// Style set per call (handles are shared): synthetic italic shear +
|
|
||||||
// underline/strikethrough bake into the (cached) texture.
|
|
||||||
TTF_SetFontStyle(vFont.reinterpret(), inStyle.toUInt())
|
|
||||||
val vSurface = TTF_RenderText_Blended(
|
|
||||||
vFont.reinterpret(),
|
|
||||||
inText,
|
|
||||||
// 0 → strlen on the UTF-8 string (see measureWidth).
|
|
||||||
0u,
|
|
||||||
vColor.readValue(),
|
|
||||||
)
|
|
||||||
TTF_SetFontStyle(vFont.reinterpret(), 0u)
|
|
||||||
if (vSurface == null) return@memScoped null
|
|
||||||
// sdl3 and sdl3_ttf cinterops both declare SDL_Surface — they
|
|
||||||
// refer to the same C struct but Kotlin sees them as distinct
|
|
||||||
// types. Reinterpret to bridge, then convert to a known ARGB
|
|
||||||
// layout so we can gamma-boost the coverage before uploading.
|
|
||||||
val vBlended = vSurface.reinterpret<sdl3.SDL_Surface>()
|
|
||||||
val vArgb = SDL_ConvertSurface(vBlended, SDL_PIXELFORMAT_ARGB8888)
|
|
||||||
SDL_DestroySurface(vBlended)
|
|
||||||
if (vArgb == null) return@memScoped null
|
|
||||||
applyCoverageGamma(vArgb)
|
|
||||||
val vTexture = SDL_CreateTextureFromSurface(vRenderer.reinterpret(), vArgb)
|
|
||||||
SDL_DestroySurface(vArgb)
|
|
||||||
vTexture
|
|
||||||
} ?: return null
|
|
||||||
|
|
||||||
val vSize = memScoped {
|
|
||||||
val vW = alloc<FloatVar>()
|
|
||||||
val vH = alloc<FloatVar>()
|
|
||||||
SDL_GetTextureSize(vTex.reinterpret(), vW.ptr, vH.ptr)
|
|
||||||
vW.value.toInt() to vH.value.toInt()
|
|
||||||
}
|
|
||||||
val vCached = CachedTexture(vTex, vSize.first, vSize.second)
|
|
||||||
fTextureCache[vKey] = vCached
|
|
||||||
return vCached
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opens the font at PHYSICAL pixels (logical fontSize × DPR) so the
|
|
||||||
rasterised glyphs match the back buffer's resolution. The cache key
|
|
||||||
is (family, logical size) — setDpr clears the cache when DPR changes.
|
|
||||||
|
|
||||||
Default family (null) tries the bundled Roboto first, then common
|
|
||||||
system fonts. A non-null family looks up bytes in the IconFont
|
|
||||||
registry and opens those — falling back to null when not registered. */
|
|
||||||
private fun getFont(
|
|
||||||
inFamily: String?,
|
|
||||||
inSize: Int,
|
|
||||||
inVariations: List<FontVariation.Setting>? = null,
|
|
||||||
): COpaquePointer? {
|
|
||||||
val vKey = FontKey(inFamily, inSize, variationsKey(inVariations))
|
|
||||||
fFontCache[vKey]?.let { return it }
|
|
||||||
|
|
||||||
val vPhysicalSize = (inSize * fDpr).coerceAtLeast(1f)
|
|
||||||
|
|
||||||
// Open a fresh handle (each variation set gets its own, since axes are
|
|
||||||
// baked in), then apply the requested axes and cache it.
|
|
||||||
val vFont: COpaquePointer? =
|
|
||||||
if (inFamily == null) {
|
|
||||||
openFontFromBytes(null, ::defaultFontBytes, vPhysicalSize)
|
|
||||||
?: run {
|
|
||||||
var vSys: COpaquePointer? = null
|
|
||||||
for (path in listOf("C:\\Windows\\Fonts\\segoeui.ttf", "C:\\Windows\\Fonts\\arial.ttf")) {
|
|
||||||
val vOpened = TTF_OpenFont(path, vPhysicalSize)
|
|
||||||
if (vOpened != null) { vSys = vOpened; break }
|
|
||||||
}
|
|
||||||
if (vSys == null) println("Sdl3TextRenderer: no usable font found for size $inSize")
|
|
||||||
vSys
|
|
||||||
}
|
|
||||||
} else if (inFamily in fMissingFamilies) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val vBytes = IconFont.bytesFor(inFamily)
|
|
||||||
if (vBytes == null) {
|
|
||||||
println("Sdl3TextRenderer: IconFont '$inFamily' not registered")
|
|
||||||
fMissingFamilies += inFamily
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
openFontFromBytes(inFamily, { vBytes }, vPhysicalSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vFont != null) {
|
|
||||||
applyAxes(vFont, inVariations)
|
|
||||||
fFontCache[vKey] = vFont
|
|
||||||
}
|
|
||||||
return vFont
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun defaultFontBytes(): ByteArray? = loadComposeResourceBytes("font/NotoSans.ttf")
|
|
||||||
|
|
||||||
/** Lazily uploads a family's bytes into the native heap (once per family),
|
|
||||||
then opens a fresh TTF_Font on a new SDL_IOFromConstMem stream. Each
|
|
||||||
size of a family gets its own font handle but shares the same byte
|
|
||||||
buffer; the buffer lives until destroy(). */
|
|
||||||
private fun openFontFromBytes(
|
|
||||||
inFamily: String?,
|
|
||||||
inBytesProvider: () -> ByteArray?,
|
|
||||||
inPhysicalPt: Float,
|
|
||||||
): COpaquePointer? {
|
|
||||||
val vSlot = fFontMem.getOrPut(inFamily) {
|
|
||||||
val vBytes = inBytesProvider() ?: return null
|
|
||||||
if (vBytes.isEmpty()) return null
|
|
||||||
val vMem = nativeHeap.allocArray<ByteVar>(vBytes.size)
|
|
||||||
vBytes.usePinned { vPinned ->
|
|
||||||
platform.posix.memcpy(vMem, vPinned.addressOf(0), vBytes.size.convert())
|
|
||||||
}
|
|
||||||
vMem to vBytes.size
|
|
||||||
}
|
|
||||||
val vIo = SDL_IOFromConstMem(vSlot.first, vSlot.second.convert()) ?: return null
|
|
||||||
return TTF_OpenFontIO(vIo.reinterpret(), true, inPhysicalPt)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decodes the codepoint at the given char index, handling UTF-16
|
|
||||||
surrogate pairs for supplementary-plane characters. Material Symbols
|
|
||||||
icons are BMP so the surrogate path rarely fires, but Icon supports
|
|
||||||
arbitrary codepoints (codepointToString returns a surrogate pair for
|
|
||||||
supplementary). */
|
|
||||||
private fun String.codePointAtSafe(inIndex: Int): Int {
|
|
||||||
val vHigh = this[inIndex].code
|
|
||||||
if (vHigh in 0xD800..0xDBFF && inIndex + 1 < length) {
|
|
||||||
val vLow = this[inIndex + 1].code
|
|
||||||
if (vLow in 0xDC00..0xDFFF) {
|
|
||||||
return 0x10000 + ((vHigh - 0xD800) shl 10) + (vLow - 0xDC00)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return vHigh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.font.FontVariation
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: SdlDisplayList — captured draw commands for retained replay
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
The geo cache's storage (RENDERER.md §3). 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-365
@@ -1,365 +0,0 @@
|
|||||||
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.NativeFinishableCanvas
|
|
||||||
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 (the default)
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
/**
|
|
||||||
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.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
// Texture cache for a ROUNDED / GENERIC content-clip leaf. The geo replay clips
|
|
||||||
// only to a rect, so a shape-clipped subtree can't ride the geo fast path — it
|
|
||||||
// would otherwise block-replay AND re-realize its offscreen rounded mask every
|
|
||||||
// frame (the dominant apidemo cost). Instead we bake the clipped content into a
|
|
||||||
// layer-local texture ONCE (record()) and blit it under the transform (drawInto),
|
|
||||||
// exactly like SdlRenderNode but scoped to the case geo can't cache. A scroll is
|
|
||||||
// translation-only, never re-records, so it just blits.
|
|
||||||
private var shapeBitmap: ImageBitmap? = null
|
|
||||||
private var shapeBitmapW = 0
|
|
||||||
private var shapeBitmapH = 0
|
|
||||||
private var shapeCached = false
|
|
||||||
|
|
||||||
// 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
|
|
||||||
shapeCached = false
|
|
||||||
|
|
||||||
val w = size.width
|
|
||||||
val h = size.height
|
|
||||||
val renderer = offscreenRenderer as? Sdl3OffscreenRenderer
|
|
||||||
if (deferMode || w <= 0 || h <= 0 || renderer == null) return
|
|
||||||
|
|
||||||
// Rounded / generic CONTENT clip → texture-cache instead of block-replaying and
|
|
||||||
// re-realizing the mask every frame (see shapeBitmap). Everything else takes the
|
|
||||||
// geo-geometry cache below.
|
|
||||||
val outline = clipOutline
|
|
||||||
if (clip && (outline is Outline.Rounded || outline is Outline.Generic)) {
|
|
||||||
recordShapeClipTexture(w, h, block)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
releaseShapeBitmap()
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bake a rounded/generic-clip leaf's content into an offscreen texture once (the
|
|
||||||
// rounded mask realizes here, not every frame). Mirrors SdlRenderNode.record:
|
|
||||||
// watches for a child layer (→ defer, children composite live) and for an image
|
|
||||||
// blit (partial-alpha content doesn't round-trip bit-exact through the 8-bit
|
|
||||||
// premultiplied offscreen → defer). Leaves of shapes + text (the bulk of apidemo's
|
|
||||||
// rounded Surfaces) keep the texture and blit it thereafter.
|
|
||||||
private fun recordShapeClipTexture(w: Int, h: Int, block: DrawScope.() -> Unit) {
|
|
||||||
val renderer = offscreenRenderer ?: run { deferMode = true; return }
|
|
||||||
val bmp = ensureShapeBitmap(w, h) ?: run { deferMode = true; return }
|
|
||||||
val target = renderer.createCanvas(bmp) ?: run { deferMode = true; return }
|
|
||||||
sawChild = false
|
|
||||||
val imgBlitsBefore = DrawStats.imageBlits
|
|
||||||
fRecordingStack.add(this)
|
|
||||||
try {
|
|
||||||
// Bake the clip into the texture so the rounded corners are transparent in
|
|
||||||
// the cached pixels; content records at layer-local origin (transform is
|
|
||||||
// applied at blit). The clip MUST be pushed inside an explicit save frame:
|
|
||||||
// a rounded clip realizes as an offscreen mask that only the enclosing
|
|
||||||
// frame's restore() composites back — pushed at stack depth 0 it belongs
|
|
||||||
// to no frame, nothing composites it, and the bake stays TRANSPARENT
|
|
||||||
// (every rounded Surface/chip/icon-button blitted empty).
|
|
||||||
target.save()
|
|
||||||
applyClip(target)
|
|
||||||
drawScope.draw(recordedDensity, recordedLayoutDirection, target, Size(w.toFloat(), h.toFloat()), block)
|
|
||||||
target.restore()
|
|
||||||
} finally {
|
|
||||||
fRecordingStack.removeAt(fRecordingStack.size - 1)
|
|
||||||
(target as? NativeFinishableCanvas)?.finish()
|
|
||||||
}
|
|
||||||
if (sawChild || DrawStats.imageBlits > imgBlitsBefore) {
|
|
||||||
deferMode = true
|
|
||||||
releaseShapeBitmap()
|
|
||||||
} else {
|
|
||||||
shapeCached = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureShapeBitmap(w: Int, h: Int): ImageBitmap? {
|
|
||||||
val renderer = offscreenRenderer ?: return null
|
|
||||||
if (shapeBitmap == null || shapeBitmapW != w || shapeBitmapH != h) {
|
|
||||||
releaseShapeBitmap()
|
|
||||||
shapeBitmap = renderer.createImageBitmap(w, h, ImageBitmapConfig.Argb8888, true, ColorSpaces.Srgb)
|
|
||||||
shapeBitmapW = w
|
|
||||||
shapeBitmapH = h
|
|
||||||
}
|
|
||||||
return shapeBitmap
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseShapeBitmap() {
|
|
||||||
(shapeBitmap as? SdlImageBitmap)?.close()
|
|
||||||
shapeBitmap = null
|
|
||||||
shapeBitmapW = 0
|
|
||||||
shapeBitmapH = 0
|
|
||||||
shapeCached = false
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
// Fully transparent layer → skip drawing entirely (upstream skiko skips
|
|
||||||
// invisible layers too). Also keeps alpha(0)-hidden hover controls from
|
|
||||||
// forcing the enclosing rounded clip to realize its offscreen mask. Must
|
|
||||||
// stay AFTER the recording-stack flag: a parent baking around an invisible
|
|
||||||
// child must still learn it is a non-leaf, or its cache goes stale when
|
|
||||||
// the child fades in.
|
|
||||||
if (alpha <= 0.003f) 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shape-clip leaf cached as a texture: blit it (the baked-in rounded mask means
|
|
||||||
// no per-frame realization). Translation rides the affine, so a scroll just
|
|
||||||
// blits. Scale/rotation would bilinear-resample the fixed-resolution texture,
|
|
||||||
// and alpha/blend/colorFilter/renderEffect need real offscreen compositing, so
|
|
||||||
// those fall through to the exact block-replay below (which re-realizes the
|
|
||||||
// mask, as before) — the cache is strictly non-regressing.
|
|
||||||
val shapeBmp = shapeBitmap
|
|
||||||
if (shapeCached && shapeBmp != null && w > 0f && h > 0f) {
|
|
||||||
val hasScaleOrRotation = scaleX != 1f || scaleY != 1f ||
|
|
||||||
rotationZ != 0f || rotationX != 0f || rotationY != 0f
|
|
||||||
val blitComposites = alpha < 1f || blendMode != BlendMode.SrcOver ||
|
|
||||||
colorFilter != null || renderEffect != null
|
|
||||||
if (!hasScaleOrRotation && !blitComposites) {
|
|
||||||
canvas.drawImageRect(
|
|
||||||
image = shapeBmp,
|
|
||||||
srcOffset = IntOffset.Zero, srcSize = IntSize(shapeBitmapW, shapeBitmapH),
|
|
||||||
dstOffset = IntOffset.Zero, dstSize = IntSize(shapeBitmapW, shapeBitmapH),
|
|
||||||
paint = Paint(),
|
|
||||||
)
|
|
||||||
canvas.restore()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
releaseShapeBitmap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-302
@@ -1,302 +0,0 @@
|
|||||||
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 texture-caching node (CDN_LAYERCACHE=texture — a fallback; the geo
|
|
||||||
display-list node SdlDisplayListRenderNode is the default, see RENDERER.md §3).
|
|
||||||
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).
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
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 SdlDisplayListRenderNode's
|
|
||||||
// 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. Push the clip inside an explicit save
|
|
||||||
// frame: a rounded clip realizes as an offscreen mask that only the
|
|
||||||
// enclosing frame's restore() composites — pushed at depth 0 it is never
|
|
||||||
// composited and the bake stays transparent (see SdlDisplayListRenderNode).
|
|
||||||
target.save()
|
|
||||||
if (clip) applyClip(target)
|
|
||||||
drawScope.draw(recordedDensity, recordedLayoutDirection, target, Size(w.toFloat(), h.toFloat()), block)
|
|
||||||
target.restore()
|
|
||||||
} 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
|
|
||||||
|
|
||||||
// Fully transparent layer → skip drawing entirely (see SdlDisplayListRenderNode).
|
|
||||||
if (alpha <= 0.003f) return
|
|
||||||
|
|
||||||
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>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-44
@@ -103,7 +103,6 @@ var Canvas.alphaMultiplier: Float
|
|||||||
internal class SkiaBackedCanvas(
|
internal class SkiaBackedCanvas(
|
||||||
internal val internalSkiaCanvas: SkCanvas,
|
internal val internalSkiaCanvas: SkCanvas,
|
||||||
) : Canvas,
|
) : Canvas,
|
||||||
com.compose.sdl.text.NativeTextCanvas,
|
|
||||||
com.compose.sdl.graphics.NativePainterCanvas,
|
com.compose.sdl.graphics.NativePainterCanvas,
|
||||||
com.compose.sdl.graphics.NativeShadowCanvas,
|
com.compose.sdl.graphics.NativeShadowCanvas,
|
||||||
com.compose.sdl.graphics.NativeFinishableCanvas {
|
com.compose.sdl.graphics.NativeFinishableCanvas {
|
||||||
@@ -497,49 +496,6 @@ internal class SkiaBackedCanvas(
|
|||||||
|
|
||||||
override fun finish() { /* Skia save/restore is balanced per call */ }
|
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(
|
override fun drawNativePainter(
|
||||||
inResourcePath: String,
|
inResourcePath: String,
|
||||||
inKind: com.compose.sdl.res.ResourceKind,
|
inKind: com.compose.sdl.res.ResourceKind,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ actual fun rendererPreferredGpuMode(): GpuMode {
|
|||||||
return when (Platform.osFamily) {
|
return when (Platform.osFamily) {
|
||||||
OsFamily.MACOSX -> GpuMode.Skia.Metal
|
OsFamily.MACOSX -> GpuMode.Skia.Metal
|
||||||
OsFamily.LINUX -> GpuMode.Skia.OpenGL
|
OsFamily.LINUX -> GpuMode.Skia.OpenGL
|
||||||
else -> GpuMode.Skia.OpenGL
|
// Windows (mingwX64, Route 1a): GPU via Skia's GL backend on an SDL WGL
|
||||||
|
// context (SkiaGLBridge, shared with Linux). CPU raster stays available
|
||||||
|
// as --gpu=software. (A native D3D12 bridge is the milestone-2 upgrade.)
|
||||||
|
OsFamily.WINDOWS -> GpuMode.Skia.OpenGL
|
||||||
|
else -> GpuMode.Software
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -9,13 +9,9 @@ import com.compose.sdl.renderer.skia.SkiaRenderBackend
|
|||||||
/** Public factory the :window module calls (its per-target makeRenderBackend
|
/** Public factory the :window module calls (its per-target makeRenderBackend
|
||||||
actual delegates here when Skia is the selected renderer). Mirrors the
|
actual delegates here when Skia is the selected renderer). Mirrors the
|
||||||
SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same
|
SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same
|
||||||
package; exactly one renderer module is included per target. Rejects
|
package; exactly one renderer module is included per target. */
|
||||||
Sdl3.* since this module has no SDL3_ttf / SDL3_image. */
|
|
||||||
actual 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
|
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||||
if (vResolved is GpuMode.Sdl3) {
|
|
||||||
error("Sdl3.* modes aren't available in a Skia build — rerun with -Prenderer=sdl3")
|
|
||||||
}
|
|
||||||
return try {
|
return try {
|
||||||
SkiaRenderBackend(inSdl, vResolved)
|
SkiaRenderBackend(inSdl, vResolved)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.compose.sdl.renderer.skia
|
||||||
|
|
||||||
|
import com.compose.sdl.loadComposeResourceBytes
|
||||||
|
import kotlinx.cinterop.ExperimentalForeignApi
|
||||||
|
import kotlinx.cinterop.addressOf
|
||||||
|
import kotlinx.cinterop.convert
|
||||||
|
import kotlinx.cinterop.usePinned
|
||||||
|
import org.jetbrains.skia.Color
|
||||||
|
import org.jetbrains.skia.Data
|
||||||
|
import org.jetbrains.skia.EncodedImageFormat
|
||||||
|
import org.jetbrains.skia.FontMgr
|
||||||
|
import org.jetbrains.skia.Surface
|
||||||
|
import org.jetbrains.skia.Typeface
|
||||||
|
import org.jetbrains.skia.paragraph.FontCollection
|
||||||
|
import org.jetbrains.skia.paragraph.ParagraphBuilder
|
||||||
|
import org.jetbrains.skia.paragraph.ParagraphStyle
|
||||||
|
import org.jetbrains.skia.paragraph.TextStyle
|
||||||
|
import org.jetbrains.skia.paragraph.TypefaceFontProvider
|
||||||
|
import platform.posix.fclose
|
||||||
|
import platform.posix.fopen
|
||||||
|
import platform.posix.fwrite
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: B6.3 spike — upstream skiko Paragraph on the mingw fork
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** DE-RISK SPIKE (throwaway). Drives the upstream skiko paragraph stack
|
||||||
|
* (org.jetbrains.skia.paragraph.ParagraphBuilder / FontCollection /
|
||||||
|
* TypefaceFontProvider + HarfBuzz shaping) end-to-end, WITHOUT touching the
|
||||||
|
* port's hand-rolled SdlParagraph engine. The only unproven piece on the
|
||||||
|
* mingwX64 fork DLL — skiko Font/Typeface already work today. If this renders
|
||||||
|
* legible text, the full B6.3 migration is unblocked on Windows.
|
||||||
|
*
|
||||||
|
* Registers the bundled data.kres NotoSans as the "Noto Sans" family (exactly
|
||||||
|
* the generic-family alias upstream's FontCache uses on the Linux platform
|
||||||
|
* mapping), so it also proves the family-resolution half of the bridge.
|
||||||
|
*
|
||||||
|
* Returns a short diagnostic string; writes a PNG to [outPath]. */
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
fun paragraphSpike(outPath: String): String {
|
||||||
|
val width = 640
|
||||||
|
val height = 200
|
||||||
|
|
||||||
|
val fontBytes = loadComposeResourceBytes("font/NotoSans.ttf")
|
||||||
|
?: return "paraspike: FAILED — could not load font/NotoSans.ttf from data.kres"
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Font collection seeded with the bundled typeface under a family alias
|
||||||
|
val fontMgr = FontMgr.default
|
||||||
|
val typeface = fontMgr.makeFromData(Data.makeFromBytes(fontBytes), 0)
|
||||||
|
?: return "paraspike: FAILED — makeFromData returned null"
|
||||||
|
val provider = TypefaceFontProvider().apply { registerTypeface(typeface, "Noto Sans") }
|
||||||
|
val fonts = FontCollection().apply {
|
||||||
|
setDefaultFontManager(fontMgr)
|
||||||
|
setAssetFontManager(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Build + lay out a paragraph via HarfBuzz shaping
|
||||||
|
val textStyle = TextStyle().apply {
|
||||||
|
color = Color.BLACK
|
||||||
|
fontSize = 42f
|
||||||
|
fontFamilies = arrayOf("Noto Sans")
|
||||||
|
}
|
||||||
|
val builder = ParagraphBuilder(ParagraphStyle(), fonts)
|
||||||
|
builder.pushStyle(textStyle)
|
||||||
|
builder.addText("Hello 123 Åé — wğ")
|
||||||
|
builder.popStyle()
|
||||||
|
val paragraph = builder.build()
|
||||||
|
paragraph.layout(width.toFloat())
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Rasterise to a white surface + snapshot PNG
|
||||||
|
val surface = Surface.makeRasterN32Premul(width, height)
|
||||||
|
surface.canvas.clear(Color.WHITE)
|
||||||
|
paragraph.paint(surface.canvas, 12f, 12f)
|
||||||
|
val png = surface.makeImageSnapshot().encodeToData(EncodedImageFormat.PNG)?.bytes
|
||||||
|
?: return "paraspike: FAILED — encodeToData(PNG) returned null"
|
||||||
|
|
||||||
|
writeBytesToFile(outPath, png)
|
||||||
|
return "paraspike: OK — font ${fontBytes.size}B, paragraph ${paragraph.maxWidth.toInt()}x${paragraph.height.toInt()} " +
|
||||||
|
"(longestLine=${paragraph.longestLine.toInt()}, lines=${paragraph.lineNumber}), wrote ${png.size}B PNG -> $outPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal binary file write via posix — the spike stands alone from the
|
||||||
|
* renderer's resource/screenshot plumbing. */
|
||||||
|
@OptIn(ExperimentalForeignApi::class)
|
||||||
|
private fun writeBytesToFile(path: String, bytes: ByteArray) {
|
||||||
|
if (bytes.isEmpty()) return
|
||||||
|
val handle = fopen(path, "wb") ?: return
|
||||||
|
try {
|
||||||
|
bytes.usePinned { pinned ->
|
||||||
|
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), handle)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose(handle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.compose.sdl.renderer.skia
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.font.FontVariation as ComposeFontVariation
|
||||||
|
import com.compose.sdl.icons.IconFont
|
||||||
|
import com.compose.sdl.loadComposeResourceBytes
|
||||||
|
import org.jetbrains.skia.Data
|
||||||
|
import org.jetbrains.skia.FontMgr
|
||||||
|
import org.jetbrains.skia.FontVariation as SkiaFontVariation
|
||||||
|
import org.jetbrains.skia.Typeface
|
||||||
|
import org.jetbrains.skia.paragraph.FontCollection
|
||||||
|
import org.jetbrains.skia.paragraph.TypefaceFontProvider
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: SkiaFonts — font-resolution bridge for the skiko paragraph engine
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the port's `(family-name, variable-axis)` font model into skiko
|
||||||
|
* [Typeface]s for the paragraph engine. Families come from
|
||||||
|
* [com.compose.sdl.text.NamedFont.projectFontName] (a bundled default, an
|
||||||
|
* [IconFont]-registered text/icon family, or `generic:*`); variable axes
|
||||||
|
* (Material Symbols `FILL`/`wght`/… and paragraph `FontWeight`→`wght`) are
|
||||||
|
* applied via [Typeface.makeClone].
|
||||||
|
*
|
||||||
|
* Every resolved typeface is registered in a [TypefaceFontProvider] under a
|
||||||
|
* UNIQUE alias, and [resolve] hands that alias back so the caller sets both
|
||||||
|
* `TextStyle.typeface` AND `TextStyle.fontFamilies = [alias]`. That matters:
|
||||||
|
* skiko's shaper maps codepoints→glyphs through `fontFamilies` (the
|
||||||
|
* FontCollection), so an icon font MUST be reachable by its alias — otherwise a
|
||||||
|
* bare `typeface` is ignored and private-use icon codepoints render as tofu.
|
||||||
|
* (Mirrors upstream FontCache: register alias + set fontFamilies + typeface.)
|
||||||
|
*/
|
||||||
|
internal object SkiaFonts {
|
||||||
|
private const val DEFAULT_ALIAS = "Noto Sans"
|
||||||
|
|
||||||
|
private val fontMgr = FontMgr.default
|
||||||
|
private val provider = TypefaceFontProvider()
|
||||||
|
|
||||||
|
/** Bundled default (NotoSans from data.kres); the fallback for every unresolved family. */
|
||||||
|
val defaultTypeface: Typeface? =
|
||||||
|
loadComposeResourceBytes("font/NotoSans.ttf")
|
||||||
|
?.let { fontMgr.makeFromData(Data.makeFromBytes(it), 0) }
|
||||||
|
?.also { provider.registerTypeface(it, DEFAULT_ALIAS) }
|
||||||
|
|
||||||
|
/** Shared collection: the alias provider first (bundled + icon + varied fonts),
|
||||||
|
then the system FontMgr for glyph fallback (CJK/emoji where present). */
|
||||||
|
val fontCollection: FontCollection = FontCollection().apply {
|
||||||
|
setDefaultFontManager(fontMgr)
|
||||||
|
setAssetFontManager(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// family name (null = default) -> base typeface
|
||||||
|
private val baseCache = mutableMapOf<String?, Typeface?>()
|
||||||
|
// resolve key -> (typeface, registered alias)
|
||||||
|
private val resolveCache = mutableMapOf<String, Pair<Typeface?, String>>()
|
||||||
|
private val registered = mutableSetOf(DEFAULT_ALIAS)
|
||||||
|
|
||||||
|
private fun baseTypeface(family: String?): Typeface? =
|
||||||
|
baseCache.getOrPut(family) {
|
||||||
|
val bytes = family?.let { IconFont.bytesFor(it) }
|
||||||
|
if (bytes != null) fontMgr.makeFromData(Data.makeFromBytes(bytes), 0) else defaultTypeface
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun variationsKey(variations: List<ComposeFontVariation.Setting>): String =
|
||||||
|
variations.sortedBy { it.axisName }.joinToString(",") { "${it.axisName}=${it.toVariationValue(null)}" }
|
||||||
|
|
||||||
|
/** Resolve a family + optional variable-axis settings to a concrete typeface AND
|
||||||
|
a provider alias (register-on-first-use). Set `fontFamilies = [alias]` and
|
||||||
|
`typeface = first` on the skiko TextStyle. */
|
||||||
|
fun resolve(family: String?, variations: List<ComposeFontVariation.Setting>?): Pair<Typeface?, String> {
|
||||||
|
if (family == null && variations.isNullOrEmpty()) return defaultTypeface to DEFAULT_ALIAS
|
||||||
|
val varKey = variations?.takeUnless { it.isEmpty() }?.let { variationsKey(it) } ?: ""
|
||||||
|
val key = "${family ?: ""}#$varKey"
|
||||||
|
return resolveCache.getOrPut(key) {
|
||||||
|
val base = baseTypeface(family) ?: defaultTypeface
|
||||||
|
val typeface = if (varKey.isEmpty() || base == null) base
|
||||||
|
else runCatching {
|
||||||
|
base.makeClone(variations!!.map { SkiaFontVariation(it.axisName, it.toVariationValue(null)) }.toTypedArray())
|
||||||
|
}.getOrDefault(base)
|
||||||
|
val alias = if (family == null && varKey.isEmpty()) DEFAULT_ALIAS else "cdn-font:$key"
|
||||||
|
if (typeface != null && alias !in registered) {
|
||||||
|
provider.registerTypeface(typeface, alias)
|
||||||
|
registered += alias
|
||||||
|
}
|
||||||
|
typeface to alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-8
@@ -5,16 +5,13 @@ package com.compose.sdl.renderer.skia
|
|||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
/** The Skia leg draws through upstream's `SkiaBackedCanvas` (real gradients/paint/
|
/** The Skia leg draws through upstream's `SkiaBackedCanvas` (real gradients/paint/
|
||||||
shader), but keeps the port's text engine (`SkiaTextRenderer`, incl. the P3.1
|
shader). Text now goes through the skiko paragraph engine (Paragraph.paint), so
|
||||||
metric work) and resource-image cache (`SkiaImageCache`). `SkiaBackedCanvas` is
|
this drawer only holds the resource-image cache (`SkiaImageCache`) that
|
||||||
manual-vendored to implement the port `NativeTextCanvas`/`NativePainterCanvas`
|
`SkiaBackedCanvas`'s `NativePainterCanvas` contract forwards to. Set once by
|
||||||
contracts and forwards to this drawer, which holds the two long-lived renderers
|
`SkiaRenderBackend`; any `SkiaBackedCanvas` (frame, offscreen, or a GraphicsLayer
|
||||||
the way the old per-frame `SkiaCanvas` used to. Set once by `SkiaRenderBackend`;
|
recording — B6.2) can then draw images onto its own `internalSkiaCanvas`. */
|
||||||
any `SkiaBackedCanvas` (frame, offscreen, or a GraphicsLayer recording — B6.2)
|
|
||||||
can then draw text/images onto its own `internalSkiaCanvas`. */
|
|
||||||
internal var skiaLeafDrawer: SkiaLeafDrawer? = null
|
internal var skiaLeafDrawer: SkiaLeafDrawer? = null
|
||||||
|
|
||||||
internal class SkiaLeafDrawer(
|
internal class SkiaLeafDrawer(
|
||||||
val textRenderer: SkiaTextRenderer,
|
|
||||||
val imageCache: SkiaImageCache,
|
val imageCache: SkiaImageCache,
|
||||||
)
|
)
|
||||||
|
|||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
package androidx.compose.ui.text
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Canvas
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.skiaCanvas
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontVariation
|
||||||
|
import androidx.compose.ui.text.style.ResolvedTextDirection
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.isSpecified
|
||||||
|
import com.compose.sdl.renderer.skia.SkiaFonts
|
||||||
|
import com.compose.sdl.text.projectFontName
|
||||||
|
import com.compose.sdl.text.projectFontVariations
|
||||||
|
import org.jetbrains.skia.Font as SkFont
|
||||||
|
import org.jetbrains.skia.FontStyle as SkFontStyle
|
||||||
|
import org.jetbrains.skia.paragraph.Alignment as SkAlignment
|
||||||
|
import org.jetbrains.skia.paragraph.DecorationLineStyle as SkDecorationLineStyle
|
||||||
|
import org.jetbrains.skia.paragraph.DecorationStyle as SkDecorationStyle
|
||||||
|
import org.jetbrains.skia.paragraph.Direction as SkDirection
|
||||||
|
import org.jetbrains.skia.paragraph.Paragraph as SkParagraph
|
||||||
|
import org.jetbrains.skia.paragraph.ParagraphBuilder as SkParagraphBuilder
|
||||||
|
import org.jetbrains.skia.paragraph.ParagraphStyle
|
||||||
|
import org.jetbrains.skia.paragraph.RectHeightMode
|
||||||
|
import org.jetbrains.skia.paragraph.RectWidthMode
|
||||||
|
import org.jetbrains.skia.paragraph.Shadow as SkShadow
|
||||||
|
import org.jetbrains.skia.paragraph.TextStyle as SkTextStyle
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: SkiaParagraphOps — skiko skparagraph impl behind the nativeMain seam
|
||||||
|
// ==================
|
||||||
|
//
|
||||||
|
// Lives in the skiko source set (skiko on classpath) and implements the
|
||||||
|
// skiko-free [NativeParagraphOps] the nativeMain [SkiaParagraph] drives. Builds
|
||||||
|
// a skiko `org.jetbrains.skia.paragraph` (HarfBuzz shaping + skunicode bidi +
|
||||||
|
// FontCollection fallback) from Compose style/spans and exposes plain-typed
|
||||||
|
// queries. Compose style/span -> skiko conversion is a reduced local version of
|
||||||
|
// upstream ParagraphBuilder.skiko.kt, keeping the port's family/variable-axis
|
||||||
|
// font model (SkiaFonts) so icons / monospace / custom fonts are unchanged.
|
||||||
|
|
||||||
|
private const val INTRINSIC_WIDTH = 100_000f
|
||||||
|
|
||||||
|
internal class SkiaParagraphOps(
|
||||||
|
private val text: String,
|
||||||
|
private val style: TextStyle,
|
||||||
|
private val widthConstraint: Float,
|
||||||
|
private val maxLines: Int,
|
||||||
|
private val density: Float,
|
||||||
|
private val spanStyles: List<AnnotatedString.Range<SpanStyle>>,
|
||||||
|
private val ellipsize: Boolean,
|
||||||
|
) : NativeParagraphOps {
|
||||||
|
|
||||||
|
private val fontPx: Float =
|
||||||
|
((if (style.fontSize.isSpecified) style.fontSize.value else 14f) * density).coerceAtLeast(1f)
|
||||||
|
private val baseFamily: String? = style.fontFamily.projectFontName()
|
||||||
|
// Icon families (Material Symbols) carry explicit axes on the family; ordinary
|
||||||
|
// text derives only the wght axis from the paragraph FontWeight.
|
||||||
|
private val baseVariations: List<FontVariation.Setting>? =
|
||||||
|
style.fontFamily.projectFontVariations()
|
||||||
|
?: style.fontWeight?.let { listOf(FontVariation.weight(it.weight)) }
|
||||||
|
private val baseTypeface = SkiaFonts.resolve(baseFamily, baseVariations).first
|
||||||
|
private val defaultFont = SkFont(baseTypeface ?: SkiaFonts.defaultTypeface, fontPx)
|
||||||
|
private val layoutWidth: Float =
|
||||||
|
if (widthConstraint.isFinite() && widthConstraint > 0f) widthConstraint else INTRINSIC_WIDTH
|
||||||
|
|
||||||
|
private var paragraph: SkParagraph = build(style.color, style.shadow, style.textDecoration)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// NativeParagraphOps
|
||||||
|
|
||||||
|
override val height: Float get() = paragraph.height
|
||||||
|
override val lineNumber: Int get() = paragraph.lineNumber
|
||||||
|
override val minIntrinsicWidth: Float get() = paragraph.minIntrinsicWidth
|
||||||
|
override val maxIntrinsicWidth: Float get() = paragraph.maxIntrinsicWidth
|
||||||
|
override val didExceedMaxLines: Boolean get() = paragraph.didExceedMaxLines()
|
||||||
|
override val alphabeticBaseline: Float get() = paragraph.alphabeticBaseline
|
||||||
|
override val defaultAscentPx: Float get() = -defaultFont.metrics.ascent
|
||||||
|
override val defaultDescentPx: Float get() = defaultFont.metrics.descent
|
||||||
|
|
||||||
|
override fun lineMetrics(): List<LineMetricData> {
|
||||||
|
val metrics = if (text.isEmpty()) emptyList() else paragraph.lineMetrics.toList()
|
||||||
|
if (metrics.isEmpty()) {
|
||||||
|
val a = -defaultFont.metrics.ascent.toDouble()
|
||||||
|
val d = defaultFont.metrics.descent.toDouble()
|
||||||
|
return listOf(
|
||||||
|
LineMetricData(0, 0, 0, 0, true, a, d, paragraph.alphabeticBaseline.toDouble().takeIf { it > 0 } ?: a,
|
||||||
|
0.0, 0.0, 0.0, a + d, 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return metrics.map {
|
||||||
|
LineMetricData(
|
||||||
|
startIndex = it.startIndex, endIndex = it.endIndex,
|
||||||
|
endExcludingWhitespaces = it.endExcludingWhitespaces, endIncludingNewline = it.endIncludingNewline,
|
||||||
|
isHardBreak = it.isHardBreak, ascent = it.ascent, descent = it.descent, baseline = it.baseline,
|
||||||
|
left = it.left, right = it.left + it.width, width = it.width, height = it.height,
|
||||||
|
lineNumber = it.lineNumber,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun placeholderRects(): List<RectData?> =
|
||||||
|
paragraph.rectsForPlaceholders.map { RectData(it.rect.left, it.rect.top, it.rect.right, it.rect.bottom) }
|
||||||
|
|
||||||
|
override fun getRectsForRange(start: Int, end: Int, useMaxHeight: Boolean): List<TextBoxData> =
|
||||||
|
paragraph.getRectsForRange(
|
||||||
|
start, end,
|
||||||
|
if (useMaxHeight) RectHeightMode.MAX else RectHeightMode.STRUT,
|
||||||
|
RectWidthMode.TIGHT,
|
||||||
|
).map { TextBoxData(it.rect.left, it.rect.top, it.rect.right, it.rect.bottom, it.direction == SkDirection.RTL) }
|
||||||
|
|
||||||
|
override fun glyphPositionAtCoordinate(x: Float, y: Float): Int =
|
||||||
|
paragraph.getGlyphPositionAtCoordinate(x, y).position
|
||||||
|
|
||||||
|
override fun wordBoundary(offset: Int): IntArray =
|
||||||
|
paragraph.getWordBoundary(offset).let { intArrayOf(it.start, it.end) }
|
||||||
|
|
||||||
|
override fun rebuildAndPaint(canvas: Canvas, color: Color, shadow: Shadow?, decoration: TextDecoration?) {
|
||||||
|
paragraph = build(color, shadow, decoration)
|
||||||
|
paragraph.paint(canvas.skiaCanvas, 0f, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Compose style/span -> skiko build
|
||||||
|
|
||||||
|
private fun build(color: Color, shadow: Shadow?, decoration: TextDecoration?): SkParagraph {
|
||||||
|
val baseStyle = makeTextStyle(baseFamily, baseVariations, fontPx, color, style.fontStyle, decoration, shadow)
|
||||||
|
val pStyle = ParagraphStyle().apply {
|
||||||
|
alignment = style.textAlign.toSkAlignment()
|
||||||
|
direction = if (style.textDirection == androidx.compose.ui.text.style.TextDirection.Rtl) SkDirection.RTL else SkDirection.LTR
|
||||||
|
textStyle = baseStyle
|
||||||
|
if (maxLines != Int.MAX_VALUE) {
|
||||||
|
maxLinesCount = maxLines
|
||||||
|
ellipsis = if (ellipsize) "…" else ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pb = SkParagraphBuilder(pStyle, SkiaFonts.fontCollection)
|
||||||
|
if (spanStyles.isEmpty()) {
|
||||||
|
pb.pushStyle(baseStyle)
|
||||||
|
pb.addText(text)
|
||||||
|
pb.popStyle()
|
||||||
|
} else {
|
||||||
|
appendWithSpans(pb, color, shadow, decoration)
|
||||||
|
}
|
||||||
|
return pb.build().also { it.layout(layoutWidth) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendWithSpans(pb: SkParagraphBuilder, color: Color, shadow: Shadow?, decoration: TextDecoration?) {
|
||||||
|
val points = buildList {
|
||||||
|
add(0); add(text.length)
|
||||||
|
spanStyles.forEach { add(it.start.coerceIn(0, text.length)); add(it.end.coerceIn(0, text.length)) }
|
||||||
|
}.distinct().sorted()
|
||||||
|
for (i in 0 until points.size - 1) {
|
||||||
|
val segStart = points[i]
|
||||||
|
val segEnd = points[i + 1]
|
||||||
|
if (segStart >= segEnd) continue
|
||||||
|
val active = spanStyles.filter { it.start <= segStart && it.end >= segEnd }
|
||||||
|
pb.pushStyle(segmentStyle(color, shadow, decoration, active))
|
||||||
|
pb.addText(text.substring(segStart, segEnd))
|
||||||
|
pb.popStyle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun segmentStyle(
|
||||||
|
color: Color, shadow: Shadow?, decoration: TextDecoration?,
|
||||||
|
active: List<AnnotatedString.Range<SpanStyle>>,
|
||||||
|
): SkTextStyle {
|
||||||
|
var family = baseFamily
|
||||||
|
var variations = baseVariations
|
||||||
|
var size = fontPx
|
||||||
|
var segColor = color
|
||||||
|
var fontStyle = style.fontStyle
|
||||||
|
var deco = decoration
|
||||||
|
active.forEach { range ->
|
||||||
|
val sp = range.item
|
||||||
|
if (sp.color.isSpecified) segColor = sp.color
|
||||||
|
sp.fontWeight?.let { variations = listOf(FontVariation.weight(it.weight)) }
|
||||||
|
if (sp.fontSize.isSpecified) size = (sp.fontSize.value * density).coerceAtLeast(1f)
|
||||||
|
sp.fontStyle?.let { fontStyle = it }
|
||||||
|
sp.textDecoration?.let { deco = it }
|
||||||
|
sp.fontFamily.projectFontName()?.let { family = it }
|
||||||
|
sp.fontFamily.projectFontVariations()?.let { variations = it }
|
||||||
|
}
|
||||||
|
return makeTextStyle(family, variations, size, segColor, fontStyle, deco, shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeTextStyle(
|
||||||
|
family: String?, variations: List<FontVariation.Setting>?, sizePx: Float,
|
||||||
|
color: Color, fontStyle: FontStyle?, decoration: TextDecoration?, shadow: Shadow?,
|
||||||
|
): SkTextStyle {
|
||||||
|
val ts = SkTextStyle()
|
||||||
|
val argb = (if (color.isSpecified) color else Color.Black).toArgb()
|
||||||
|
ts.color = argb
|
||||||
|
ts.fontSize = sizePx
|
||||||
|
// Register + resolve to a provider alias so skiko's shaper maps codepoints
|
||||||
|
// through the exact typeface (icon fonts would otherwise fall back to
|
||||||
|
// Noto Sans and render private-use glyphs as tofu).
|
||||||
|
val (tf, alias) = SkiaFonts.resolve(family, variations)
|
||||||
|
tf?.let { ts.typeface = it }
|
||||||
|
ts.fontFamilies = arrayOf(alias)
|
||||||
|
if (fontStyle == FontStyle.Italic) ts.fontStyle = SkFontStyle.ITALIC
|
||||||
|
decoration?.takeUnless { it == TextDecoration.None }?.let {
|
||||||
|
ts.decorationStyle = SkDecorationStyle(
|
||||||
|
it.contains(TextDecoration.Underline), false, it.contains(TextDecoration.LineThrough),
|
||||||
|
false, argb, SkDecorationLineStyle.SOLID, 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val letter = style.letterSpacing
|
||||||
|
if (letter.isSpecified) {
|
||||||
|
ts.letterSpacing = if (letter.isEm) sizePx * letter.value else letter.value * density
|
||||||
|
}
|
||||||
|
shadow?.takeUnless { it == Shadow.None }?.let {
|
||||||
|
ts.addShadow(SkShadow(it.color.toArgb(), it.offset.x, it.offset.y, it.blurRadius.toDouble()))
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TextAlign.toSkAlignment(): SkAlignment = when (this) {
|
||||||
|
TextAlign.Left -> SkAlignment.LEFT
|
||||||
|
TextAlign.Right -> SkAlignment.RIGHT
|
||||||
|
TextAlign.Center -> SkAlignment.CENTER
|
||||||
|
TextAlign.Justify -> SkAlignment.JUSTIFY
|
||||||
|
TextAlign.End -> SkAlignment.END
|
||||||
|
else -> SkAlignment.START
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: factory actuals (skikoRendererMain)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/** Actual for the nativeMain `expect fun buildParagraphOps`; runs in the skiko
|
||||||
|
* source set (compiled into both the official-skiko and fork siblings). */
|
||||||
|
internal actual fun buildParagraphOps(
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
width: Float,
|
||||||
|
maxLines: Int,
|
||||||
|
ellipsize: Boolean,
|
||||||
|
density: Float,
|
||||||
|
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
|
||||||
|
): NativeParagraphOps = SkiaParagraphOps(text, style, width, maxLines, density, spanStyles, ellipsize)
|
||||||
|
|
||||||
|
/** Actual for `expect fun paragraphIntrinsicWidths` — [min, max] from an
|
||||||
|
* unbounded layout. */
|
||||||
|
internal actual fun paragraphIntrinsicWidths(
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
density: Float,
|
||||||
|
spanStyles: List<AnnotatedString.Range<SpanStyle>>,
|
||||||
|
): FloatArray {
|
||||||
|
val ops = SkiaParagraphOps(text, style, Float.POSITIVE_INFINITY, Int.MAX_VALUE, density, spanStyles, false)
|
||||||
|
return floatArrayOf(ops.minIntrinsicWidth, ops.maxIntrinsicWidth)
|
||||||
|
}
|
||||||
+9
-18
@@ -3,7 +3,7 @@ package com.compose.sdl.renderer.skia
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import com.compose.sdl.res.ImageLoader
|
import com.compose.sdl.res.ImageLoader
|
||||||
import com.compose.sdl.res.ResourceKind
|
import com.compose.sdl.res.ResourceKind
|
||||||
import com.compose.sdl.text.TextMeasurer
|
import com.compose.sdl.text.TextRendererCapabilities
|
||||||
import com.compose.sdl.*
|
import com.compose.sdl.*
|
||||||
import androidx.compose.ui.graphics.asComposeCanvas
|
import androidx.compose.ui.graphics.asComposeCanvas
|
||||||
import org.jetbrains.skia.Canvas
|
import org.jetbrains.skia.Canvas
|
||||||
@@ -30,11 +30,14 @@ internal class SkiaRenderBackend(
|
|||||||
) : RenderBackend {
|
) : RenderBackend {
|
||||||
|
|
||||||
private val fBridge: SkiaBridge = buildBridge()
|
private val fBridge: SkiaBridge = buildBridge()
|
||||||
private val fSkiaTextRenderer = SkiaTextRenderer()
|
|
||||||
private val fSkiaImageCache = SkiaImageCache()
|
private val fSkiaImageCache = SkiaImageCache()
|
||||||
private var fCurrentCanvas: Canvas? = null
|
private var fCurrentCanvas: Canvas? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// skiko's paragraph engine always supports variable-font axes (Material
|
||||||
|
// Symbols FILL/wght/GRAD/opsz) — silence the icon capability warning.
|
||||||
|
TextRendererCapabilities.supportsFontVariations = true
|
||||||
|
|
||||||
// Encoded-image decode (painterResource / SVG in :components-resources).
|
// Encoded-image decode (painterResource / SVG in :components-resources).
|
||||||
// Registered at CONSTRUCTION, not first frame: the official resources
|
// Registered at CONSTRUCTION, not first frame: the official resources
|
||||||
// pipeline decodes during COMPOSITION, which runs before beginFrame —
|
// pipeline decodes during COMPOSITION, which runs before beginFrame —
|
||||||
@@ -50,13 +53,10 @@ internal class SkiaRenderBackend(
|
|||||||
// contracts (text/painter/shadow) forward to the port renderers via this global
|
// contracts (text/painter/shadow) forward to the port renderers via this global
|
||||||
// drawer. It is per-WINDOW state (each window has its own text renderer + image
|
// drawer. It is per-WINDOW state (each window has its own text renderer + image
|
||||||
// cache), so it must be re-pointed at THIS backend before every frame — exactly
|
// cache), so it must be re-pointed at THIS backend before every frame — exactly
|
||||||
// like ComposeWindow.installGlobals() does for currentTextMeasurer. Setting it
|
// Setting it once in the ctor left it dangling at a CLOSED window's (destroyed)
|
||||||
// once in the ctor left it dangling at a CLOSED window's (destroyed) renderer
|
// image cache after a multi-window teardown → crash on the surviving window's
|
||||||
// after a multi-window teardown → crash on the surviving window's next frame.
|
// next frame, so it is re-pointed at THIS backend before every frame.
|
||||||
private val fLeafDrawer = SkiaLeafDrawer(fSkiaTextRenderer, fSkiaImageCache)
|
private val fLeafDrawer = SkiaLeafDrawer(fSkiaImageCache)
|
||||||
|
|
||||||
override val textMeasurer: TextMeasurer
|
|
||||||
get() = fSkiaTextRenderer.textMeasurer
|
|
||||||
|
|
||||||
override val imageLoader: ImageLoader = object : ImageLoader {
|
override val imageLoader: ImageLoader = object : ImageLoader {
|
||||||
override fun intrinsicSize(inPath: String, inKind: ResourceKind): Size =
|
override fun intrinsicSize(inPath: String, inKind: ResourceKind): Size =
|
||||||
@@ -69,7 +69,6 @@ internal class SkiaRenderBackend(
|
|||||||
is GpuMode.Skia.Metal -> makeMetalBridge(sdl) ?: error("Skia.Metal isn't supported on this target")
|
is GpuMode.Skia.Metal -> makeMetalBridge(sdl) ?: error("Skia.Metal isn't supported on this target")
|
||||||
is GpuMode.Skia.OpenGL -> SkiaGLBridge(sdl).also { require(it.init()) { "Skia.OpenGL init failed" } }
|
is GpuMode.Skia.OpenGL -> SkiaGLBridge(sdl).also { require(it.init()) { "Skia.OpenGL init failed" } }
|
||||||
is GpuMode.Software -> SkiaSurfaceBridge(sdl)
|
is GpuMode.Software -> SkiaSurfaceBridge(sdl)
|
||||||
is GpuMode.Sdl3,
|
|
||||||
is GpuMode.Auto -> error("SkiaRenderBackend received non-Skia gpuMode $gpuMode")
|
is GpuMode.Auto -> error("SkiaRenderBackend received non-Skia gpuMode $gpuMode")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +83,6 @@ internal class SkiaRenderBackend(
|
|||||||
canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12))
|
canvas.clear(SkColor.makeARGB(0xFF, 0x12, 0x12, 0x12))
|
||||||
canvas.save()
|
canvas.save()
|
||||||
if (inDpr != 1f) canvas.scale(inDpr, inDpr)
|
if (inDpr != 1f) canvas.scale(inDpr, inDpr)
|
||||||
// Sp-valued span sizes resolve through resolveRunPx, which needs the
|
|
||||||
// same LocalDensity DPR the paragraph used to bake base fontPx (16sp *
|
|
||||||
// density=2 → 32px). ComposeWindow calls beginFrame(1f) — layout is
|
|
||||||
// already in physical pixels — so use backend.pixelDensity directly
|
|
||||||
// here or Sp spans render at logical-point sizes and look tiny next
|
|
||||||
// to their base text.
|
|
||||||
fSkiaTextRenderer.setDensity(sdl.pixelDensity)
|
|
||||||
fCurrentCanvas = canvas
|
fCurrentCanvas = canvas
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +112,6 @@ internal class SkiaRenderBackend(
|
|||||||
// so a stray draw before the next window's drawRoot can't hit freed renderers.
|
// so a stray draw before the next window's drawRoot can't hit freed renderers.
|
||||||
if (skiaLeafDrawer === fLeafDrawer) skiaLeafDrawer = null
|
if (skiaLeafDrawer === fLeafDrawer) skiaLeafDrawer = null
|
||||||
fSkiaImageCache.destroy()
|
fSkiaImageCache.destroy()
|
||||||
fSkiaTextRenderer.destroy()
|
|
||||||
fBridge.destroy()
|
fBridge.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-637
@@ -1,637 +0,0 @@
|
|||||||
package com.compose.sdl.renderer.skia
|
|
||||||
|
|
||||||
import com.compose.sdl.*
|
|
||||||
import com.compose.sdl.icons.IconFont
|
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
|
||||||
import com.compose.sdl.graphics.r8
|
|
||||||
import com.compose.sdl.graphics.g8
|
|
||||||
import com.compose.sdl.graphics.b8
|
|
||||||
import com.compose.sdl.graphics.a8
|
|
||||||
import com.compose.sdl.text.ColorRun
|
|
||||||
import com.compose.sdl.text.TextMeasurer
|
|
||||||
import com.compose.sdl.text.TextRendererCapabilities
|
|
||||||
import com.compose.sdl.text.WrappedText
|
|
||||||
import androidx.compose.ui.graphics.Color as ComposeColorAlias
|
|
||||||
import androidx.compose.ui.text.AnnotatedString.Range
|
|
||||||
import androidx.compose.ui.text.SpanStyle
|
|
||||||
import com.compose.sdl.text.lineColorRuns
|
|
||||||
import com.compose.sdl.text.resolveRunPx
|
|
||||||
import com.compose.sdl.text.runVariations
|
|
||||||
import com.compose.sdl.text.spansAffectMetrics
|
|
||||||
import com.compose.sdl.text.styledLineCellHeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import org.jetbrains.skia.Canvas
|
|
||||||
import org.jetbrains.skia.Color
|
|
||||||
import org.jetbrains.skia.Data
|
|
||||||
import org.jetbrains.skia.Font
|
|
||||||
import org.jetbrains.skia.FontMgr
|
|
||||||
import org.jetbrains.skia.FontStyle
|
|
||||||
import org.jetbrains.skia.Paint
|
|
||||||
import org.jetbrains.skia.Rect as SkRect
|
|
||||||
import org.jetbrains.skia.Typeface
|
|
||||||
import androidx.compose.ui.text.font.FontVariation as ComposeFontVariation
|
|
||||||
import org.jetbrains.skia.FontVariation as SkiaFontVariation
|
|
||||||
|
|
||||||
// ==================
|
|
||||||
// MARK: SkiaTextRenderer
|
|
||||||
// ==================
|
|
||||||
|
|
||||||
// Width-cache cap — cap-and-clear like the SDL renderer (Sdl3TextRenderer):
|
|
||||||
// wrap candidates over large bodies otherwise cache every partial-line
|
|
||||||
// substring for the whole session. Lookups stay a single hash op.
|
|
||||||
private const val kWidthCacheMax: Int = 16384
|
|
||||||
|
|
||||||
/** Replaces SDL3TextRenderer. Uses Skia for measurement + draw.
|
|
||||||
|
|
||||||
IMPORTANT: in Skiko 0.144.6, the typefaces returned by FontMgr.default
|
|
||||||
.matchFamilyStyle on macOS go through SkTypeface_Mac::onCharsToGlyphs,
|
|
||||||
which aborts inside sk_malloc_flags as soon as Font.measureText or
|
|
||||||
measureTextWidth is called. We work around it by loading the typeface
|
|
||||||
from a file we ship next to the binary — file-loaded typefaces don't
|
|
||||||
exercise that code path on the macOS Skiko build. The bundled font also
|
|
||||||
gives us consistent rendering across macOS / Linux. */
|
|
||||||
class SkiaTextRenderer {
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Publish capabilities so Material Symbols install() etc. can warn
|
|
||||||
// when the active renderer can't honour the axes they request.
|
|
||||||
TextRendererCapabilities.supportsFontVariations = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val fFontMgr: FontMgr = FontMgr.default
|
|
||||||
|
|
||||||
// Density used to resolve Sp-valued span sizes via resolveRunPx. The Skia
|
|
||||||
// canvas is drawn at 1:1 (beginFrame(1f)) so the base fontPx arrives already
|
|
||||||
// resolved; this only matters for Sp span sizes (Em spans multiply the base
|
|
||||||
// and don't consult density). Mirrors Sdl3TextRenderer.fDpr.
|
|
||||||
private var fDensity: Float = 1f
|
|
||||||
internal val density: Float get() = fDensity
|
|
||||||
fun setDensity(inDensity: Float) { fDensity = inDensity }
|
|
||||||
|
|
||||||
private val fTypeface: Typeface? = pickTypeface()
|
|
||||||
|
|
||||||
/** Typeface key bundles family with a stable string representation of any
|
|
||||||
variable-font axis settings, so each variant gets its own makeClone'd
|
|
||||||
typeface in the cache. Null variations / null family resolve to the
|
|
||||||
default-font slot. */
|
|
||||||
private data class TypefaceKey(val family: String?, val variations: String)
|
|
||||||
|
|
||||||
private fun variationsKey(inV: List<ComposeFontVariation.Setting>?): String {
|
|
||||||
if (inV.isNullOrEmpty()) return ""
|
|
||||||
// Sort by tag so equivalent settings hash the same regardless of
|
|
||||||
// caller-supplied order.
|
|
||||||
return inV.sortedBy { it.axisName }.joinToString(",") { "${it.axisName}=${it.toVariationValue(null)}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Overrides the wght axis (or adds one) on top of caller-supplied variations,
|
|
||||||
so per-span FontWeight can be applied without discarding the base font's
|
|
||||||
other axes (e.g. a Material Symbols icon that already carries FILL/GRAD/opsz).
|
|
||||||
weight=400 short-circuits back to the caller list — the outer drawLine already
|
|
||||||
reuses the pre-loaded default font in that case. */
|
|
||||||
private fun withWeight(inV: List<ComposeFontVariation.Setting>?, inWeight: Int): List<ComposeFontVariation.Setting> {
|
|
||||||
val vOthers = inV?.filter { it.axisName != "wght" } ?: emptyList()
|
|
||||||
return vOthers + ComposeFontVariation.Setting("wght", inWeight.toFloat())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-family + variations typeface cache. Default font (key family=null,
|
|
||||||
// variations="") is the one resolved by pickTypeface(); other entries
|
|
||||||
// lazily resolve from IconFont.bytesFor() and Typeface.makeClone(...).
|
|
||||||
// A null value means lookup failed once — don't retry every frame.
|
|
||||||
private val fTypefaceCache = mutableMapOf<TypefaceKey, Typeface?>(TypefaceKey(null, "") to fTypeface)
|
|
||||||
|
|
||||||
// Per-(typefaceKey, fontSize) Skia Font cache (Font wraps a Typeface at a
|
|
||||||
// fixed size, glyph cache, hinting, etc.).
|
|
||||||
private val fFontCache = mutableMapOf<Pair<TypefaceKey, Int>, Font>()
|
|
||||||
|
|
||||||
// Cache of measured widths per (typefaceKey, text, fontSize).
|
|
||||||
private val fWidthCache = mutableMapOf<Triple<TypefaceKey, String, Int>, Int>()
|
|
||||||
|
|
||||||
/** WORKAROUND for Skiko 0.144.6 / macOS — both Font.measureText and
|
|
||||||
Font.measureTextWidth call into SkFont::measureText, which goes through
|
|
||||||
SkTypeface_Mac::onCharsToGlyphs and aborts inside sk_malloc_flags. Same
|
|
||||||
code path even for a file-loaded typeface, because SkFontMgr_Mac wraps
|
|
||||||
the file data in a CoreText-backed SkTypeface_Mac. Until Skiko is fixed
|
|
||||||
(or we get a FreeType-backed FontMgr) we estimate widths from the char
|
|
||||||
class. font.metrics is safe to read — it doesn't trigger glyph lookup. */
|
|
||||||
val textMeasurer: TextMeasurer = object : TextMeasurer {
|
|
||||||
override fun measure(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String?, inFontVariations: List<ComposeFontVariation.Setting>?): IntSize {
|
|
||||||
val vKey = TypefaceKey(inFontFamily, variationsKey(inFontVariations))
|
|
||||||
val vFont = getFont(vKey, inFontFamily, inFontVariations, inFontSize)
|
|
||||||
val vMetrics = vFont.metrics
|
|
||||||
val vLineHeight = (vMetrics.descent - vMetrics.ascent).toInt().coerceAtLeast(1)
|
|
||||||
val vWrap = wrap(inText, inFontSize, inMaxWidth, inFontFamily, inFontVariations)
|
|
||||||
val vWidth = if (vWrap.lines.isEmpty()) 0
|
|
||||||
else vWrap.lines.maxOf { estimateTextWidth(it, inFontSize, inFontFamily, inFontVariations) }
|
|
||||||
return IntSize(vWidth, vLineHeight * vWrap.lines.size.coerceAtLeast(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun wrap(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String?, inFontVariations: List<ComposeFontVariation.Setting>?): WrappedText =
|
|
||||||
wrapTextWithStarts(inText, inFontSize, inMaxWidth, inFontFamily, inFontVariations)
|
|
||||||
|
|
||||||
override fun lineHeight(inFontSize: Int, inFontFamily: String?, inFontVariations: List<ComposeFontVariation.Setting>?): Float {
|
|
||||||
val vKey = TypefaceKey(inFontFamily, variationsKey(inFontVariations))
|
|
||||||
val vMetrics = getFont(vKey, inFontFamily, inFontVariations, inFontSize).metrics
|
|
||||||
return (vMetrics.descent - vMetrics.ascent).coerceAtLeast(1f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun drawText(
|
|
||||||
inCanvas: Canvas,
|
|
||||||
inText: String,
|
|
||||||
inX: Float,
|
|
||||||
inY: Float,
|
|
||||||
inBoxWidth: Int,
|
|
||||||
inBoxHeight: Int,
|
|
||||||
inColor: ComposeColor,
|
|
||||||
inFontSize: Int,
|
|
||||||
inAlign: TextAlign = TextAlign.Start,
|
|
||||||
inSoftWrap: Boolean = true,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<ComposeFontVariation.Setting>? = null,
|
|
||||||
inSpans: List<Range<SpanStyle>>? = null,
|
|
||||||
// Pre-computed wrap (cached on the ProjectLayoutNode by the measure pass) so a
|
|
||||||
// huge body isn't re-wrapped here every frame. Null = wrap inline.
|
|
||||||
inWrapped: WrappedText? = null,
|
|
||||||
// Visible vertical band in canvas coords; lines outside it are skipped.
|
|
||||||
inViewTop: Float = Float.NEGATIVE_INFINITY,
|
|
||||||
inViewBottom: Float = Float.POSITIVE_INFINITY,
|
|
||||||
// Paragraph-level bits folded into every wrapped-line run — mirrors the
|
|
||||||
// SDL path so a TextStyle(fontStyle = Italic, textDecoration = Underline)
|
|
||||||
// still paints when there are no span styles.
|
|
||||||
inBaseItalic: Boolean = false,
|
|
||||||
inBaseUnderline: Boolean = false,
|
|
||||||
inBaseLineThrough: Boolean = false,
|
|
||||||
// The paragraph's per-line band (TextStyle.lineHeight) in px — stack lines at
|
|
||||||
// the same advance layout used. <= 0 → font-metrics height (legacy callers).
|
|
||||||
inLineHeightPx: Float = 0f,
|
|
||||||
// Compat-trim (raw styles): the first line keeps the tight font cell. False for
|
|
||||||
// M3's LineHeightStyle(Trim.None) — every line is the full band.
|
|
||||||
inTrimFirstLine: Boolean = true,
|
|
||||||
) {
|
|
||||||
val vKey = TypefaceKey(inFontFamily, variationsKey(inFontVariations))
|
|
||||||
val vBaseFont = getFont(vKey, inFontFamily, inFontVariations, inFontSize)
|
|
||||||
val vBaseMetrics = vBaseFont.metrics
|
|
||||||
// Per-mode stacking (mirrors SdlParagraph): compat trim keeps the FIRST line at
|
|
||||||
// the tight font cell; M3's Trim.None makes every line the full band. Following
|
|
||||||
// lines advance by the band (inLineHeightPx) when supplied, else the font cell.
|
|
||||||
val vFontCellH = (vBaseMetrics.descent - vBaseMetrics.ascent).coerceAtLeast(1f)
|
|
||||||
val vBaseLineHeight = if (inLineHeightPx > 0f) inLineHeightPx else vFontCellH
|
|
||||||
val vFirstLineH = if (inTrimFirstLine || inLineHeightPx <= 0f) vFontCellH else vBaseLineHeight
|
|
||||||
val vCapHeight = if (vBaseMetrics.capHeight > 0f) vBaseMetrics.capHeight
|
|
||||||
else inFontSize * 0.7f
|
|
||||||
|
|
||||||
// Same wrap algorithm as measureText so layout and rendering produce
|
|
||||||
// identical line breakdowns. softWrap = false (e.g. a singleLine field)
|
|
||||||
// stays one line and overflows the box width. Reuse the measure pass's
|
|
||||||
// cached wrap when supplied.
|
|
||||||
val vWrapWidth = if (inSoftWrap) inBoxWidth else Int.MAX_VALUE
|
|
||||||
val vWrapped = inWrapped ?: wrapTextWithStarts(inText, inFontSize, vWrapWidth, inFontFamily, inFontVariations)
|
|
||||||
val vLines = vWrapped.lines
|
|
||||||
// Base-only fast path: no per-span styling AND no paragraph decoration or
|
|
||||||
// italic to fold in — keep the original single-line cap-centring for the
|
|
||||||
// button case and uniform per-line stacking for multi-line.
|
|
||||||
val vHasSpans = inSpans != null
|
|
||||||
val vBaseOnly = !vHasSpans && !inBaseItalic && !inBaseUnderline && !inBaseLineThrough
|
|
||||||
if (vBaseOnly) {
|
|
||||||
val vFastPaint = Paint().apply { color = toSkiaColor(inColor); isAntiAlias = true }
|
|
||||||
if (vLines.size == 1 && '\n' !in inText) {
|
|
||||||
val vBaseline = inY + (inBoxHeight + vCapHeight) / 2f
|
|
||||||
val vPenX = alignX(inX, inBoxWidth, estimateTextWidth(vLines[0], inFontSize, inFontFamily, inFontVariations).toFloat(), inAlign)
|
|
||||||
inCanvas.drawString(expandTabs(vLines[0]), vPenX, vBaseline, vBaseFont, vFastPaint)
|
|
||||||
} else {
|
|
||||||
for ((vIdx, vLine) in vLines.withIndex()) {
|
|
||||||
val vSlotH = if (vIdx == 0) vFirstLineH else vBaseLineHeight
|
|
||||||
val vSlotTop = inY + (if (vIdx == 0) 0f else vFirstLineH + (vIdx - 1) * vBaseLineHeight)
|
|
||||||
if (vSlotTop + vSlotH < inViewTop || vSlotTop > inViewBottom) continue
|
|
||||||
val vBaseline = vSlotTop + (vSlotH + vCapHeight) / 2f
|
|
||||||
val vPenX = alignX(inX, inBoxWidth, estimateTextWidth(vLine, inFontSize, inFontFamily, inFontVariations).toFloat(), inAlign)
|
|
||||||
inCanvas.drawString(expandTabs(vLine), vPenX, vBaseline, vBaseFont, vFastPaint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vFastPaint.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-line size spans → the line's box height matches the tallest run
|
|
||||||
// cell (same styledLineCellHeight the paragraph measured with, so paint
|
|
||||||
// stacks lines exactly where layout put them). No metric-affecting spans →
|
|
||||||
// uniform base line height.
|
|
||||||
val vMetricSpans = spansAffectMetrics(inSpans)
|
|
||||||
var vLineY = inY
|
|
||||||
for ((vIdx, vLine) in vLines.withIndex()) {
|
|
||||||
val vLineStart = vWrapped.lineStarts.getOrElse(vIdx) { 0 }
|
|
||||||
val vLineH =
|
|
||||||
if (vMetricSpans && inSpans != null) {
|
|
||||||
kotlin.math.max(
|
|
||||||
styledLineCellHeight(
|
|
||||||
vLine, vLineStart, inSpans, inFontSize, fDensity,
|
|
||||||
textMeasurer, inFontFamily, inFontVariations,
|
|
||||||
),
|
|
||||||
if ((vIdx == 0 && inTrimFirstLine) || inLineHeightPx <= 0f) 0f else inLineHeightPx,
|
|
||||||
).coerceAtLeast(1f)
|
|
||||||
} else if (vIdx == 0) vFirstLineH else vBaseLineHeight
|
|
||||||
if (vLineY + vLineH < inViewTop || vLineY > inViewBottom) {
|
|
||||||
vLineY += vLineH
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
drawLineStyled(
|
|
||||||
inCanvas, vLine, vLineStart, inSpans, inColor, inFontSize,
|
|
||||||
inFontFamily, inFontVariations,
|
|
||||||
inBoxLeft = inX, inBoxTop = vLineY, inBoxWidth = inBoxWidth, inBoxHeight = vLineH,
|
|
||||||
inAlign = inAlign,
|
|
||||||
inBaseFont = vBaseFont, inBaseCapHeight = vCapHeight,
|
|
||||||
inBaseItalic = inBaseItalic,
|
|
||||||
inBaseUnderline = inBaseUnderline,
|
|
||||||
inBaseLineThrough = inBaseLineThrough,
|
|
||||||
)
|
|
||||||
vLineY += vLineH
|
|
||||||
if (vLineY > inBoxHeight + inY) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Aligns a line's pen X inside the box by its natural line width. */
|
|
||||||
private fun alignX(inX: Float, inBoxWidth: Int, inLineWidth: Float, inAlign: TextAlign): Float =
|
|
||||||
when (inAlign) {
|
|
||||||
TextAlign.Start -> inX
|
|
||||||
TextAlign.Center -> inX + (inBoxWidth - inLineWidth) / 2f
|
|
||||||
TextAlign.End -> inX + inBoxWidth.toFloat() - inLineWidth
|
|
||||||
else -> inX
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Renders ONE wrapped line inside a per-line box, with per-run styling and a
|
|
||||||
common baseline. Mirrors Sdl3TextRenderer.drawText's spans path: styled
|
|
||||||
prefix advances, alignment width = sum of styled run advances, common
|
|
||||||
baseline centres the line's TALLEST run cell in the box, background rect
|
|
||||||
behind each run, underline / lineThrough rects from run metrics. Paragraph-
|
|
||||||
level italic / decoration fold in via lineColorRuns's base flags. */
|
|
||||||
private fun drawLineStyled(
|
|
||||||
inCanvas: Canvas,
|
|
||||||
inLine: String,
|
|
||||||
inLineStart: Int,
|
|
||||||
inSpans: List<Range<SpanStyle>>?,
|
|
||||||
inColor: ComposeColor,
|
|
||||||
inFontSize: Int,
|
|
||||||
inFontFamily: String?,
|
|
||||||
inFontVariations: List<ComposeFontVariation.Setting>?,
|
|
||||||
inBoxLeft: Float,
|
|
||||||
inBoxTop: Float,
|
|
||||||
inBoxWidth: Int,
|
|
||||||
inBoxHeight: Float,
|
|
||||||
inAlign: TextAlign,
|
|
||||||
inBaseFont: Font,
|
|
||||||
inBaseCapHeight: Float,
|
|
||||||
inBaseItalic: Boolean,
|
|
||||||
inBaseUnderline: Boolean,
|
|
||||||
inBaseLineThrough: Boolean,
|
|
||||||
) {
|
|
||||||
// Build style runs. inSpans is null when only paragraph-level base flags
|
|
||||||
// affect painting — a single run covers the whole line with the base flags
|
|
||||||
// folded in via lineColorRuns's inBase* args.
|
|
||||||
val vRuns: List<ColorRun> = lineColorRuns(
|
|
||||||
inLine, inLineStart, inSpans ?: emptyList(), inColor,
|
|
||||||
inBaseItalic = inBaseItalic,
|
|
||||||
inBaseUnderline = inBaseUnderline,
|
|
||||||
inBaseLineThrough = inBaseLineThrough,
|
|
||||||
)
|
|
||||||
if (vRuns.isEmpty()) return
|
|
||||||
|
|
||||||
// Per-run resolutions: run pixel size, run font (weight/variations), run
|
|
||||||
// advance measured at ITS style. Fresh instances retained across the
|
|
||||||
// background/paint/underline passes so we don't measure or resolve the
|
|
||||||
// same run multiple times.
|
|
||||||
val vN = vRuns.size
|
|
||||||
val vRunFonts = arrayOfNulls<Font>(vN)
|
|
||||||
val vRunPx = IntArray(vN)
|
|
||||||
val vRunAdvances = FloatArray(vN)
|
|
||||||
for (i in 0 until vN) {
|
|
||||||
val vRun = vRuns[i]
|
|
||||||
val vPx = resolveRunPx(vRun, inFontSize, fDensity)
|
|
||||||
vRunPx[i] = vPx
|
|
||||||
val vVars = runVariations(vRun, inFontVariations)
|
|
||||||
val vRunFont = if (vRun.weight == 400 && vPx == inFontSize && vVars === inFontVariations) inBaseFont
|
|
||||||
else {
|
|
||||||
val vRunKey = TypefaceKey(inFontFamily, variationsKey(vVars))
|
|
||||||
getFont(vRunKey, inFontFamily, vVars, vPx)
|
|
||||||
}
|
|
||||||
vRunFonts[i] = vRunFont
|
|
||||||
vRunAdvances[i] = estimateTextWidth(
|
|
||||||
inLine.substring(vRun.start, vRun.end),
|
|
||||||
vPx, inFontFamily, vVars,
|
|
||||||
).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alignment width = sum of styled advances (a bold/resized run pushes
|
|
||||||
// the total right by its own amount).
|
|
||||||
var vLineW = 0f
|
|
||||||
for (i in 0 until vN) vLineW += vRunAdvances[i]
|
|
||||||
val vPenX0 = alignX(inBoxLeft, inBoxWidth, vLineW, inAlign)
|
|
||||||
|
|
||||||
// Common baseline: centre the line's TALLEST run cell in the box, then
|
|
||||||
// sit every run's baseline on it. Uses -metrics.ascent (Skia ascent is
|
|
||||||
// negative) for the run's cap-top-to-baseline distance. Without this a
|
|
||||||
// bigger/smaller run centres its own texture and floats off the baseline.
|
|
||||||
var vMaxCellH = inBaseFont.metrics.let { (it.descent - it.ascent).coerceAtLeast(1f) }
|
|
||||||
var vMaxAscent = -inBaseFont.metrics.ascent
|
|
||||||
for (i in 0 until vN) {
|
|
||||||
val vFontI = vRunFonts[i] ?: continue
|
|
||||||
val vM = vFontI.metrics
|
|
||||||
val vCell = (vM.descent - vM.ascent).coerceAtLeast(1f)
|
|
||||||
if (vCell > vMaxCellH) vMaxCellH = vCell
|
|
||||||
val vAsc = -vM.ascent
|
|
||||||
if (vAsc > vMaxAscent) vMaxAscent = vAsc
|
|
||||||
}
|
|
||||||
val vBaselineY = inBoxTop + (inBoxHeight - vMaxCellH) / 2f + vMaxAscent
|
|
||||||
|
|
||||||
// 1) Backgrounds first — fill each run's slice of the line band before
|
|
||||||
// the glyphs so they sit ON the background.
|
|
||||||
var vRunX = vPenX0
|
|
||||||
for (i in 0 until vN) {
|
|
||||||
val vRun = vRuns[i]
|
|
||||||
val vAdv = vRunAdvances[i]
|
|
||||||
if (vRun.background != ComposeColorAlias.Unspecified && vRun.background.alpha > 0f) {
|
|
||||||
val vBgPaint = Paint().apply { color = toSkiaColor(vRun.background); isAntiAlias = false }
|
|
||||||
inCanvas.drawRect(
|
|
||||||
SkRect.makeXYWH(vRunX, inBoxTop, vAdv, inBoxHeight),
|
|
||||||
vBgPaint,
|
|
||||||
)
|
|
||||||
vBgPaint.close()
|
|
||||||
}
|
|
||||||
vRunX += vAdv
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Glyphs — walk runs L→R, advancing by each run's styled advance so
|
|
||||||
// a bold/resized run pushes the following runs over by the right amount.
|
|
||||||
vRunX = vPenX0
|
|
||||||
for (i in 0 until vN) {
|
|
||||||
val vRun = vRuns[i]
|
|
||||||
val vFont = vRunFonts[i] ?: continue
|
|
||||||
val vAdv = vRunAdvances[i]
|
|
||||||
val vSeg = expandTabs(inLine.substring(vRun.start, vRun.end))
|
|
||||||
val vSegPaint = Paint().apply { color = toSkiaColor(vRun.color); isAntiAlias = true }
|
|
||||||
// Faux italic via skewX. Reset after so the cached Font handle
|
|
||||||
// is reusable by non-italic runs on the next line.
|
|
||||||
val vHadSkew = vFont.skewX
|
|
||||||
if (vRun.italic) vFont.skewX = -0.2f
|
|
||||||
inCanvas.drawString(vSeg, vRunX, vBaselineY, vFont, vSegPaint)
|
|
||||||
if (vRun.italic) vFont.skewX = vHadSkew
|
|
||||||
vSegPaint.close()
|
|
||||||
vRunX += vAdv
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Underline / strikethrough — draw 1-2px rects from run metrics AFTER
|
|
||||||
// the glyphs (so a heavier run's stems don't paint over the line). Skia
|
|
||||||
// reports underlinePosition as distance from baseline to TOP of the
|
|
||||||
// stroke (positive downward); strikeoutPosition is negative (above the
|
|
||||||
// baseline, near x-height).
|
|
||||||
vRunX = vPenX0
|
|
||||||
for (i in 0 until vN) {
|
|
||||||
val vRun = vRuns[i]
|
|
||||||
val vFont = vRunFonts[i] ?: continue
|
|
||||||
val vAdv = vRunAdvances[i]
|
|
||||||
if (vRun.underline || vRun.lineThrough) {
|
|
||||||
val vM = vFont.metrics
|
|
||||||
val vDecoPaint = Paint().apply { color = toSkiaColor(vRun.color); isAntiAlias = false }
|
|
||||||
if (vRun.underline) {
|
|
||||||
val vThick = (vM.underlineThickness ?: (vRunPx[i] / 16f)).coerceAtLeast(1f)
|
|
||||||
val vPos = vM.underlinePosition ?: (vRunPx[i] * 0.08f)
|
|
||||||
inCanvas.drawRect(
|
|
||||||
SkRect.makeXYWH(vRunX, vBaselineY + vPos, vAdv, vThick),
|
|
||||||
vDecoPaint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (vRun.lineThrough) {
|
|
||||||
val vThick = (vM.strikeoutThickness ?: (vRunPx[i] / 16f)).coerceAtLeast(1f)
|
|
||||||
val vPos = vM.strikeoutPosition ?: (-vRunPx[i] * 0.28f)
|
|
||||||
inCanvas.drawRect(
|
|
||||||
SkRect.makeXYWH(vRunX, vBaselineY + vPos, vAdv, vThick),
|
|
||||||
vDecoPaint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
vDecoPaint.close()
|
|
||||||
}
|
|
||||||
vRunX += vAdv
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Greedy soft-wrap that also tracks each line's start index in the
|
|
||||||
ORIGINAL text. Hard lines (split on '\n') always start a new line,
|
|
||||||
and the '\n' is consumed between them. Long hard lines are split at
|
|
||||||
whitespace (the trailing whitespace stays attached to the preceding
|
|
||||||
word so original-text length is preserved per hard line). Impossibly
|
|
||||||
long words split mid-word. maxWidth >= half Int.MAX_VALUE = no wrap. */
|
|
||||||
private fun wrapTextWithStarts(inText: String, inFontSize: Int, inMaxWidth: Int, inFontFamily: String? = null, inFontVariations: List<ComposeFontVariation.Setting>? = null): WrappedText {
|
|
||||||
if (inText.isEmpty()) return WrappedText(listOf(""), intArrayOf(0))
|
|
||||||
val vLines = mutableListOf<String>()
|
|
||||||
val vStarts = mutableListOf<Int>()
|
|
||||||
val vUnbounded = inMaxWidth >= Int.MAX_VALUE / 2
|
|
||||||
|
|
||||||
var vHardStart = 0
|
|
||||||
while (vHardStart <= inText.length) {
|
|
||||||
val vNl = inText.indexOf('\n', vHardStart)
|
|
||||||
val vHardEnd = if (vNl < 0) inText.length else vNl
|
|
||||||
val vHard = inText.substring(vHardStart, vHardEnd)
|
|
||||||
|
|
||||||
if (vUnbounded || vHard.isEmpty() || estimateTextWidth(vHard, inFontSize, inFontFamily, inFontVariations) <= inMaxWidth) {
|
|
||||||
vLines.add(vHard)
|
|
||||||
vStarts.add(vHardStart)
|
|
||||||
} else {
|
|
||||||
wrapHardLine(vHard, inFontSize, inMaxWidth, vHardStart, vLines, vStarts, inFontFamily, inFontVariations)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vNl < 0) break
|
|
||||||
vHardStart = vNl + 1 // skip the consumed '\n'
|
|
||||||
}
|
|
||||||
return WrappedText(vLines, vStarts.toIntArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun wrapHardLine(
|
|
||||||
inLine: String,
|
|
||||||
inFontSize: Int,
|
|
||||||
inMaxWidth: Int,
|
|
||||||
inBaseOffset: Int,
|
|
||||||
outLines: MutableList<String>,
|
|
||||||
outStarts: MutableList<Int>,
|
|
||||||
inFontFamily: String? = null,
|
|
||||||
inFontVariations: List<ComposeFontVariation.Setting>? = null,
|
|
||||||
) {
|
|
||||||
var vCurrent = StringBuilder()
|
|
||||||
var vLineStartInHard = 0 // start of the current sub-line within inLine
|
|
||||||
var i = 0
|
|
||||||
while (i < inLine.length) {
|
|
||||||
val vWordStart = i
|
|
||||||
while (i < inLine.length && !inLine[i].isWhitespace()) i++
|
|
||||||
while (i < inLine.length && inLine[i].isWhitespace()) i++
|
|
||||||
val vWord = inLine.substring(vWordStart, i)
|
|
||||||
val vCandidate = vCurrent.toString() + vWord
|
|
||||||
if (estimateTextWidth(vCandidate, inFontSize, inFontFamily, inFontVariations) <= inMaxWidth) {
|
|
||||||
vCurrent.append(vWord)
|
|
||||||
} else {
|
|
||||||
if (vCurrent.isNotEmpty()) {
|
|
||||||
outLines.add(vCurrent.toString())
|
|
||||||
outStarts.add(inBaseOffset + vLineStartInHard)
|
|
||||||
vLineStartInHard += vCurrent.length
|
|
||||||
vCurrent = StringBuilder()
|
|
||||||
}
|
|
||||||
if (estimateTextWidth(vWord, inFontSize, inFontFamily, inFontVariations) > inMaxWidth) {
|
|
||||||
val vSub = StringBuilder()
|
|
||||||
for (ch in vWord) {
|
|
||||||
if (estimateTextWidth(vSub.toString() + ch, inFontSize, inFontFamily, inFontVariations) > inMaxWidth) {
|
|
||||||
if (vSub.isNotEmpty()) {
|
|
||||||
outLines.add(vSub.toString())
|
|
||||||
outStarts.add(inBaseOffset + vLineStartInHard)
|
|
||||||
vLineStartInHard += vSub.length
|
|
||||||
vSub.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vSub.append(ch)
|
|
||||||
}
|
|
||||||
vCurrent.append(vSub)
|
|
||||||
} else {
|
|
||||||
vCurrent.append(vWord)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vCurrent.isNotEmpty()) {
|
|
||||||
outLines.add(vCurrent.toString())
|
|
||||||
outStarts.add(inBaseOffset + vLineStartInHard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Width = sum of per-glyph advances. We don't use Font.measureTextWidth
|
|
||||||
because in Skiko 0.148.2 it returns roughly half the real advance
|
|
||||||
(measureText also undermeasures: rect.width ≈ 30 vs sum=62 for
|
|
||||||
"Disabled" at 16px Roboto). getStringGlyphs + getWidths bypasses
|
|
||||||
that path and matches what drawString actually paints, so the
|
|
||||||
resulting layout no longer clips text inside Button / centered
|
|
||||||
containers. Cached per (text, fontSize) since the wrap algorithm
|
|
||||||
queries the same prefixes many times. */
|
|
||||||
private fun estimateTextWidth(inText: String, inFontSize: Int, inFontFamily: String? = null, inFontVariations: List<ComposeFontVariation.Setting>? = null): Int {
|
|
||||||
if (inText.isEmpty()) return 0
|
|
||||||
val vKey = TypefaceKey(inFontFamily, variationsKey(inFontVariations))
|
|
||||||
// Key tab-containing text by the current tab width so changing "tab
|
|
||||||
// size" re-measures instead of returning a stale cached width.
|
|
||||||
val vCacheText = if ('\t' in inText) "${TextLayoutConfig.tabWidth} | |||||||