Compare commits

...
6 Commits
Author SHA1 Message Date
Bitsy f5fae6d32e docs(bridge): document settings-wide vs per-module application
Both worked since the plugin is Plugin<Any> branching on Settings vs
Project, but the module-level path was undocumented and untested —
verified now with the example applied compose-plugin style. Also spells
out that a pluginManagement plugins{} entry only pins the version while
the settings-level plugins{} block is what applies build-wide.
2026-07-14 13:08:39 +02:00
Bitsy b94f1f7bbb feat(bridge): compose.desktop.native { entryPoint } — application{} for native
The native counterpart of compose.desktop { application { mainClass } }:
the bridge grafts a 'native' extension onto the Compose plugin's desktop
extension (extensions are ExtensionAware — no compile-time dependency)
and, in afterEvaluate, declares binaries.executable(entryPoint) on every
Kotlin/Native DESKTOP target (mingw/linux/osx families). Targets that
already declare an executable are skipped, so manual configuration wins.

KGP access is reflective for the same reason as the resources packaging:
settings-applied plugins live in a parent classloader that cannot see
project-plugin types.

Replaces the last piece of consumer boilerplate — bubble-wrap's build
shrinks to targets + dependencies + the two entry points, verified
end-to-end (executable declared by the DSL links, bundles data.kres,
boots).
2026-07-14 13:04:38 +02:00
Bitsy ffbb87eddd perf(sdl): containment-gate text draws under pending clips
Text runs no longer force mask realization unconditionally: like other
draws they pass a containment test against the pending rounded clips
(with a 2px margin for glyph overhang / AA bleed). Small centred labels
inside circle-clipped nodes — a popped bubble's tiny text — previously
cost a full offscreen mask pass each, per frame.

Bubble-wrap probe (84 bubbles, half popped, continuous animation):
23 FPS before the lazy-clip series, 39 after it.
2026-07-14 12:47:18 +02:00
Bitsy 63fad4083f fix(sdl): lazily-realized clip masks belong to the clip's save-frame
The press artifact ('square on click, hover fine'): a press draws TWO
overhanging circles — the state layer, then the ripple animation — each
inside its own inner clipRect{} save-frame. The first one realized the
pending mask INSIDE the ripple's inner frame, whose saved clipLayers
count predated the realization, so that frame's restore() composited
and closed the mask early; the second circle then drew with no pending
and no mask — a faint square filling the clip's rect region. Hover only
draws one circle, hence always looked correct.

A lazily-realized mask is now attributed to the save-frame that PUSHED
the clip: PendingRoundClip records fStack.size at push, and realization
bumps the clipLayers threshold of every save-state entered since, so
only the owning frame's restore composites the layer — matching where
the eager implementation would have opened it.

Reproduced and verified with an automated press-hold rig (locate bubble
by colour, hold, capture mid-hold): square before, round after; eager-
forced realization used as the discriminating experiment.
2026-07-14 12:44:15 +02:00
Bitsy 872d740ce7 perf(sdl): lazy rounded clips — record, prove-safe, or realize on demand
Opening an offscreen mask per rounded clip is the most expensive thing
the SDL canvas does (two render-target switches + clear + corner cut +
feather + composite) — and Compose's dominant idiom
clip(shape).background(color) never needs one. clipRoundRect now only
RECORDS the clip (the rect part applies immediately); each draw either
proves itself safe or realizes the mask on demand:

- full-cover solid rect over the innermost pending clip (the background
  idiom) -> emitted directly as an antialiased rounded-rect fill,
- content whose device aabb passes a 4-corner ellipse containment test
  against every pending outline -> drawn as-is under the rect clip,
- anything genuinely crossing an arc (overhanging text, expanding
  ripples, difference clips, shadows) -> realizes the offscreen masks
  (outermost first) exactly as before.

save/restore tracks pending depth; never-realized pendings pop for
free. Measured on the bubble-wrap sheet (84 circle-clipped bubbles +
highlight dots, continuous animation): 23 -> 37 FPS.

Known open issue under investigation: a press on a bubble can show a
square state-layer/ripple artifact (hover is correct) — suspected
custom-blend fallback in the mask feather pass; follow-up commit.
2026-07-14 12:27:58 +02:00
Bitsy 4c2d46429d fix(publish): Windows owns the root metadata modules — 0.1.15 roots had no mingwX64 variants
The KotlinMultiplatform (root) publications ran on the macOS job, and a
macOS host never declares the mingwX64 target (vHostSupportsMingw) — so
every published root .module carried variants for linux/macos/jvm but
NOT mingw_x64. Windows consumers of the bridge resolved the root fine
and then found 'No matching variant' for every coordinate: v0.1.15 (and
every earlier release) is unusable from the published root modules on
Windows. Local mavenLocal flows never caught it because this repo's dev
box IS Windows, whose roots include all targets.

Two changes:
- createRenderBackend / rendererPreferredGpuMode become a real
  expect/actual seam (:ui nativeMain expect, actuals in each renderer
  source set). They were duplicate no-expect declarations — fine
  per-target, but shared nativeMain METADATA couldn't see them when a
  host's targets span both renderers, which is exactly what blocked
  :window's metadata compilation on Windows hosts.
- The publish workflow moves the KotlinMultiplatform + Jvm + bridge
  plugin publications to the WINDOWS job (the only host declaring every
  target) and updates the delete-then-retry ownership map to match.

Verified on Windows: repo-wide common+native metadata sweep green for
all library modules, full publishKotlinMultiplatformPublicationToMavenLocal
(root .module variants now cover linux_x64/linux_arm64/macos_arm64/
mingw_x64/jvm), demo links + boots, and the bubble-wrap consumer example
resolves and links mingwX64 through the bridge against the local roots.
2026-07-14 10:34:50 +02:00
11 changed files with 366 additions and 41 deletions
+17 -16
View File
@@ -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"
+13 -6
View File
@@ -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
@@ -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
@@ -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. " +
@@ -128,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>()
@@ -148,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.
@@ -168,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).
@@ -269,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 ||
@@ -375,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, vDeviceRound)) 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
@@ -573,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
@@ -707,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(),
@@ -714,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))
} }
@@ -736,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))
} }
@@ -768,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
@@ -876,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
@@ -894,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,
@@ -916,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
@@ -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,42 @@ 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 ## composeResources — zero setup
@@ -80,6 +80,7 @@ class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
private fun installBridge(project: Project) { private fun installBridge(project: Project) {
installResourcePackaging(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) }) {
@@ -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)
})
}
}