Compare commits

..
5 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
4 changed files with 308 additions and 15 deletions
@@ -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).
@@ -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)
})
}
}