Compare commits

...
5 Commits
Author SHA1 Message Date
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
Bitsy 120abbad93 fix(sdl): glyphs scale with the layer + antialiased, overlap-free clip masks
Text: drawNativeText mapped the pen position through the canvas affine
but passed LOCAL font size and centering boxes to the glyph renderer —
inside graphicsLayer(scale) text rendered full-size and off-centre
(JVM/Skia scales glyphs with the canvas). Font size and boxes now
switch to device units at the drawText boundary (affine column norms);
wrapping stays in local units so line breaks keep matching layout.

Clip masks: the round-rect corner cutter now clamps device radii to the
int-truncated bbox half-size (opposite cuts overlapped by a fraction of
a pixel, flattening N/E/S/W into an octagon look), adapts segment count
to on-screen arc length (~4px/vertex, Sdl3DrawScope density), and adds
a ~1.2px feathered ring (custom dst*=srcAlpha blend) — SDL_RenderGeometry
has no AA, so mask edges were visibly rougher than drawn shapes.

Parity-verified against upstream JVM with OS screenshots of the same
pre-popped bubble-wrap sheet.
2026-07-14 01:30:53 +02:00
Bitsy a39002f1b8 feat(sdl3.def): carry the Linux Skia-stack linker flags in the klib
-L multi-arch dirs + fontconfig/GL/X11 moved from the apps' build files
into sdl3.def's linux linkerOpts — cinterop endorses them into the klib,
so consumers (bridge apps included) need no linkerOpts at all on Linux.
Harmless under -Prenderer=sdl3 (unused -l) and when a -L dir is absent
(LLD skips it). The apps keep their copies until the next publish makes
the klib-carried flags the ones consumers resolve; duplicates are inert.

Windows flags stay app-side ON PURPOSE: --subsystem,windows / -Wl,-s are
policy (no console window, stripped binary), not requirements — a .def
would force them on every consumer binary including debug builds and
test executables.
2026-07-14 01:10:37 +02:00
Bitsy fb470ecf0c fix(sdl): scale round-rect clip radii into device space — scaled circles became lozenges
Sdl3Canvas.clipRoundRect cuts the corner mask in DEVICE space (the bbox
goes through the canvas affine) but used the LOCAL user-space corner
radii. Under a graphicsLayer scale the oversized cuts consumed nearly
the whole shape — a circle clipped at scale 0.55 rendered as a diamond
(bubble-wrap's popped bubbles on the SDL renderer vs perfect circles on
JVM). Scale the radii by the affine's column norms alongside the bbox;
rotation already degrades to AABBs on this path.

Repro: two OS-level screenshots of the same pre-popped sheet, native vs
jvm, before/after.
2026-07-14 01:10:10 +02:00
Bitsy 2b6ac65a1f feat(bridge): auto-package composeResources into data.kres for consumers
With the official org.jetbrains.compose plugin applied, the bridge now
registers a package<Variant>ComposeResources<Target> Zip per native
executable link task: the Compose plugin's PREPARED resources (values
xml already converted to .cvr — zipping the raw source dir would break
stringResource) land in data.kres next to the binary, STORED, under the
composeResources/<res-package>/ prefix the generated accessors carry.
Consumers get the official composeResources UX on the port's targets
with zero build-script code.

Convention-based on purpose: applied from settings, the plugin's
classloader is a PARENT of the project's buildscript loader, so KGP /
Compose-plugin types are structurally invisible (typed compileOnly
access dies with NoClassDefFound at class-init). Link-task names,
binary output dirs, prepare-task names and the prepared-resources dir
are stable conventions; packageOfResClass is read reflectively with the
documented {group}.{module}.generated.resources fallback.

