Compare commits

...
4 Commits
Author SHA1 Message Date
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
6 changed files with 250 additions and 26 deletions
@@ -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 \
@@ -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,
@@ -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 }