mirror of
https://github.com/bitsycore/compose-desktop-native.git
synced 2026-10-06 19:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5fae6d32e | ||
|
|
b94f1f7bbb | ||
|
|
ffbb87eddd | ||
|
|
63fad4083f | ||
|
|
872d740ce7 | ||
|
|
4c2d46429d | ||
|
|
120abbad93 | ||
|
|
a39002f1b8 | ||
|
|
fb470ecf0c | ||
|
|
2b6ac65a1f |
@@ -18,9 +18,8 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
# Each host runs only the publish tasks Gradle actually generated for it.
|
# Each host runs only the publish tasks Gradle actually generated for it.
|
||||||
# macOS also publishes the shared kotlinMultiplatform metadata module.
|
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
|
||||||
# No library module declares jvm() today — apps do their own JVM parity
|
# (see its matrix entry) + the jvm jar + the bridge plugin.
|
||||||
# build against upstream Compose, so JVM isn't part of the publication.
|
|
||||||
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
# `target` is the Gradle K/N target token (Capitalised) — one per host,
|
||||||
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
|
||||||
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
# skipped for the app build because SDL3 static libs on the ubuntu runner
|
||||||
@@ -31,16 +30,8 @@ jobs:
|
|||||||
host: macos
|
host: macos
|
||||||
target: MacosArm64
|
target: MacosArm64
|
||||||
exe_ext: kexe
|
exe_ext: kexe
|
||||||
# jvm publication: only :material-symbols declares a jvm() target
|
|
||||||
# (its common API renders via upstream Compose on JVM) — publish
|
|
||||||
# its host-independent jar from the same host as the metadata.
|
|
||||||
# :compose-desktop-native-bridge (the consumer-side bridge Gradle plugin +
|
|
||||||
# its plugin marker) is host-independent too — same job.
|
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
|
||||||
publishMacosArm64PublicationToGitHubPackagesRepository
|
publishMacosArm64PublicationToGitHubPackagesRepository
|
||||||
publishJvmPublicationToGitHubPackagesRepository
|
|
||||||
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
|
||||||
- runner: ubuntu-22.04
|
- runner: ubuntu-22.04
|
||||||
host: linux
|
host: linux
|
||||||
target: LinuxX64
|
target: LinuxX64
|
||||||
@@ -52,8 +43,18 @@ jobs:
|
|||||||
host: windows
|
host: windows
|
||||||
target: MingwX64
|
target: MingwX64
|
||||||
exe_ext: exe
|
exe_ext: exe
|
||||||
|
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
|
||||||
|
# the ONLY host that declares every target (vHostSupportsMingw is
|
||||||
|
# host-gated), so only its generated .module files carry the full
|
||||||
|
# variant table — the macOS-published roots of v0.1.15 had NO
|
||||||
|
# mingwX64 variants and Windows consumers could not resolve them.
|
||||||
|
# The host-independent jvm jar (:material-symbols) and the bridge
|
||||||
|
# plugin ride along on the same job.
|
||||||
tasks: >-
|
tasks: >-
|
||||||
publishMingwX64PublicationToGitHubPackagesRepository
|
publishMingwX64PublicationToGitHubPackagesRepository
|
||||||
|
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
|
||||||
|
publishJvmPublicationToGitHubPackagesRepository
|
||||||
|
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
@@ -148,8 +149,8 @@ jobs:
|
|||||||
# already completed; you can't tell from the 409 alone. The fix: between
|
# already completed; you can't tell from the 409 alone. The fix: between
|
||||||
# attempts, DELETE this host's package versions for the current release
|
# attempts, DELETE this host's package versions for the current release
|
||||||
# and re-upload from a clean slate. Each host owns DISJOINT package
|
# and re-upload from a clean slate. Each host owns DISJOINT package
|
||||||
# names (roots / -macosarm64 / -jvm / bridge on macOS, -mingwx64 on
|
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
|
||||||
# Windows, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
|
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
|
||||||
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
|
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
|
||||||
# a 403 on delete just degrades to the old blind-retry behaviour.
|
# a 403 on delete just degrades to the old blind-retry behaviour.
|
||||||
- name: Publish to GitHub Packages
|
- name: Publish to GitHub Packages
|
||||||
@@ -161,9 +162,9 @@ jobs:
|
|||||||
# Keep in sync with the module list in settings.gradle.kts.
|
# Keep in sync with the module list in settings.gradle.kts.
|
||||||
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
|
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
|
||||||
case "${{ matrix.host }}" in
|
case "${{ matrix.host }}" in
|
||||||
macos) SUFFIXES="_ROOT_ -macosarm64" ;;
|
macos) SUFFIXES="-macosarm64" ;;
|
||||||
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
|
||||||
windows) SUFFIXES="-mingwx64" ;;
|
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
|
||||||
esac
|
esac
|
||||||
host_packages() {
|
host_packages() {
|
||||||
for m in $MODULES; do
|
for m in $MODULES; do
|
||||||
@@ -171,7 +172,7 @@ jobs:
|
|||||||
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
|
if [ "$s" = "_ROOT_" ]; then echo "com.bitsycore.compose.sdl.$m"; else echo "com.bitsycore.compose.sdl.$m$s"; fi
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
if [ "${{ matrix.host }}" = "macos" ]; then
|
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||||
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
|
echo "com.bitsycore.compose.sdl.material-symbols-jvm"
|
||||||
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
|
echo "com.bitsycore.compose.sdl.compose-desktop-native-bridge"
|
||||||
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
|
echo "com.bitsycore.compose-desktop-native.bridge.com.bitsycore.compose-desktop-native.bridge.gradle.plugin"
|
||||||
|
|||||||
@@ -275,11 +275,15 @@ commonMain
|
|||||||
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
attached to: mingwX64Main always; macOS/Linux when -Prenderer=sdl3.
|
||||||
```
|
```
|
||||||
|
|
||||||
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
|
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
|
||||||
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
|
`:ui`'s nativeMain with `actual`s in BOTH `skikoRendererMain` and
|
||||||
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
|
`sdlRendererMain` — unambiguous because **only one of the two renderer source
|
||||||
resolves because **only one of the two renderer source sets is attached to a
|
sets is attached to a given target**. (They used to be plain duplicate
|
||||||
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
declarations with no expect; that compiled per-target but shared nativeMain
|
||||||
|
METADATA couldn't see them on a host whose targets span both renderers, which
|
||||||
|
blocked the WINDOWS host from producing :window's KotlinMultiplatform
|
||||||
|
publication — and Windows must publish the root modules, see the publish
|
||||||
|
workflow.) Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
|
||||||
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
**not even created**, so Gradle has nothing to warn about and Skiko is never
|
||||||
pulled in.
|
pulled in.
|
||||||
|
|
||||||
@@ -512,7 +516,10 @@ that should surface in tooling.
|
|||||||
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
|
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
|
||||||
while per-target compilation is fine). Declare EVERY artifact the common
|
while per-target compilation is fine). Declare EVERY artifact the common
|
||||||
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
|
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
|
||||||
own bridge rule. Note only the macOS publish job compiles common metadata —
|
own bridge rule. Note only the WINDOWS publish job compiles common metadata
|
||||||
|
(it owns the root KotlinMultiplatform publications — the only host that
|
||||||
|
declares every target, so only its .module files carry the full variant
|
||||||
|
table; macOS-published roots left v0.1.15 without mingwX64 variants) —
|
||||||
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
test with `gradlew :<module>:compileCommonMainKotlinMetadata` before tagging.
|
||||||
- **`Path()` in commonMain returns different actuals per renderer** —
|
- **`Path()` in commonMain returns different actuals per renderer** —
|
||||||
the Skia renderer produces a `SkiaBackedPath` (wraps
|
the Skia renderer produces a `SkiaBackedPath` (wraps
|
||||||
|
|||||||
@@ -20,8 +20,13 @@ linkerOpts.osx = \
|
|||||||
-framework QuartzCore -weak_framework CoreHaptics \
|
-framework QuartzCore -weak_framework CoreHaptics \
|
||||||
-lpthread -lm
|
-lpthread -lm
|
||||||
|
|
||||||
linkerOpts.linux_x64 = -lpthread -ldl -lm -lrt
|
# The -L multi-arch dirs + fontconfig/GL/X11 serve the SKIA renderer (font
|
||||||
linkerOpts.linux_arm64 = -lpthread -ldl -lm -lrt
|
# matching, GL backend, windowing) — K/N's LLD sysroot doesn't include the
|
||||||
|
# host's multi-arch dir, and carrying them here means apps (including bridge
|
||||||
|
# consumers) need NO linker flags of their own. Unused under -Prenderer=sdl3;
|
||||||
|
# the extra -l is harmless, and LLD skips missing -L dirs.
|
||||||
|
linkerOpts.linux_x64 = -L/usr/lib/x86_64-linux-gnu -lpthread -ldl -lm -lrt -lfontconfig -lGL -lX11
|
||||||
|
linkerOpts.linux_arm64 = -L/usr/lib/aarch64-linux-gnu -lpthread -ldl -lm -lrt -lfontconfig -lGL -lX11
|
||||||
|
|
||||||
linkerOpts.mingw_x64 = \
|
linkerOpts.mingw_x64 = \
|
||||||
-lm -lkernel32 -luser32 -lgdi32 -lwinmm -limm32 \
|
-lm -lkernel32 -luser32 -lgdi32 -lwinmm -limm32 \
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.compose.sdl
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: Renderer entry points (expect)
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/* The renderer seam :window builds against. Each renderer source set
|
||||||
|
(skikoRendererMain / sdlRendererMain) supplies the actuals; exactly one of
|
||||||
|
the two is attached to any given target, so resolution stays unambiguous.
|
||||||
|
|
||||||
|
These were originally declared identically in both renderer source sets
|
||||||
|
with NO expect — fine per-target, but shared nativeMain METADATA (=: what
|
||||||
|
:window's KotlinMultiplatform publication compiles against) could not see
|
||||||
|
them on a host where the attached targets span BOTH renderers (Windows:
|
||||||
|
skikoRenderer for macos/linux + sdlRenderer for mingw). The expect makes
|
||||||
|
nativeMain metadata self-contained, which lets the WINDOWS publish job —
|
||||||
|
the only host that declares every target — produce the root modules. */
|
||||||
|
|
||||||
|
/* Create the render backend for the selected [GpuMode]; null when the
|
||||||
|
backend can't initialise (caller falls back / reports). */
|
||||||
|
expect fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend?
|
||||||
|
|
||||||
|
/* The renderer module's per-OS default GpuMode (used for GpuMode.Auto). */
|
||||||
|
expect fun rendererPreferredGpuMode(): GpuMode
|
||||||
@@ -6,7 +6,7 @@ import kotlin.experimental.ExperimentalNativeApi
|
|||||||
// MARK: SDL3 renderer per-OS default
|
// MARK: SDL3 renderer per-OS default
|
||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
fun rendererPreferredGpuMode(): GpuMode {
|
actual fun rendererPreferredGpuMode(): GpuMode {
|
||||||
@OptIn(ExperimentalNativeApi::class)
|
@OptIn(ExperimentalNativeApi::class)
|
||||||
return when (Platform.osFamily) {
|
return when (Platform.osFamily) {
|
||||||
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
|
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.sdl.Sdl3RenderBackend
|
|||||||
renderer modules expose createRenderBackend / rendererPreferredGpuMode
|
renderer modules expose createRenderBackend / rendererPreferredGpuMode
|
||||||
with identical signatures in this package, and the build includes exactly
|
with identical signatures in this package, and the build includes exactly
|
||||||
one of them per target. Rejects Skia.* since this module has no Skiko. */
|
one of them per target. Rejects Skia.* since this module has no Skiko. */
|
||||||
fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
|
||||||
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
|
||||||
if (vResolved is GpuMode.Skia) {
|
if (vResolved is GpuMode.Skia) {
|
||||||
error("$vResolved isn't available in this build — Skiko isn't linked. " +
|
error("$vResolved isn't available in this build — Skiko isn't linked. " +
|
||||||
|
|||||||
+300
-36
@@ -1,5 +1,6 @@
|
|||||||
package com.compose.sdl.renderer.sdl
|
package com.compose.sdl.renderer.sdl
|
||||||
|
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.geometry.RoundRect
|
import androidx.compose.ui.geometry.RoundRect
|
||||||
@@ -26,6 +27,7 @@ import com.compose.sdl.icons.IconFont
|
|||||||
import kotlinx.cinterop.*
|
import kotlinx.cinterop.*
|
||||||
import sdl3.*
|
import sdl3.*
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sqrt
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -126,7 +128,7 @@ internal class Sdl3Canvas(
|
|||||||
// offscreen clip opened inside this save frame (see fClipLayers below).
|
// offscreen clip opened inside this save frame (see fClipLayers below).
|
||||||
private data class State(
|
private data class State(
|
||||||
val a: Float, val b: Float, val c: Float, val d: Float, val e: Float, val f: Float,
|
val a: Float, val b: Float, val c: Float, val d: Float, val e: Float, val f: Float,
|
||||||
val clip: IntArray?, val alpha: Float, val clipLayers: Int,
|
val clip: IntArray?, val alpha: Float, val clipLayers: Int, val pendingClips: Int,
|
||||||
)
|
)
|
||||||
private val fStack = ArrayDeque<State>()
|
private val fStack = ArrayDeque<State>()
|
||||||
|
|
||||||
@@ -146,9 +148,122 @@ internal class Sdl3Canvas(
|
|||||||
)
|
)
|
||||||
private val fClipLayers = ArrayDeque<OffscreenClip>()
|
private val fClipLayers = ArrayDeque<OffscreenClip>()
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Lazy rounded clips. Opening an offscreen mask per clip is the SINGLE
|
||||||
|
// most expensive thing this canvas does (two render-target switches +
|
||||||
|
// clear + corner cut + feather + composite) - and the dominant Compose
|
||||||
|
// idiom `Modifier.clip(CircleShape).background(...)` never needs one:
|
||||||
|
// the background is a full-cover rect drawable AS the rounded shape, and
|
||||||
|
// content wholly inside the rounded region needs no mask at all. So
|
||||||
|
// clipRoundRect only RECORDS the clip; each draw either proves itself
|
||||||
|
// safe (containment / cover) or realizes the offscreen on demand.
|
||||||
|
|
||||||
|
private class PendingRoundClip(
|
||||||
|
val deviceRound: RoundRect, // device-space outline (clamped radii)
|
||||||
|
val localRound: RoundRect, // as passed in (for cover -> shape-fill emission)
|
||||||
|
val region: IntArray, // fClip intersect bbox at push time
|
||||||
|
val bbox: IntArray, // device bbox of the outline
|
||||||
|
val prevClip: IntArray?, // fClip before this clip was pushed
|
||||||
|
// Affine snapshot at push - cover -> fill re-emits in LOCAL space and
|
||||||
|
// must bail if the transform moved since.
|
||||||
|
val ma: Float, val mb: Float, val mc: Float, val md: Float, val me: Float, val mf: Float,
|
||||||
|
// fStack.size when the clip was pushed: a lazily-realized mask must be
|
||||||
|
// composited by the save-frame that OWNS the clip, not by whatever
|
||||||
|
// inner frame happened to trigger realization (a press draws the state
|
||||||
|
// layer and the ripple in separate inner clipRect{} frames - the first
|
||||||
|
// realize must not let the inner restore close the mask, or the second
|
||||||
|
// draw runs unmasked: the "square on click" artifact).
|
||||||
|
val saveDepth: Int,
|
||||||
|
)
|
||||||
|
private val fPendingClips = ArrayDeque<PendingRoundClip>()
|
||||||
|
|
||||||
|
/* Point-in-round-rect (device space) - inside the rect and, within a
|
||||||
|
corner cut square, inside its ellipse. */
|
||||||
|
private fun roundRectContains(inRR: RoundRect, inX: Float, inY: Float): Boolean {
|
||||||
|
if (inX < inRR.left || inX > inRR.right || inY < inRR.top || inY > inRR.bottom) return false
|
||||||
|
fun corner(inRx: Float, inRy: Float, inDx: Float, inDy: Float): Boolean {
|
||||||
|
if (inRx <= 0f || inRy <= 0f) return true
|
||||||
|
val vNx = inDx / inRx; val vNy = inDy / inRy
|
||||||
|
return vNx * vNx + vNy * vNy <= 1f
|
||||||
|
}
|
||||||
|
val vTl = inRR.topLeftCornerRadius; val vTr = inRR.topRightCornerRadius
|
||||||
|
val vBr = inRR.bottomRightCornerRadius; val vBl = inRR.bottomLeftCornerRadius
|
||||||
|
if (inX < inRR.left + vTl.x && inY < inRR.top + vTl.y)
|
||||||
|
return corner(vTl.x, vTl.y, inX - (inRR.left + vTl.x), inY - (inRR.top + vTl.y))
|
||||||
|
if (inX > inRR.right - vTr.x && inY < inRR.top + vTr.y)
|
||||||
|
return corner(vTr.x, vTr.y, inX - (inRR.right - vTr.x), inY - (inRR.top + vTr.y))
|
||||||
|
if (inX > inRR.right - vBr.x && inY > inRR.bottom - vBr.y)
|
||||||
|
return corner(vBr.x, vBr.y, inX - (inRR.right - vBr.x), inY - (inRR.bottom - vBr.y))
|
||||||
|
if (inX < inRR.left + vBl.x && inY > inRR.bottom - vBl.y)
|
||||||
|
return corner(vBl.x, vBl.y, inX - (inRR.left + vBl.x), inY - (inRR.bottom - vBl.y))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A device-space aabb is inside the round rect iff its four corners are. */
|
||||||
|
private fun roundRectContainsAabb(inRR: RoundRect, inL: Float, inT: Float, inR: Float, inB: Float): Boolean =
|
||||||
|
roundRectContains(inRR, inL, inT) && roundRectContains(inRR, inR, inT) &&
|
||||||
|
roundRectContains(inRR, inR, inB) && roundRectContains(inRR, inL, inB)
|
||||||
|
|
||||||
|
/* Device aabb of a LOCAL rect under the current affine. */
|
||||||
|
private fun deviceAabb(inL: Float, inT: Float, inR: Float, inB: Float): FloatArray {
|
||||||
|
val vX0 = mapX(inL, inT); val vX1 = mapX(inR, inT); val vX2 = mapX(inR, inB); val vX3 = mapX(inL, inB)
|
||||||
|
val vY0 = mapY(inL, inT); val vY1 = mapY(inR, inT); val vY2 = mapY(inR, inB); val vY3 = mapY(inL, inB)
|
||||||
|
return floatArrayOf(
|
||||||
|
minOf(vX0, vX1, vX2, vX3), minOf(vY0, vY1, vY2, vY3),
|
||||||
|
maxOf(vX0, vX1, vX2, vX3), maxOf(vY0, vY1, vY2, vY3),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If the LOCAL-space aabb stays inside every pending rounded clip, drawing
|
||||||
|
it under the already-active rect clip is correct - no mask needed.
|
||||||
|
Otherwise realize the pending masks before the draw proceeds. */
|
||||||
|
private fun admitDraw(inL: Float, inT: Float, inR: Float, inB: Float) {
|
||||||
|
if (fPendingClips.isEmpty()) return
|
||||||
|
val vBox = deviceAabb(inL, inT, inR, inB)
|
||||||
|
for (vPending in fPendingClips) {
|
||||||
|
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
|
||||||
|
realizePendingClips()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Convert pending clips into real offscreen mask layers (outermost first). */
|
||||||
|
private fun realizePendingClips() {
|
||||||
|
if (fPendingClips.isEmpty()) return
|
||||||
|
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
||||||
|
while (fPendingClips.isNotEmpty()) {
|
||||||
|
val vPending = fPendingClips.removeFirst()
|
||||||
|
fScope.flush()
|
||||||
|
// A clipRect after the push may have narrowed the visible area.
|
||||||
|
val vRegion = intersect(fClip, vPending.region)
|
||||||
|
val vTarget = fClipTargets?.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
|
||||||
|
if (vTarget == null || vRegion[2] <= vRegion[0] || vRegion[3] <= vRegion[1]) {
|
||||||
|
// Degrade to the rect clip that is already active.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
|
||||||
|
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
|
||||||
|
fClip = vRegion
|
||||||
|
applyClip()
|
||||||
|
clearRegion(vRegion)
|
||||||
|
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPending.prevClip, vRegion, vPending.bbox, vPending.deviceRound))
|
||||||
|
// Attribute the realized mask to the save-frame that pushed the
|
||||||
|
// clip: inner frames entered since must NOT composite it on their
|
||||||
|
// restore - bump their thresholds past the new layer.
|
||||||
|
for (vI in vPending.saveDepth until fStack.size) {
|
||||||
|
val vState = fStack[vI]
|
||||||
|
if (vState.clipLayers < fClipLayers.size) {
|
||||||
|
fStack[vI] = vState.copy(clipLayers = fClipLayers.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Flushes any pending batched geometry to SDL, then frees the scope's
|
// Flushes any pending batched geometry to SDL, then frees the scope's
|
||||||
// native buffer + clears the SDL clip. Call once per frame after the draw.
|
// native buffer + clears the SDL clip. Call once per frame after the draw.
|
||||||
fun finish() {
|
fun finish() {
|
||||||
|
fPendingClips.clear()
|
||||||
fScope.release()
|
fScope.release()
|
||||||
// Safety: an unbalanced save/restore must never leave the renderer pointed
|
// Safety: an unbalanced save/restore must never leave the renderer pointed
|
||||||
// at a scratch target when the frame is presented.
|
// at a scratch target when the frame is presented.
|
||||||
@@ -166,11 +281,14 @@ internal class Sdl3Canvas(
|
|||||||
// Offscreen canvas: the outermost save (CanvasDrawScope brackets its draw with
|
// Offscreen canvas: the outermost save (CanvasDrawScope brackets its draw with
|
||||||
// save/restore) switches the SDL render target to the ImageBitmap's texture.
|
// save/restore) switches the SDL render target to the ImageBitmap's texture.
|
||||||
if (fOffscreenTexture != null && fStack.isEmpty()) beginOffscreen()
|
if (fOffscreenTexture != null && fStack.isEmpty()) beginOffscreen()
|
||||||
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size))
|
fStack.addLast(State(fMa, fMb, fMc, fMd, fMe, fMf, fClip, fAlpha, fClipLayers.size, fPendingClips.size))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun restore() {
|
override fun restore() {
|
||||||
val vPrev = fStack.removeLastOrNull() ?: return
|
val vPrev = fStack.removeLastOrNull() ?: return
|
||||||
|
// Pending (never-realized) rounded clips opened inside this save frame:
|
||||||
|
// nothing was drawn through an offscreen for them - just drop them.
|
||||||
|
while (fPendingClips.size > vPrev.pendingClips) fPendingClips.removeLast()
|
||||||
// Composite + pop any offscreen rounded clips opened inside this save frame
|
// Composite + pop any offscreen rounded clips opened inside this save frame
|
||||||
// before restoring the plain state (each composite restores render target
|
// before restoring the plain state (each composite restores render target
|
||||||
// and SDL clip to what it was when the clip was opened).
|
// and SDL clip to what it was when the clip was opened).
|
||||||
@@ -267,6 +385,7 @@ internal class Sdl3Canvas(
|
|||||||
// treating Difference as intersect clipped the border TO the notch instead
|
// treating Difference as intersect clipped the border TO the notch instead
|
||||||
// (a floating line under the label, no outline anywhere else).
|
// (a floating line under the label, no outline anywhere else).
|
||||||
private fun clipRectDifference(inL: Float, inT: Float, inR: Float, inB: Float) {
|
private fun clipRectDifference(inL: Float, inT: Float, inR: Float, inB: Float) {
|
||||||
|
realizePendingClips()
|
||||||
val vDiff = mapRectAABB(inL, inT, inR, inB)
|
val vDiff = mapRectAABB(inL, inT, inR, inB)
|
||||||
val vRegion = fClip ?: intArrayOf(0, 0, fSize.width.toInt(), fSize.height.toInt())
|
val vRegion = fClip ?: intArrayOf(0, 0, fSize.width.toInt(), fSize.height.toInt())
|
||||||
if (fClipTargets == null || fSize.width < 1f || fSize.height < 1f ||
|
if (fClipTargets == null || fSize.width < 1f || fSize.height < 1f ||
|
||||||
@@ -330,11 +449,19 @@ internal class Sdl3Canvas(
|
|||||||
// arbitrary paths keep the clipPath bbox fallback.
|
// arbitrary paths keep the clipPath bbox fallback.
|
||||||
|
|
||||||
override fun clipRoundRect(inRoundRect: RoundRect) {
|
override fun clipRoundRect(inRoundRect: RoundRect) {
|
||||||
|
// The mask is cut in DEVICE space (the bbox below goes through the
|
||||||
|
// affine), but the corner radii arrive in LOCAL user space — scale
|
||||||
|
// them by the affine too, or a graphicsLayer-scaled circle clip cuts
|
||||||
|
// with oversized corners and degenerates toward a lozenge (seen as
|
||||||
|
// diamond-shaped "popped" bubbles under scale 0.55). Column norms
|
||||||
|
// approximate per-axis scale; rotation already degrades to AABBs here.
|
||||||
|
val vScaleX = sqrt(fMa * fMa + fMb * fMb)
|
||||||
|
val vScaleY = sqrt(fMc * fMc + fMd * fMd)
|
||||||
val vMaxRadius = maxOf(
|
val vMaxRadius = maxOf(
|
||||||
inRoundRect.topLeftCornerRadius.x, inRoundRect.topLeftCornerRadius.y,
|
inRoundRect.topLeftCornerRadius.x * vScaleX, inRoundRect.topLeftCornerRadius.y * vScaleY,
|
||||||
inRoundRect.topRightCornerRadius.x, inRoundRect.topRightCornerRadius.y,
|
inRoundRect.topRightCornerRadius.x * vScaleX, inRoundRect.topRightCornerRadius.y * vScaleY,
|
||||||
inRoundRect.bottomRightCornerRadius.x, inRoundRect.bottomRightCornerRadius.y,
|
inRoundRect.bottomRightCornerRadius.x * vScaleX, inRoundRect.bottomRightCornerRadius.y * vScaleY,
|
||||||
inRoundRect.bottomLeftCornerRadius.x, inRoundRect.bottomLeftCornerRadius.y,
|
inRoundRect.bottomLeftCornerRadius.x * vScaleX, inRoundRect.bottomLeftCornerRadius.y * vScaleY,
|
||||||
)
|
)
|
||||||
// Effectively-square corners or no offscreen pool → plain rectangular clip.
|
// Effectively-square corners or no offscreen pool → plain rectangular clip.
|
||||||
if (vMaxRadius < 0.5f || fClipTargets == null || fSize.width < 1f || fSize.height < 1f) {
|
if (vMaxRadius < 0.5f || fClipTargets == null || fSize.width < 1f || fSize.height < 1f) {
|
||||||
@@ -343,6 +470,20 @@ internal class Sdl3Canvas(
|
|||||||
}
|
}
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
val vBbox = mapRectAABB(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
|
val vBbox = mapRectAABB(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
|
||||||
|
// Clamp to the DEVICE half-box: the bbox is int-truncated, so a scaled
|
||||||
|
// radius can exceed it by a fraction — opposite corner cuts then overlap
|
||||||
|
// and leave flat spots at N/E/S/W (a circle read as an octagon).
|
||||||
|
val vHalfW = (vBbox[2] - vBbox[0]) / 2f
|
||||||
|
val vHalfH = (vBbox[3] - vBbox[1]) / 2f
|
||||||
|
fun deviceRadius(inR: CornerRadius) =
|
||||||
|
CornerRadius(minOf(inR.x * vScaleX, vHalfW), minOf(inR.y * vScaleY, vHalfH))
|
||||||
|
val vDeviceRound = RoundRect(
|
||||||
|
vBbox[0].toFloat(), vBbox[1].toFloat(), vBbox[2].toFloat(), vBbox[3].toFloat(),
|
||||||
|
topLeftCornerRadius = deviceRadius(inRoundRect.topLeftCornerRadius),
|
||||||
|
topRightCornerRadius = deviceRadius(inRoundRect.topRightCornerRadius),
|
||||||
|
bottomRightCornerRadius = deviceRadius(inRoundRect.bottomRightCornerRadius),
|
||||||
|
bottomLeftCornerRadius = deviceRadius(inRoundRect.bottomLeftCornerRadius),
|
||||||
|
)
|
||||||
val vRegion = intersect(fClip, vBbox)
|
val vRegion = intersect(fClip, vBbox)
|
||||||
// Nothing visible: keep behaviour of a normal clip (cull) without an
|
// Nothing visible: keep behaviour of a normal clip (cull) without an
|
||||||
// offscreen pass. The enclosing save/restore restores the clip afterwards.
|
// offscreen pass. The enclosing save/restore restores the clip afterwards.
|
||||||
@@ -351,20 +492,18 @@ internal class Sdl3Canvas(
|
|||||||
applyClip()
|
applyClip()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val vTarget = fClipTargets.target(fClipLayers.size, fSize.width.toInt(), fSize.height.toInt())
|
// LAZY: record only — the rect part of the clip applies immediately,
|
||||||
if (vTarget == null) {
|
// the offscreen mask is opened by the first draw that actually needs
|
||||||
// Couldn't allocate a scratch target — degrade to a rectangular clip.
|
// it (admitDraw / realizePendingClips). The common clip+background
|
||||||
clipRect(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
|
// idiom never does.
|
||||||
return
|
|
||||||
}
|
|
||||||
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
|
||||||
val vPrevTarget = SDL_GetRenderTarget(vRenderer)
|
|
||||||
val vPrevClip = fClip
|
val vPrevClip = fClip
|
||||||
SDL_SetRenderTarget(vRenderer, vTarget.reinterpret())
|
|
||||||
fClip = vRegion
|
fClip = vRegion
|
||||||
applyClip()
|
applyClip()
|
||||||
clearRegion(vRegion)
|
fPendingClips.addLast(PendingRoundClip(
|
||||||
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPrevClip, vRegion, vBbox, inRoundRect))
|
vDeviceRound, inRoundRect, vRegion, vBbox, vPrevClip,
|
||||||
|
fMa, fMb, fMc, fMd, fMe, fMf,
|
||||||
|
fStack.size,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pops the top offscreen clip: flush its subtree, cut the rounded corners out
|
// Pops the top offscreen clip: flush its subtree, cut the rounded corners out
|
||||||
@@ -427,22 +566,24 @@ internal class Sdl3Canvas(
|
|||||||
private fun zeroRoundRectCorners(inBbox: IntArray, inRoundRect: RoundRect) {
|
private fun zeroRoundRectCorners(inBbox: IntArray, inRoundRect: RoundRect) {
|
||||||
val vLeft = inBbox[0].toFloat(); val vTop = inBbox[1].toFloat()
|
val vLeft = inBbox[0].toFloat(); val vTop = inBbox[1].toFloat()
|
||||||
val vRight = inBbox[2].toFloat(); val vBottom = inBbox[3].toFloat()
|
val vRight = inBbox[2].toFloat(); val vBottom = inBbox[3].toFloat()
|
||||||
val vSeg = 12
|
|
||||||
val vTl = inRoundRect.topLeftCornerRadius
|
val vTl = inRoundRect.topLeftCornerRadius
|
||||||
val vTr = inRoundRect.topRightCornerRadius
|
val vTr = inRoundRect.topRightCornerRadius
|
||||||
val vBr = inRoundRect.bottomRightCornerRadius
|
val vBr = inRoundRect.bottomRightCornerRadius
|
||||||
val vBl = inRoundRect.bottomLeftCornerRadius
|
val vBl = inRoundRect.bottomLeftCornerRadius
|
||||||
// Upper bound: 4 corners × vSeg triangles × 3 vertices. Written straight
|
// Segments per quarter arc ~ every 4 device px along the arc, matching
|
||||||
// into the native SDL_Vertex buffer — the old ArrayList<Float> staging
|
// Sdl3DrawScope's density (~64/full circle at typical radii).
|
||||||
// boxed ~300 floats per clip layer per frame.
|
val vMaxR = maxOf(vTl.x, vTl.y, vTr.x, vTr.y, vBr.x, vBr.y, vBl.x, vBl.y)
|
||||||
fun cornerCount(inR: androidx.compose.ui.geometry.CornerRadius): Int =
|
val vSeg = ((vMaxR * 1.5708f) / 4f).toInt().coerceIn(8, 32)
|
||||||
if (inR.x <= 0f || inR.y <= 0f) 0 else vSeg * 3
|
fun cornerCount(inR: CornerRadius): Int = if (inR.x <= 0f || inR.y <= 0f) 0 else vSeg * 3
|
||||||
val vMax = cornerCount(vTl) + cornerCount(vTr) + cornerCount(vBr) + cornerCount(vBl)
|
val vFanMax = cornerCount(vTl) + cornerCount(vTr) + cornerCount(vBr) + cornerCount(vBl)
|
||||||
if (vMax == 0) return
|
if (vFanMax == 0) return
|
||||||
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
|
||||||
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_NONE)
|
|
||||||
memScoped {
|
memScoped {
|
||||||
val vBuf = allocArray<SDL_Vertex>(vMax)
|
// ============
|
||||||
|
// Pass 1 — hard cut: zero everything outside each corner arc
|
||||||
|
// (triangle fan from the corner apex, blend NONE writes RGBA 0).
|
||||||
|
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_NONE)
|
||||||
|
val vBuf = allocArray<SDL_Vertex>(vFanMax)
|
||||||
var vIdx = 0
|
var vIdx = 0
|
||||||
fun put(inX: Float, inY: Float) {
|
fun put(inX: Float, inY: Float) {
|
||||||
vBuf[vIdx].position.x = inX; vBuf[vIdx].position.y = inY
|
vBuf[vIdx].position.x = inX; vBuf[vIdx].position.y = inY
|
||||||
@@ -470,6 +611,51 @@ internal class Sdl3Canvas(
|
|||||||
cutout(vRight, vBottom, vRight - vBr.x, vBottom - vBr.y, vBr.x, vBr.y, 0f)
|
cutout(vRight, vBottom, vRight - vBr.x, vBottom - vBr.y, vBr.x, vBr.y, 0f)
|
||||||
cutout(vLeft, vBottom, vLeft + vBl.x, vBottom - vBl.y, vBl.x, vBl.y, 90f)
|
cutout(vLeft, vBottom, vLeft + vBl.x, vBottom - vBl.y, vBl.x, vBl.y, 90f)
|
||||||
if (vIdx > 0) SDL_RenderGeometry(vRenderer, null, vBuf, vIdx, null, 0)
|
if (vIdx > 0) SDL_RenderGeometry(vRenderer, null, vBuf, vIdx, null, 0)
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// Pass 2 — feathered edge: a ~1.2px ring band straddling each arc
|
||||||
|
// whose vertex alpha ramps 1 → 0 outward, drawn with a custom
|
||||||
|
// "dst *= srcAlpha" blend. SDL_RenderGeometry has no antialiasing;
|
||||||
|
// without this the mask boundary is a hard polygon edge while the
|
||||||
|
// drawn shapes get Sdl3DrawScope's fringe AA (visibly rougher —
|
||||||
|
// scaled-down circle clips read as low-poly).
|
||||||
|
val vMul = SDL_ComposeCustomBlendMode(
|
||||||
|
SDL_BLENDFACTOR_ZERO, SDL_BLENDFACTOR_SRC_ALPHA, SDL_BLENDOPERATION_ADD,
|
||||||
|
SDL_BLENDFACTOR_ZERO, SDL_BLENDFACTOR_SRC_ALPHA, SDL_BLENDOPERATION_ADD,
|
||||||
|
)
|
||||||
|
SDL_SetRenderDrawBlendMode(vRenderer, vMul)
|
||||||
|
val vFeather = 1.2f
|
||||||
|
val vBandMax = 4 * vSeg * 6
|
||||||
|
val vBand = allocArray<SDL_Vertex>(vBandMax)
|
||||||
|
var vBIdx = 0
|
||||||
|
fun putBand(inX: Float, inY: Float, inKeep: Float) {
|
||||||
|
vBand[vBIdx].position.x = inX; vBand[vBIdx].position.y = inY
|
||||||
|
vBand[vBIdx].color.r = 1f; vBand[vBIdx].color.g = 1f; vBand[vBIdx].color.b = 1f; vBand[vBIdx].color.a = inKeep
|
||||||
|
vBand[vBIdx].tex_coord.x = 0f; vBand[vBIdx].tex_coord.y = 0f
|
||||||
|
vBIdx++
|
||||||
|
}
|
||||||
|
fun feather(inCx: Float, inCy: Float, inRx: Float, inRy: Float, inStartDeg: Float) {
|
||||||
|
if (inRx <= 1f || inRy <= 1f) return
|
||||||
|
var vPrevInX = 0f; var vPrevInY = 0f; var vPrevOutX = 0f; var vPrevOutY = 0f
|
||||||
|
for (vI in 0..vSeg) {
|
||||||
|
val vAngle = ((inStartDeg + 90f * vI / vSeg) * (PI / 180.0)).toFloat()
|
||||||
|
val vCos = cos(vAngle); val vSin = sin(vAngle)
|
||||||
|
val vInX = inCx + (inRx - vFeather) * vCos
|
||||||
|
val vInY = inCy + (inRy - vFeather) * vSin
|
||||||
|
val vOutX = inCx + inRx * vCos
|
||||||
|
val vOutY = inCy + inRy * vSin
|
||||||
|
if (vI > 0) {
|
||||||
|
putBand(vPrevInX, vPrevInY, 1f); putBand(vPrevOutX, vPrevOutY, 0f); putBand(vInX, vInY, 1f)
|
||||||
|
putBand(vInX, vInY, 1f); putBand(vPrevOutX, vPrevOutY, 0f); putBand(vOutX, vOutY, 0f)
|
||||||
|
}
|
||||||
|
vPrevInX = vInX; vPrevInY = vInY; vPrevOutX = vOutX; vPrevOutY = vOutY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
feather(vLeft + vTl.x, vTop + vTl.y, vTl.x, vTl.y, 180f)
|
||||||
|
feather(vRight - vTr.x, vTop + vTr.y, vTr.x, vTr.y, 270f)
|
||||||
|
feather(vRight - vBr.x, vBottom - vBr.y, vBr.x, vBr.y, 0f)
|
||||||
|
feather(vLeft + vBl.x, vBottom - vBl.y, vBl.x, vBl.y, 90f)
|
||||||
|
if (vBIdx > 0) SDL_RenderGeometry(vRenderer, null, vBand, vBIdx, null, 0)
|
||||||
}
|
}
|
||||||
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_BLEND)
|
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_BLEND)
|
||||||
}
|
}
|
||||||
@@ -502,6 +688,7 @@ internal class Sdl3Canvas(
|
|||||||
inAmbientColor: androidx.compose.ui.graphics.Color,
|
inAmbientColor: androidx.compose.ui.graphics.Color,
|
||||||
inSpotColor: androidx.compose.ui.graphics.Color,
|
inSpotColor: androidx.compose.ui.graphics.Color,
|
||||||
) {
|
) {
|
||||||
|
realizePendingClips()
|
||||||
if (inElevationPx <= 0f) return
|
if (inElevationPx <= 0f) return
|
||||||
|
|
||||||
// Generic paths (CutCornerShape, GenericShape) don't 9-slice — blur the
|
// Generic paths (CutCornerShape, GenericShape) don't 9-slice — blur the
|
||||||
@@ -636,6 +823,7 @@ internal class Sdl3Canvas(
|
|||||||
// before re-rasterising a vector). Write transparent directly instead of the
|
// before re-rasterising a vector). Write transparent directly instead of the
|
||||||
// paint colour.
|
// paint colour.
|
||||||
if (paint.blendMode == BlendMode.Clear) {
|
if (paint.blendMode == BlendMode.Clear) {
|
||||||
|
realizePendingClips()
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
clearRegion(intArrayOf(
|
clearRegion(intArrayOf(
|
||||||
mapX(left, top).toInt(), mapY(left, top).toInt(),
|
mapX(left, top).toInt(), mapY(left, top).toInt(),
|
||||||
@@ -643,21 +831,75 @@ internal class Sdl3Canvas(
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (fPendingClips.isNotEmpty() && tryDrawRectUnderPendingClips(left, top, right, bottom, paint)) return
|
||||||
prep().rectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
prep().rectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The `clip(shape).background(color)` idiom: a FILL rect that fully covers
|
||||||
|
the innermost pending clip becomes a direct rounded-shape fill (with the
|
||||||
|
scope's fringe AA — no offscreen mask at all). A rect fully INSIDE every
|
||||||
|
pending clip draws as-is. Returns true when the draw has been emitted. */
|
||||||
|
private fun tryDrawRectUnderPendingClips(inL: Float, inT: Float, inR: Float, inB: Float, inPaint: Paint): Boolean {
|
||||||
|
val vBox = deviceAabb(inL, inT, inR, inB)
|
||||||
|
val vInner = fPendingClips.last()
|
||||||
|
// Cover case: fill paint, no stroke, rect covers the whole clip outline,
|
||||||
|
// affine unchanged since the clip was pushed (local-space re-emission),
|
||||||
|
// uniform corner radii (the scope's roundRectCore takes one radius), and
|
||||||
|
// the clip outline itself contained in every OUTER pending clip.
|
||||||
|
val vLr = vInner.localRound
|
||||||
|
val vUniform = vLr.topLeftCornerRadius.x == vLr.topLeftCornerRadius.y &&
|
||||||
|
vLr.topLeftCornerRadius == vLr.topRightCornerRadius &&
|
||||||
|
vLr.topLeftCornerRadius == vLr.bottomRightCornerRadius &&
|
||||||
|
vLr.topLeftCornerRadius == vLr.bottomLeftCornerRadius
|
||||||
|
val vAffineUnchanged = vInner.ma == fMa && vInner.mb == fMb && vInner.mc == fMc &&
|
||||||
|
vInner.md == fMd && vInner.me == fMe && vInner.mf == fMf
|
||||||
|
if (inPaint.style == PaintingStyle.Fill && inPaint.blendMode == BlendMode.SrcOver &&
|
||||||
|
vUniform && vAffineUnchanged &&
|
||||||
|
vBox[0] <= vInner.bbox[0] && vBox[1] <= vInner.bbox[1] &&
|
||||||
|
vBox[2] >= vInner.bbox[2] && vBox[3] >= vInner.bbox[3]
|
||||||
|
) {
|
||||||
|
var vOutersContain = true
|
||||||
|
for (vPending in fPendingClips) {
|
||||||
|
if (vPending === vInner) continue
|
||||||
|
if (!roundRectContainsAabb(vPending.deviceRound, vInner.deviceRound.left, vInner.deviceRound.top, vInner.deviceRound.right, vInner.deviceRound.bottom)) {
|
||||||
|
vOutersContain = false; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vOutersContain) {
|
||||||
|
prep().roundRectCore(
|
||||||
|
brushFor(inPaint),
|
||||||
|
Offset(vLr.left, vLr.top), Size(vLr.width, vLr.height),
|
||||||
|
vLr.topLeftCornerRadius.x,
|
||||||
|
(inPaint.alpha * fAlpha), Fill,
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Containment: inside every pending outline → the active rect clip is enough.
|
||||||
|
for (vPending in fPendingClips) {
|
||||||
|
if (!roundRectContainsAabb(vPending.deviceRound, vBox[0], vBox[1], vBox[2], vBox[3])) {
|
||||||
|
realizePendingClips()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
override fun drawRoundRect(
|
override fun drawRoundRect(
|
||||||
left: Float, top: Float, right: Float, bottom: Float,
|
left: Float, top: Float, right: Float, bottom: Float,
|
||||||
radiusX: Float, radiusY: Float, paint: Paint,
|
radiusX: Float, radiusY: Float, paint: Paint,
|
||||||
) {
|
) {
|
||||||
|
admitDraw(left, top, right, bottom)
|
||||||
prep().roundRectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), radiusX, (paint.alpha * fAlpha), styleFor(paint))
|
prep().roundRectCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), radiusX, (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
override fun drawOval(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
|
||||||
|
admitDraw(left, top, right, bottom)
|
||||||
prep().ovalCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
prep().ovalCore(brushFor(paint), Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
|
override fun drawCircle(center: Offset, radius: Float, paint: Paint) {
|
||||||
|
admitDraw(center.x - radius, center.y - radius, center.x + radius, center.y + radius)
|
||||||
prep().circleCore(brushFor(paint), radius, center, (paint.alpha * fAlpha), styleFor(paint))
|
prep().circleCore(brushFor(paint), radius, center, (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,14 +907,17 @@ internal class Sdl3Canvas(
|
|||||||
left: Float, top: Float, right: Float, bottom: Float,
|
left: Float, top: Float, right: Float, bottom: Float,
|
||||||
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
|
startAngle: Float, sweepAngle: Float, useCenter: Boolean, paint: Paint,
|
||||||
) {
|
) {
|
||||||
|
realizePendingClips()
|
||||||
prep().arcCore(brushFor(paint), startAngle, sweepAngle, useCenter, Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
prep().arcCore(brushFor(paint), startAngle, sweepAngle, useCenter, Offset(left, top), Size(right - left, bottom - top), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
|
override fun drawLine(p1: Offset, p2: Offset, paint: Paint) {
|
||||||
|
realizePendingClips()
|
||||||
prep().lineCore(brushFor(paint), p1, p2, paint.strokeWidth, paint.strokeCap, (paint.alpha * fAlpha))
|
prep().lineCore(brushFor(paint), p1, p2, paint.strokeWidth, paint.strokeCap, (paint.alpha * fAlpha))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun drawPath(path: ComposePath, paint: Paint) {
|
override fun drawPath(path: ComposePath, paint: Paint) {
|
||||||
|
realizePendingClips()
|
||||||
prep().pathCore(path, brushFor(paint), (paint.alpha * fAlpha), styleFor(paint))
|
prep().pathCore(path, brushFor(paint), (paint.alpha * fAlpha), styleFor(paint))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +942,11 @@ internal class Sdl3Canvas(
|
|||||||
inBaseItalic: Boolean,
|
inBaseItalic: Boolean,
|
||||||
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
|
inTextDecoration: androidx.compose.ui.text.style.TextDecoration?,
|
||||||
) {
|
) {
|
||||||
|
// Small centred labels (a popped bubble's "pop") usually sit fully
|
||||||
|
// inside a rounded clip — gate on containment like other draws, with
|
||||||
|
// a 2px margin for glyph overhang / AA bleed, instead of paying a
|
||||||
|
// mask pass per text run.
|
||||||
|
admitDraw(inX - 2f, inY - 2f, inX + inBoxWidth + 2f, inY + inBoxHeight + 2f)
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
// Paragraph-level decoration bits forwarded to every wrapped line.
|
// Paragraph-level decoration bits forwarded to every wrapped line.
|
||||||
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
|
val vUnderline = inTextDecoration?.contains(androidx.compose.ui.text.style.TextDecoration.Underline) == true
|
||||||
@@ -712,15 +962,26 @@ internal class Sdl3Canvas(
|
|||||||
// node's real box. The per-line path below centres within a lineHeight band
|
// node's real box. The per-line path below centres within a lineHeight band
|
||||||
// (1.2 em for Material Symbols), taller than the size-clamped icon node,
|
// (1.2 em for Material Symbols), taller than the size-clamped icon node,
|
||||||
// which pushed every icon ~0.1 em below centre.
|
// which pushed every icon ~0.1 em below centre.
|
||||||
|
// Device-space scale for GLYPHS: the pen position already maps through
|
||||||
|
// the affine, but font size and centering boxes must scale with it too
|
||||||
|
// or text inside a graphicsLayer(scale) renders at full size, off
|
||||||
|
// centre (JVM/Skia scales glyphs with the canvas). Wrap stays in LOCAL
|
||||||
|
// units below so line breaks match what layout measured.
|
||||||
|
val vTextScaleX = sqrt(fMa * fMa + fMb * fMb)
|
||||||
|
val vTextScaleY = sqrt(fMc * fMc + fMd * fMd)
|
||||||
|
val vDeviceFontPx =
|
||||||
|
if (vTextScaleY == 1f) inFontSizePx
|
||||||
|
else (inFontSizePx * vTextScaleY).toInt().coerceAtLeast(1)
|
||||||
|
|
||||||
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
|
if (inFontFamily != null && IconFont.isIconFamily(inFontFamily)) {
|
||||||
vTr.drawText(
|
vTr.drawText(
|
||||||
inText = inText,
|
inText = inText,
|
||||||
inX = mapX(inX, inY).toInt(),
|
inX = mapX(inX, inY).toInt(),
|
||||||
inY = mapY(inX, inY).toInt(),
|
inY = mapY(inX, inY).toInt(),
|
||||||
inBoxWidth = inBoxWidth.toInt(),
|
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
|
||||||
inBoxHeight = inBoxHeight.toInt(),
|
inBoxHeight = (inBoxHeight * vTextScaleY).toInt(),
|
||||||
inColor = vColor,
|
inColor = vColor,
|
||||||
inFontSize = inFontSizePx,
|
inFontSize = vDeviceFontPx,
|
||||||
inAlign = inTextAlign,
|
inAlign = inTextAlign,
|
||||||
inFontFamily = inFontFamily,
|
inFontFamily = inFontFamily,
|
||||||
inFontVariations = inFontVariations,
|
inFontVariations = inFontVariations,
|
||||||
@@ -758,15 +1019,15 @@ internal class Sdl3Canvas(
|
|||||||
if (vLineY >= inY + inBoxHeight) break
|
if (vLineY >= inY + inBoxHeight) break
|
||||||
vTr.drawText(
|
vTr.drawText(
|
||||||
inText = vLine,
|
inText = vLine,
|
||||||
// Position maps through the affine (translate/scale reach the origin);
|
// Position maps through the affine; size/boxes switch to DEVICE
|
||||||
// glyph scaling/rotation isn't wired, so text in a rotated/scaled layer
|
// units here (scaled by the affine's column norms) so glyphs
|
||||||
// repositions but doesn't itself scale or rotate.
|
// scale with the layer. Rotation still only repositions.
|
||||||
inX = mapX(inX, vLineY).toInt(),
|
inX = mapX(inX, vLineY).toInt(),
|
||||||
inY = mapY(inX, vLineY).toInt(),
|
inY = mapY(inX, vLineY).toInt(),
|
||||||
inBoxWidth = inBoxWidth.toInt(),
|
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
|
||||||
inBoxHeight = vLineH.toInt(),
|
inBoxHeight = (vLineH * vTextScaleY).toInt(),
|
||||||
inColor = vColor,
|
inColor = vColor,
|
||||||
inFontSize = inFontSizePx,
|
inFontSize = vDeviceFontPx,
|
||||||
inAlign = inTextAlign,
|
inAlign = inTextAlign,
|
||||||
inFontFamily = inFontFamily,
|
inFontFamily = inFontFamily,
|
||||||
inFontVariations = inFontVariations,
|
inFontVariations = inFontVariations,
|
||||||
@@ -794,6 +1055,7 @@ internal class Sdl3Canvas(
|
|||||||
inContentScale: androidx.compose.ui.layout.ContentScale,
|
inContentScale: androidx.compose.ui.layout.ContentScale,
|
||||||
inAlpha: Float,
|
inAlpha: Float,
|
||||||
) {
|
) {
|
||||||
|
admitDraw(inX, inY, inX + inWidth, inY + inHeight)
|
||||||
fScope.flush()
|
fScope.flush()
|
||||||
// Map the origin through the affine and scale the size by the matrix's axis
|
// Map the origin through the affine and scale the size by the matrix's axis
|
||||||
// magnitudes (rotation contributes 1, so it only repositions the blit — the
|
// magnitudes (rotation contributes 1, so it only repositions the blit — the
|
||||||
@@ -812,6 +1074,7 @@ internal class Sdl3Canvas(
|
|||||||
|
|
||||||
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
|
override fun drawImage(image: ImageBitmap, topLeftOffset: Offset, paint: Paint) {
|
||||||
val vBmp = image as? SdlImageBitmap ?: return
|
val vBmp = image as? SdlImageBitmap ?: return
|
||||||
|
admitDraw(topLeftOffset.x, topLeftOffset.y, topLeftOffset.x + vBmp.width, topLeftOffset.y + vBmp.height)
|
||||||
drawImageRect(
|
drawImageRect(
|
||||||
image,
|
image,
|
||||||
androidx.compose.ui.unit.IntOffset.Zero,
|
androidx.compose.ui.unit.IntOffset.Zero,
|
||||||
@@ -834,6 +1097,7 @@ internal class Sdl3Canvas(
|
|||||||
dstSize: androidx.compose.ui.unit.IntSize,
|
dstSize: androidx.compose.ui.unit.IntSize,
|
||||||
paint: Paint,
|
paint: Paint,
|
||||||
) {
|
) {
|
||||||
|
realizePendingClips()
|
||||||
val vTex = (image as? SdlImageBitmap)?.texture ?: return
|
val vTex = (image as? SdlImageBitmap)?.texture ?: return
|
||||||
// Commit pending frame geometry and re-assert this canvas's target + clip
|
// Commit pending frame geometry and re-assert this canvas's target + clip
|
||||||
// (an offscreen render just borrowed the render target).
|
// (an offscreen render just borrowed the render target).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import kotlin.experimental.ExperimentalNativeApi
|
|||||||
|
|
||||||
internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
|
internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
|
||||||
|
|
||||||
fun rendererPreferredGpuMode(): GpuMode {
|
actual fun rendererPreferredGpuMode(): GpuMode {
|
||||||
@OptIn(ExperimentalNativeApi::class)
|
@OptIn(ExperimentalNativeApi::class)
|
||||||
return when (Platform.osFamily) {
|
return when (Platform.osFamily) {
|
||||||
OsFamily.MACOSX -> GpuMode.Skia.Metal
|
OsFamily.MACOSX -> GpuMode.Skia.Metal
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.skia.SkiaRenderBackend
|
|||||||
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. Rejects
|
||||||
Sdl3.* since this module has no SDL3_ttf / SDL3_image. */
|
Sdl3.* since this module has no SDL3_ttf / SDL3_image. */
|
||||||
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) {
|
if (vResolved is GpuMode.Sdl3) {
|
||||||
error("Sdl3.* modes aren't available in a Skia build — rerun with -Prenderer=sdl3")
|
error("Sdl3.* modes aren't available in a Skia build — rerun with -Prenderer=sdl3")
|
||||||
|
|||||||
@@ -59,8 +59,63 @@ kotlin {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The plugin can also be applied to a single module's `build.gradle.kts` instead
|
### Settings-wide or per-module
|
||||||
of settings (it then bridges only that module's configurations).
|
|
||||||
|
The plugin applies at either level:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// settings.gradle.kts — every module of the build
|
||||||
|
plugins { id("com.bitsycore.compose-desktop-native.bridge") version "<v>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// build.gradle.kts — this module only (compose-plugin style)
|
||||||
|
plugins { id("com.bitsycore.compose-desktop-native.bridge") }
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the difference between the two settings blocks: a
|
||||||
|
`pluginManagement { plugins { id(...) version ... } }` entry only PINS the
|
||||||
|
version (so module-level applications can omit it); the top-level
|
||||||
|
`plugins { }` block in settings is what actually applies it build-wide.
|
||||||
|
Rule of thumb: single app module → apply in the module; multi-module builds →
|
||||||
|
apply once in settings.
|
||||||
|
|
||||||
|
## compose.desktop.native — the application block for native
|
||||||
|
|
||||||
|
The native counterpart of `compose.desktop { application { mainClass } }`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
compose.desktop {
|
||||||
|
application { mainClass = "app.MainJvmKt" } // upstream jvm
|
||||||
|
native { entryPoint = "app.main" } // compose-desktop-native
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Declares an executable with that entry point on every Kotlin/Native desktop
|
||||||
|
target — no `targets.withType<KotlinNativeTarget> { binaries.executable { … } }`
|
||||||
|
boilerplate. Targets that already declare an executable are left untouched,
|
||||||
|
so manual configuration (extra linker flags, custom build types) still wins.
|
||||||
|
|
||||||
|
## composeResources — zero setup
|
||||||
|
|
||||||
|
If the module also applies the official `org.jetbrains.compose` plugin, the
|
||||||
|
bridge completes the resources story on the native desktop targets: it
|
||||||
|
registers a `package<Variant>ComposeResources<Target>` task per native
|
||||||
|
executable that bundles the Compose plugin's prepared resources into
|
||||||
|
`data.kres` next to the binary (a STORED zip the port's runtime reads via
|
||||||
|
SDL_GetBasePath). Files under `src/commonMain/composeResources/` + the
|
||||||
|
generated `Res.*` accessors then work exactly like on every other platform —
|
||||||
|
drawables, strings (`values/*.xml`), fonts, raw files:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation("org.jetbrains.compose.components:components-resources:<cmp-version>")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`compose.resources { packageOfResClass = … }` is honoured; source-set
|
||||||
|
overrides follow the default hierarchy (a `mingwX64Main` resource beats a
|
||||||
|
`commonMain` one).
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ plugins {
|
|||||||
`java-gradle-plugin`
|
`java-gradle-plugin`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
gradlePlugin {
|
gradlePlugin {
|
||||||
plugins {
|
plugins {
|
||||||
create("composeDesktopNativeBridge") {
|
create("composeDesktopNativeBridge") {
|
||||||
|
|||||||
+2
@@ -79,6 +79,8 @@ class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
|
|||||||
// ==================
|
// ==================
|
||||||
|
|
||||||
private fun installBridge(project: Project) {
|
private fun installBridge(project: Project) {
|
||||||
|
installResourcePackaging(project)
|
||||||
|
installNativeApplicationDsl(project)
|
||||||
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
|
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
|
||||||
project.configurations.configureEach { configuration ->
|
project.configurations.configureEach { configuration ->
|
||||||
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
|
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.bitsycore.compose.sdl.gradle
|
||||||
|
|
||||||
|
import org.gradle.api.Action
|
||||||
|
import org.gradle.api.NamedDomainObjectCollection
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.plugins.ExtensionAware
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: compose.desktop.native { } — the native counterpart of application { }
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native desktop analog of `compose.desktop { application { mainClass } }`:
|
||||||
|
*
|
||||||
|
* ```kotlin
|
||||||
|
* compose.desktop {
|
||||||
|
* application { mainClass = "bubblewrap.MainJvmKt" } // jvm (upstream)
|
||||||
|
* native { entryPoint = "bubblewrap.main" } // this port
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Declares an executable with the given entry point on every Kotlin/Native
|
||||||
|
* DESKTOP target (mingwX64 / linuxX64 / linuxArm64 / macosArm64) — replacing
|
||||||
|
* the hand-written `targets.withType<KotlinNativeTarget> { binaries.executable
|
||||||
|
* { entryPoint = … } }` block. Targets that already declare an executable are
|
||||||
|
* left untouched, so manual configuration (extra linker flags, custom build
|
||||||
|
* types) still wins.
|
||||||
|
*/
|
||||||
|
abstract class ComposeDesktopNativeExtension {
|
||||||
|
/** Entry point (`package.functionName`) for the native executables. */
|
||||||
|
var entryPoint: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grafts the `native` extension onto the Compose plugin's `desktop` extension
|
||||||
|
(extensions are ExtensionAware, so this needs no compile-time dependency)
|
||||||
|
and materialises the executables in afterEvaluate. All KGP access is
|
||||||
|
reflective: applied from settings, this plugin's classloader is a PARENT of
|
||||||
|
the project's buildscript loader and cannot see KGP types. */
|
||||||
|
internal fun installNativeApplicationDsl(project: Project) {
|
||||||
|
project.pluginManager.withPlugin("org.jetbrains.compose") {
|
||||||
|
project.pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
|
||||||
|
val composeExt = project.extensions.findByName("compose") as? ExtensionAware ?: return@withPlugin
|
||||||
|
val desktopExt = composeExt.extensions.findByName("desktop") as? ExtensionAware ?: return@withPlugin
|
||||||
|
if (desktopExt.extensions.findByName("native") != null) return@withPlugin
|
||||||
|
val nativeExt = desktopExt.extensions.create("native", ComposeDesktopNativeExtension::class.java)
|
||||||
|
project.afterEvaluate { configureNativeExecutables(it, nativeExt) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val desktopKonanFamilies = setOf("MINGW", "LINUX", "OSX")
|
||||||
|
|
||||||
|
private fun configureNativeExecutables(project: Project, ext: ComposeDesktopNativeExtension) {
|
||||||
|
val entryPoint = ext.entryPoint ?: return
|
||||||
|
val kotlinExt = project.extensions.findByName("kotlin") ?: return
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val targets = kotlinExt.javaClass.getMethod("getTargets").invoke(kotlinExt) as NamedDomainObjectCollection<Any>
|
||||||
|
for (target in targets) {
|
||||||
|
val platformType = target.javaClass.getMethod("getPlatformType").invoke(target)
|
||||||
|
if (platformType.toString() != "native") continue
|
||||||
|
val konanTarget = target.javaClass.getMethod("getKonanTarget").invoke(target)
|
||||||
|
val family = konanTarget.javaClass.getMethod("getFamily").invoke(konanTarget)
|
||||||
|
if ((family as Enum<*>).name !in desktopKonanFamilies) continue
|
||||||
|
val binaries = target.javaClass.getMethod("getBinaries").invoke(target)
|
||||||
|
// Respect manual configuration: skip targets that already have an
|
||||||
|
// executable (matching by the binary class, not the name suffix).
|
||||||
|
val existing = (binaries as Iterable<*>).any { it != null && it.javaClass.simpleName == "Executable" }
|
||||||
|
if (existing) continue
|
||||||
|
val executableMethod = binaries.javaClass.methods.first {
|
||||||
|
it.name == "executable" && it.parameterCount == 1 && Action::class.java.isAssignableFrom(it.parameterTypes[0])
|
||||||
|
}
|
||||||
|
executableMethod.invoke(binaries, Action<Any> { binary ->
|
||||||
|
binary.javaClass.getMethod("setEntryPoint", String::class.java).invoke(binary, entryPoint)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package com.bitsycore.compose.sdl.gradle
|
||||||
|
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.api.file.DuplicatesStrategy
|
||||||
|
import org.gradle.api.plugins.ExtensionAware
|
||||||
|
import org.gradle.api.tasks.bundling.Zip
|
||||||
|
import org.gradle.api.tasks.bundling.ZipEntryCompression
|
||||||
|
|
||||||
|
// ==================
|
||||||
|
// MARK: composeResources → data.kres packaging
|
||||||
|
// ==================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives consumer apps the OFFICIAL composeResources experience on the port's
|
||||||
|
* native desktop targets: files under `src/<sourceSet>/composeResources/` +
|
||||||
|
* the generated `Res.*` accessors, with nothing hand-rolled.
|
||||||
|
*
|
||||||
|
* The official Compose plugin already handles accessor generation and every
|
||||||
|
* other platform's packaging (jvm classpath, Android assets, …). What the
|
||||||
|
* native desktop targets need is the port's runtime bundle: `data.kres`, a
|
||||||
|
* STORED zip next to the executable that the runtime opens via
|
||||||
|
* SDL_GetBasePath() and reads entry-by-entry (fseek+fread — hence no
|
||||||
|
* compression). This registers one Zip task per native executable link task,
|
||||||
|
* zipping the Compose plugin's PREPARED resources (values*.xml are converted
|
||||||
|
* to .cvr there — zipping the raw source dir would break stringResource)
|
||||||
|
* under the same `composeResources/<res-package>/` prefix the generated
|
||||||
|
* accessors carry.
|
||||||
|
*
|
||||||
|
* IMPLEMENTATION NOTE — conventions, not KGP types: applied from settings,
|
||||||
|
* this plugin lives in a classloader that is a PARENT of the project's
|
||||||
|
* buildscript loader, so KGP / Compose plugin classes are structurally
|
||||||
|
* invisible to it. Link tasks (`link<Variant>Executable<Target>`), binary
|
||||||
|
* output dirs (`build/bin/<target>/<variant>Executable`), prepare tasks
|
||||||
|
* (`prepareComposeResourcesTaskFor<SourceSet>`) and the prepared-resources
|
||||||
|
* dir are all stable KGP / Compose-plugin naming conventions.
|
||||||
|
*/
|
||||||
|
internal fun installResourcePackaging(project: Project) {
|
||||||
|
project.pluginManager.withPlugin("org.jetbrains.compose") {
|
||||||
|
project.pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
|
||||||
|
// afterEvaluate: link tasks, prepare tasks and compose.resources {}
|
||||||
|
// all materialise during project evaluation.
|
||||||
|
project.afterEvaluate { registerDataKresTasks(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The port's desktop targets and each one's default-hierarchy source sets,
|
||||||
|
most specific first — a target-level resource overrides a commonMain one. */
|
||||||
|
private val desktopTargets = mapOf(
|
||||||
|
"MingwX64" to listOf("mingwX64Main", "mingwMain", "nativeMain", "commonMain"),
|
||||||
|
"LinuxX64" to listOf("linuxX64Main", "linuxMain", "nativeMain", "commonMain"),
|
||||||
|
"LinuxArm64" to listOf("linuxArm64Main", "linuxMain", "nativeMain", "commonMain"),
|
||||||
|
"MacosArm64" to listOf("macosArm64Main", "macosMain", "appleMain", "nativeMain", "commonMain"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun registerDataKresTasks(project: Project) {
|
||||||
|
val taskNames = project.tasks.names
|
||||||
|
val resPackage = project.resolveResourcePackage()
|
||||||
|
for ((target, sourceSets) in desktopTargets) {
|
||||||
|
for (variant in listOf("Debug", "Release")) {
|
||||||
|
val linkName = "link${variant}Executable$target"
|
||||||
|
if (linkName !in taskNames) continue
|
||||||
|
val zipName = "package${variant}ComposeResources$target"
|
||||||
|
if (zipName in taskNames) continue
|
||||||
|
val prepareNames = sourceSets
|
||||||
|
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
|
||||||
|
.filter { it in taskNames }
|
||||||
|
val zipTask = project.tasks.register(zipName, Zip::class.java) { task ->
|
||||||
|
task.description = "Bundles composeResources into data.kres next to the $target ${variant.lowercase()} executable."
|
||||||
|
task.archiveFileName.set("data.kres")
|
||||||
|
task.destinationDirectory.set(
|
||||||
|
project.layout.buildDirectory.dir(
|
||||||
|
"bin/${target.replaceFirstChar { it.lowercase() }}/${variant.replaceFirstChar { it.lowercase() }}Executable"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// STORED: the runtime's reader hands raw bytes straight to the
|
||||||
|
// decoders — an entry is one fseek+fread, never inflated.
|
||||||
|
task.entryCompression = ZipEntryCompression.STORED
|
||||||
|
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
|
for (prepareName in prepareNames) {
|
||||||
|
val sourceSet = prepareName.removePrefix("prepareComposeResourcesTaskFor")
|
||||||
|
.replaceFirstChar { it.lowercase() }
|
||||||
|
val prepared = project.layout.buildDirectory.dir(
|
||||||
|
"generated/compose/resourceGenerator/preparedResources/$sourceSet/composeResources"
|
||||||
|
)
|
||||||
|
task.from(prepared) { spec -> spec.into("composeResources/$resPackage") }
|
||||||
|
task.dependsOn(project.tasks.named(prepareName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.tasks.named(linkName) { it.dependsOn(zipTask) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The package the generated accessors carry in their resource paths —
|
||||||
|
compose.resources.packageOfResClass (read REFLECTIVELY: the ResourcesExtension
|
||||||
|
class lives in the project's buildscript loader, invisible from here), or
|
||||||
|
the Compose plugin's documented default `{group}.{module}.generated.resources`
|
||||||
|
(lowercased, '-' → '_', digit-leading segments prefixed with '_'). */
|
||||||
|
private fun Project.resolveResourcePackage(): String {
|
||||||
|
val explicit = runCatching {
|
||||||
|
val composeExt = extensions.findByName("compose") as? ExtensionAware
|
||||||
|
val resourcesExt = composeExt?.extensions?.findByName("resources")
|
||||||
|
resourcesExt?.javaClass?.getMethod("getPackageOfResClass")?.invoke(resourcesExt) as? String
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
if (explicit.isNotEmpty()) return explicit
|
||||||
|
val groupName = group.toString().lowercase().asUnderscoredIdentifier()
|
||||||
|
val moduleName = name.lowercase().asUnderscoredIdentifier()
|
||||||
|
val id = if (groupName.isNotEmpty()) "$groupName.$moduleName" else moduleName
|
||||||
|
return "$id.generated.resources"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.asUnderscoredIdentifier(): String =
|
||||||
|
replace('-', '_').let { if (it.isNotEmpty() && it.first().isDigit()) "_$it" else it }
|
||||||
Reference in New Issue
Block a user