Verified end-to-end with the bubble-wrap example: drawable + strings.xml
resolve through Res.* on mingwX64 (data.kres auto-bundled, app boots,
.cvr conversion intact) while jvm and android keep the official
pipeline.
2026-07-14 00:58:29 +02:00
13 changed files with 308 additions and 52 deletions
+17 -16
View File
@@ -18,9 +18,8 @@ jobs:
strategy:
fail-fast: false
# Each host runs only the publish tasks Gradle actually generated for it.
# macOS also publishes the shared kotlinMultiplatform metadata module.
# No library module declares jvm() today — apps do their own JVM parity
# build against upstream Compose, so JVM isn't part of the publication.
# WINDOWS also publishes the shared kotlinMultiplatform metadata modules
# (see its matrix entry) + the jvm jar + the bridge plugin.
# `target` is the Gradle K/N target token (Capitalised) — one per host,
# used for :demo / :apidemo linkReleaseExecutable<Target>. linuxArm64 is
# skipped for the app build because SDL3 static libs on the ubuntu runner
@@ -31,16 +30,8 @@ jobs:
host: macos
target: MacosArm64
exe_ext: kexe
# jvm publication: only :material-symbols declares a jvm() target
# (its common API renders via upstream Compose on JVM) — publish
# its host-independent jar from the same host as the metadata.
# :compose-desktop-native-bridge (the consumer-side bridge Gradle plugin +
# its plugin marker) is host-independent too — same job.
tasks: >-
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishMacosArm64PublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
- runner: ubuntu-22.04
host: linux
target: LinuxX64
@@ -52,8 +43,18 @@ jobs:
host: windows
target: MingwX64
exe_ext: exe
# WINDOWS owns the root KotlinMultiplatform metadata modules: it is
# the ONLY host that declares every target (vHostSupportsMingw is
# host-gated), so only its generated .module files carry the full
# variant table — the macOS-published roots of v0.1.15 had NO
# mingwX64 variants and Windows consumers could not resolve them.
# The host-independent jvm jar (:material-symbols) and the bridge
# plugin ride along on the same job.
tasks: >-
publishMingwX64PublicationToGitHubPackagesRepository
publishKotlinMultiplatformPublicationToGitHubPackagesRepository
publishJvmPublicationToGitHubPackagesRepository
:compose-desktop-native-bridge:publishAllPublicationsToGitHubPackagesRepository
runs-on: ${{ matrix.runner }}
@@ -148,8 +149,8 @@ jobs:
# already completed; you can't tell from the 409 alone. The fix: between
# attempts, DELETE this host's package versions for the current release
# and re-upload from a clean slate. Each host owns DISJOINT package
# names (roots / -macosarm64 / -jvm / bridge on macOS, -mingwx64 on
# Windows, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
# names (roots / -mingwx64 / -jvm / bridge on Windows, -macosarm64 on
# macOS, -linuxx64/-linuxarm64 on Linux), so parallel jobs never touch
# each other's uploads. Uses the run's GITHUB_TOKEN (packages: write);
# a 403 on delete just degrades to the old blind-retry behaviour.
- name: Publish to GitHub Packages
@@ -161,9 +162,9 @@ jobs:
# Keep in sync with the module list in settings.gradle.kts.
MODULES="ui ui-util ui-geometry ui-unit ui-backhandler ui-tooling-preview animation animation-core animation-graphics foundation foundation-layout material3 material-ripple window material-symbols navigation3-ui components-resources"
case "${{ matrix.host }}" in
macos) SUFFIXES="_ROOT_ -macosarm64" ;;
macos) SUFFIXES="-macosarm64" ;;
linux) SUFFIXES="-linuxx64 -linuxarm64" ;;
windows) SUFFIXES="-mingwx64" ;;
windows) SUFFIXES="_ROOT_ -mingwx64" ;;
esac
host_packages() {
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
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.compose-desktop-native-bridge"
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.
```
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are declared identically
in both `skikoRendererMain` and `sdlRendererMain`. `:window` calls them straight
from `:ui` — no `expect`/`actual`, no factory layer — and the right impl
resolves because **only one of the two renderer source sets is attached to a
given target**. Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
`createRenderBackend(…)` + `rendererPreferredGpuMode()` are `expect`s in
`:ui`'s nativeMain with `actual`s in BOTH `skikoRendererMain` and
`sdlRendererMain` — unambiguous because **only one of the two renderer source
sets is attached to a given target**. (They used to be plain duplicate
declarations with no expect; that compiled per-target but shared nativeMain
METADATA couldn't see them on a host whose targets span both renderers, which
blocked the WINDOWS host from producing :window's KotlinMultiplatform
publication — and Windows must publish the root modules, see the publish
workflow.) Under `-Prenderer=sdl3`, the `skikoRenderer*` source sets are
**not even created**, so Gradle has nothing to warn about and Skiko is never
pulled in.
@@ -512,7 +516,10 @@ that should surface in tooling.
'Color'` / `Cannot access class ...` in `compileCommonMainKotlinMetadata`,
while per-target compilation is fine). Declare EVERY artifact the common
code touches DIRECTLY (ui-graphics, ui-text, ui-unit, …) and give each its
own bridge rule. Note only the 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.
- **`Path()` in commonMain returns different actuals per renderer** —
the Skia renderer produces a `SkiaBackedPath` (wraps
@@ -20,8 +20,13 @@ linkerOpts.osx = \
-framework QuartzCore -weak_framework CoreHaptics \
-lpthread -lm
linkerOpts.linux_x64 = -lpthread -ldl -lm -lrt
linkerOpts.linux_arm64 = -lpthread -ldl -lm -lrt
# The -L multi-arch dirs + fontconfig/GL/X11 serve the SKIA renderer (font
# matching, GL backend, windowing) — K/N's LLD sysroot doesn't include the
# host's multi-arch dir, and carrying them here means apps (including bridge
# consumers) need NO linker flags of their own. Unused under -Prenderer=sdl3;
# the extra -l is harmless, and LLD skips missing -L dirs.
linkerOpts.linux_x64 = -L/usr/lib/x86_64-linux-gnu -lpthread -ldl -lm -lrt -lfontconfig -lGL -lX11
linkerOpts.linux_arm64 = -L/usr/lib/aarch64-linux-gnu -lpthread -ldl -lm -lrt -lfontconfig -lGL -lX11
linkerOpts.mingw_x64 = \
-lm -lkernel32 -luser32 -lgdi32 -lwinmm -limm32 \
@@ -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
// ==================
fun rendererPreferredGpuMode(): GpuMode {
actual fun rendererPreferredGpuMode(): GpuMode {
@OptIn(ExperimentalNativeApi::class)
return when (Platform.osFamily) {
OsFamily.MACOSX -> GpuMode.Sdl3.Metal
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.sdl.Sdl3RenderBackend
renderer modules expose createRenderBackend / rendererPreferredGpuMode
with identical signatures in this package, and the build includes exactly
one of them per target. Rejects Skia.* since this module has no Skiko. */
fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
actual fun createRenderBackend(inSdl: SDL3Backend, inGpu: GpuMode): RenderBackend? {
val vResolved = if (inGpu is GpuMode.Auto) rendererPreferredGpuMode() else inGpu
if (vResolved is GpuMode.Skia) {
error("$vResolved isn't available in this build — Skiko isn't linked. " +
@@ -1,5 +1,6 @@
package com.compose.sdl.renderer.sdl
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
@@ -26,6 +27,7 @@ import com.compose.sdl.icons.IconFont
import kotlinx.cinterop.*
import sdl3.*
import kotlin.math.PI
import kotlin.math.sqrt
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
@@ -330,11 +332,19 @@ internal class Sdl3Canvas(
// arbitrary paths keep the clipPath bbox fallback.
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(
inRoundRect.topLeftCornerRadius.x, inRoundRect.topLeftCornerRadius.y,
inRoundRect.topRightCornerRadius.x, inRoundRect.topRightCornerRadius.y,
inRoundRect.bottomRightCornerRadius.x, inRoundRect.bottomRightCornerRadius.y,
inRoundRect.bottomLeftCornerRadius.x, inRoundRect.bottomLeftCornerRadius.y,
inRoundRect.topLeftCornerRadius.x * vScaleX, inRoundRect.topLeftCornerRadius.y * vScaleY,
inRoundRect.topRightCornerRadius.x * vScaleX, inRoundRect.topRightCornerRadius.y * vScaleY,
inRoundRect.bottomRightCornerRadius.x * vScaleX, inRoundRect.bottomRightCornerRadius.y * vScaleY,
inRoundRect.bottomLeftCornerRadius.x * vScaleX, inRoundRect.bottomLeftCornerRadius.y * vScaleY,
)
// Effectively-square corners or no offscreen pool → plain rectangular clip.
if (vMaxRadius < 0.5f || fClipTargets == null || fSize.width < 1f || fSize.height < 1f) {
@@ -343,6 +353,20 @@ internal class Sdl3Canvas(
}
fScope.flush()
val vBbox = mapRectAABB(inRoundRect.left, inRoundRect.top, inRoundRect.right, inRoundRect.bottom)
// Clamp to the DEVICE half-box: the bbox is int-truncated, so a scaled
// radius can exceed it by a fraction — opposite corner cuts then overlap
// and leave flat spots at N/E/S/W (a circle read as an octagon).
val vHalfW = (vBbox[2] - vBbox[0]) / 2f
val vHalfH = (vBbox[3] - vBbox[1]) / 2f
fun deviceRadius(inR: CornerRadius) =
CornerRadius(minOf(inR.x * vScaleX, vHalfW), minOf(inR.y * vScaleY, vHalfH))
val vDeviceRound = RoundRect(
vBbox[0].toFloat(), vBbox[1].toFloat(), vBbox[2].toFloat(), vBbox[3].toFloat(),
topLeftCornerRadius = deviceRadius(inRoundRect.topLeftCornerRadius),
topRightCornerRadius = deviceRadius(inRoundRect.topRightCornerRadius),
bottomRightCornerRadius = deviceRadius(inRoundRect.bottomRightCornerRadius),
bottomLeftCornerRadius = deviceRadius(inRoundRect.bottomLeftCornerRadius),
)
val vRegion = intersect(fClip, vBbox)
// Nothing visible: keep behaviour of a normal clip (cull) without an
// offscreen pass. The enclosing save/restore restores the clip afterwards.
@@ -364,7 +388,7 @@ internal class Sdl3Canvas(
fClip = vRegion
applyClip()
clearRegion(vRegion)
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPrevClip, vRegion, vBbox, inRoundRect))
fClipLayers.addLast(OffscreenClip(vTarget, vPrevTarget, vPrevClip, vRegion, vBbox, vDeviceRound))
}
// Pops the top offscreen clip: flush its subtree, cut the rounded corners out
@@ -427,22 +451,24 @@ internal class Sdl3Canvas(
private fun zeroRoundRectCorners(inBbox: IntArray, inRoundRect: RoundRect) {
val vLeft = inBbox[0].toFloat(); val vTop = inBbox[1].toFloat()
val vRight = inBbox[2].toFloat(); val vBottom = inBbox[3].toFloat()
val vSeg = 12
val vTl = inRoundRect.topLeftCornerRadius
val vTr = inRoundRect.topRightCornerRadius
val vBr = inRoundRect.bottomRightCornerRadius
val vBl = inRoundRect.bottomLeftCornerRadius
// Upper bound: 4 corners × vSeg triangles × 3 vertices. Written straight
// into the native SDL_Vertex buffer — the old ArrayList<Float> staging
// boxed ~300 floats per clip layer per frame.
fun cornerCount(inR: androidx.compose.ui.geometry.CornerRadius): Int =
if (inR.x <= 0f || inR.y <= 0f) 0 else vSeg * 3
val vMax = cornerCount(vTl) + cornerCount(vTr) + cornerCount(vBr) + cornerCount(vBl)
if (vMax == 0) return
// Segments per quarter arc ~ every 4 device px along the arc, matching
// Sdl3DrawScope's density (~64/full circle at typical radii).
val vMaxR = maxOf(vTl.x, vTl.y, vTr.x, vTr.y, vBr.x, vBr.y, vBl.x, vBl.y)
val vSeg = ((vMaxR * 1.5708f) / 4f).toInt().coerceIn(8, 32)
fun cornerCount(inR: CornerRadius): Int = if (inR.x <= 0f || inR.y <= 0f) 0 else vSeg * 3
val vFanMax = cornerCount(vTl) + cornerCount(vTr) + cornerCount(vBr) + cornerCount(vBl)
if (vFanMax == 0) return
val vRenderer = fRenderer.reinterpret<cnames.structs.SDL_Renderer>()
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_NONE)
memScoped {
val vBuf = allocArray<SDL_Vertex>(vMax)
// ============
// Pass 1 — hard cut: zero everything outside each corner arc
// (triangle fan from the corner apex, blend NONE writes RGBA 0).
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_NONE)
val vBuf = allocArray<SDL_Vertex>(vFanMax)
var vIdx = 0
fun put(inX: Float, inY: Float) {
vBuf[vIdx].position.x = inX; vBuf[vIdx].position.y = inY
@@ -470,6 +496,51 @@ internal class Sdl3Canvas(
cutout(vRight, vBottom, vRight - vBr.x, vBottom - vBr.y, vBr.x, vBr.y, 0f)
cutout(vLeft, vBottom, vLeft + vBl.x, vBottom - vBl.y, vBl.x, vBl.y, 90f)
if (vIdx > 0) SDL_RenderGeometry(vRenderer, null, vBuf, vIdx, null, 0)
// ============
// Pass 2 — feathered edge: a ~1.2px ring band straddling each arc
// whose vertex alpha ramps 1 → 0 outward, drawn with a custom
// "dst *= srcAlpha" blend. SDL_RenderGeometry has no antialiasing;
// without this the mask boundary is a hard polygon edge while the
// drawn shapes get Sdl3DrawScope's fringe AA (visibly rougher —
// scaled-down circle clips read as low-poly).
val vMul = SDL_ComposeCustomBlendMode(
SDL_BLENDFACTOR_ZERO, SDL_BLENDFACTOR_SRC_ALPHA, SDL_BLENDOPERATION_ADD,
SDL_BLENDFACTOR_ZERO, SDL_BLENDFACTOR_SRC_ALPHA, SDL_BLENDOPERATION_ADD,
)
SDL_SetRenderDrawBlendMode(vRenderer, vMul)
val vFeather = 1.2f
val vBandMax = 4 * vSeg * 6
val vBand = allocArray<SDL_Vertex>(vBandMax)
var vBIdx = 0
fun putBand(inX: Float, inY: Float, inKeep: Float) {
vBand[vBIdx].position.x = inX; vBand[vBIdx].position.y = inY
vBand[vBIdx].color.r = 1f; vBand[vBIdx].color.g = 1f; vBand[vBIdx].color.b = 1f; vBand[vBIdx].color.a = inKeep
vBand[vBIdx].tex_coord.x = 0f; vBand[vBIdx].tex_coord.y = 0f
vBIdx++
}
fun feather(inCx: Float, inCy: Float, inRx: Float, inRy: Float, inStartDeg: Float) {
if (inRx <= 1f || inRy <= 1f) return
var vPrevInX = 0f; var vPrevInY = 0f; var vPrevOutX = 0f; var vPrevOutY = 0f
for (vI in 0..vSeg) {
val vAngle = ((inStartDeg + 90f * vI / vSeg) * (PI / 180.0)).toFloat()
val vCos = cos(vAngle); val vSin = sin(vAngle)
val vInX = inCx + (inRx - vFeather) * vCos
val vInY = inCy + (inRy - vFeather) * vSin
val vOutX = inCx + inRx * vCos
val vOutY = inCy + inRy * vSin
if (vI > 0) {
putBand(vPrevInX, vPrevInY, 1f); putBand(vPrevOutX, vPrevOutY, 0f); putBand(vInX, vInY, 1f)
putBand(vInX, vInY, 1f); putBand(vPrevOutX, vPrevOutY, 0f); putBand(vOutX, vOutY, 0f)
}
vPrevInX = vInX; vPrevInY = vInY; vPrevOutX = vOutX; vPrevOutY = vOutY
}
}
feather(vLeft + vTl.x, vTop + vTl.y, vTl.x, vTl.y, 180f)
feather(vRight - vTr.x, vTop + vTr.y, vTr.x, vTr.y, 270f)
feather(vRight - vBr.x, vBottom - vBr.y, vBr.x, vBr.y, 0f)
feather(vLeft + vBl.x, vBottom - vBl.y, vBl.x, vBl.y, 90f)
if (vBIdx > 0) SDL_RenderGeometry(vRenderer, null, vBand, vBIdx, null, 0)
}
SDL_SetRenderDrawBlendMode(vRenderer, SDL_BLENDMODE_BLEND)
}
@@ -712,15 +783,26 @@ internal class Sdl3Canvas(
// node's real box. The per-line path below centres within a lineHeight band
// (1.2 em for Material Symbols), taller than the size-clamped icon node,
// which pushed every icon ~0.1 em below centre.
// 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)) {
vTr.drawText(
inText = inText,
inX = mapX(inX, inY).toInt(),
inY = mapY(inX, inY).toInt(),
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = inBoxHeight.toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
inBoxHeight = (inBoxHeight * vTextScaleY).toInt(),
inColor = vColor,
inFontSize = inFontSizePx,
inFontSize = vDeviceFontPx,
inAlign = inTextAlign,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
@@ -758,15 +840,15 @@ internal class Sdl3Canvas(
if (vLineY >= inY + inBoxHeight) break
vTr.drawText(
inText = vLine,
// Position maps through the affine (translate/scale reach the origin);
// glyph scaling/rotation isn't wired, so text in a rotated/scaled layer
// repositions but doesn't itself scale or rotate.
// Position maps through the affine; size/boxes switch to DEVICE
// units here (scaled by the affine's column norms) so glyphs
// scale with the layer. Rotation still only repositions.
inX = mapX(inX, vLineY).toInt(),
inY = mapY(inX, vLineY).toInt(),
inBoxWidth = inBoxWidth.toInt(),
inBoxHeight = vLineH.toInt(),
inBoxWidth = (inBoxWidth * vTextScaleX).toInt(),
inBoxHeight = (vLineH * vTextScaleY).toInt(),
inColor = vColor,
inFontSize = inFontSizePx,
inFontSize = vDeviceFontPx,
inAlign = inTextAlign,
inFontFamily = inFontFamily,
inFontVariations = inFontVariations,
@@ -9,7 +9,7 @@ import kotlin.experimental.ExperimentalNativeApi
internal expect fun makeMetalBridge(backend: SDL3Backend): SkiaBridge?
fun rendererPreferredGpuMode(): GpuMode {
actual fun rendererPreferredGpuMode(): GpuMode {
@OptIn(ExperimentalNativeApi::class)
return when (Platform.osFamily) {
OsFamily.MACOSX -> GpuMode.Skia.Metal
@@ -11,7 +11,7 @@ import com.compose.sdl.renderer.skia.SkiaRenderBackend
SDL3 module's createRenderBackend / rendererPreferredGpuMode in the same
package; exactly one renderer module is included per target. Rejects
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
if (vResolved is GpuMode.Sdl3) {
error("Sdl3.* modes aren't available in a Skia build — rerun with -Prenderer=sdl3")
@@ -62,6 +62,27 @@ kotlin {
The plugin can also be applied to a single module's `build.gradle.kts` instead
of settings (it then bridges only that module's configurations).
## 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
- The substituted klib version defaults to the plugin's own version (both ship
@@ -16,6 +16,7 @@ plugins {
`java-gradle-plugin`
}
gradlePlugin {
plugins {
create("composeDesktopNativeBridge") {
@@ -79,6 +79,7 @@ class ComposeDesktopNativeBridgePlugin : Plugin<Any> {
// ==================
private fun installBridge(project: Project) {
installResourcePackaging(project)
val version = project.providers.gradleProperty(versionProperty).orNull ?: pluginVersion
project.configurations.configureEach { configuration ->
if (nativeTargetTokens.any { configuration.name.contains(it, ignoreCase = true) }) {
@@ -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 }