Compare commits

..
4 Commits
Author SHA1 Message Date
Bitsy 0c09109e6e fix(bridge): make the app-icon object a real input of the mingw link
Editing an app-icon PNG rebuilt the .ico and the windres .o and then left the
link UP-TO-DATE, so the .exe silently kept the OLD embedded icon. Reproduced on
:apidemo - generateComposeNativeIco and compileComposeNativeIconResource both
re-ran, linkDebugExecutableMingwX64 reported UP-TO-DATE, and the executable's
md5 did not move.

The link was wired with a bare `dependsOn(windresTask)`, which only ORDERS the
two. The object's path reaches the linker as a `linkerOpts` STRING, so the path
is an input but its CONTENT never is - nothing about a changed .o could
invalidate the link.

Replaced with `inputs.files(windresTask)`, which carries the task dependency AND
puts the object's content into the up-to-date check. Verified: the link now
re-runs and the .exe changes when an icon PNG is edited, in both directions.

The .rgba window-icon blobs were never affected - they go into the data.kres Zip
via `from(dir)`, and a Copy spec is already a declared input.

Also annotates the three icon task types with @DisableCachingByDefault. They
had no cacheability declaration, which failed `validatePlugins` and with it
:compose-desktop-native-bridge:build; that failure predates this change.
2026-09-12 18:44:46 +02:00
Bitsy 5804128e1d fix(bridge): aggregate dependency modules' composeResources into data.kres
In a multi-module project, resources declared in a LIBRARY module never reached
the final data.kres - only the app module's own composeResources were packaged,
so every Res.* accessor pointing at a submodule resource failed at runtime.

The Zip sourced the app's own `generated/compose/resourceGenerator/
preparedResources/<sourceSet>/composeResources`, which is per-module by
construction. Kotlin already solves this: `<target>AggregateResources` merges
this project's own composeResources with those of every dependency module into
`build/kotlin-multiplatform-resources/aggregated-resources/<target>/`, each
under its OWN `composeResources/<package>/` prefix. The plugin now reads that.

Two bugs fixed at once. The old path also forced a SINGLE resolved package onto
everything it copied, which is wrong the moment more than one module
contributes - the aggregate keeps each module's real package instead.

The aggregate directory is already rooted at `composeResources/`, so it is
copied with no `into()` remapping. Kept the old per-module wiring as a fallback
for Kotlin versions that do not register the aggregation tasks.

Verified with a throwaway library module carrying a composeResources file:
  before  data.kres had only the app's resources
  after   composeResources/<app>.generated.resources/files/app_marker.txt
          composeResources/<lib>.generated.resources/files/submodule_marker.txt
Existing single-module apps are unaffected - :demo's resources still land under
`demo.generated.resources` (it sets packageOfResClass explicitly, which the old
code also read), and its Images screen still loads PNG/JPG/SVG/XML at runtime.
2026-09-12 13:10:30 +02:00
Bitsy 04d1df58de docs(plan): record the icon-font centring fix 2026-09-12 12:32:39 +02:00
Bitsy a87ff2d7f6 fix(icons): centre the icon-font glyph so Material Symbols stop rendering low
Material Symbols icons sat 2-4px lower on native than on the JVM reference,
the offset scaling with icon size (~13% of it): eye 11px -> +2, gear/lock 16px
-> +2, download 28px -> +4. Equal top and bottom deltas with identical ink
heights, so it was a pure downward translation, not a sizing problem.

The two stacks positioned the glyph differently. The JVM actual centres it
explicitly (MaterialSymbolsIcon.jvm.kt: y = height/2 - (ascent+descent)/2),
while IconFontIcon just placed IconText in a Box with the DEFAULT TopStart
alignment. The glyph's natural text box is taller than the nominal icon size -
its height is the font's ascent+descent and Material Symbols' em box overshoots
the icon grid - so top-aligning it pushed the glyph down.

Centering the text box is equivalent to the JVM's baseline math, because
IconText sets no lineHeight and its box height therefore IS ascent+descent.

wrapContentSize(unbounded = true) is load-bearing and was the whole reason a
first attempt at this changed nothing: Modifier.size() hands the child FIXED
constraints, so the text measured at exactly `size`, nothing overflowed, and
contentAlignment had nothing to align.

Measured on apidemo, native vs jvm ink boxes:
  gear      +2/+1 -> 0/0
  download  +4/+4 -> 0/0
  lock      +2/+1 -> 0/0
  eye       +2/+2 -> +1/+1   (half-pixel rounding: an odd-height glyph centred
                              in an even box; the JVM draws at a float y where
                              native placement is integer)
Whole-window drift >32/255: 0.29% -> 0.17%.

Found by measuring rather than by eye, off the apidemo --screenshot harness.
2026-09-12 12:32:30 +02:00
4 changed files with 77 additions and 13 deletions
+12
View File
@@ -813,6 +813,18 @@ Two residuals, neither a text-metrics bug:
(0.29% at +0px vs 2.13% at +1px), where before +2px halved the error. The residual 0.29% (0.29% at +0px vs 2.13% at +1px), where before +2px halved the error. The residual 0.29%
is rasterizer AA. is rasterizer AA.
- **Icon-font glyphs rendered LOW - ✅ FIXED.** Material Symbols sat 2-4px lower on native
than on jvm, the offset scaling with icon size (~13% of it). Cause: the JVM actual centres
the glyph explicitly (`y = height/2 - (ascent+descent)/2`), while `IconFontIcon` placed
`IconText` in a Box at the DEFAULT TopStart alignment - and the glyph's natural text box is
taller than the nominal icon size. Fixed by centring, with
`wrapContentSize(unbounded = true)` so the text measures at its natural size first:
`Modifier.size()` imposes FIXED constraints, so without it nothing overflows and
contentAlignment is a no-op (a first attempt without it changed literally nothing).
gear/download/lock are now pixel-exact; the eye is 1px off from half-pixel rounding (odd
glyph height centred in an even box; jvm draws at a float y, native placement is integer).
Whole-window >32/255 drift 0.29% -> 0.17%.
- **Missing glyph in the session header.** The leading icon next to "Untitled session" - **Missing glyph in the session header.** The leading icon next to "Untitled session"
renders as a `.notdef` TOFU on jvm and as NOTHING on native - so the glyph is absent from renders as a `.notdef` TOFU on jvm and as NOTHING on native - so the glyph is absent from
the subsetted Material Symbols font on both, and the two stacks just disagree on how to the subsetted Material Symbols font on both, and the two stacks just disagree on how to
@@ -2,7 +2,9 @@ package com.compose.sdl.icons
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.font.FontVariation
@@ -36,9 +38,21 @@ fun IconFontIcon(
size: Dp = IconDefaults.DefaultIconSize, size: Dp = IconDefaults.DefaultIconSize,
fontVariationSettings: List<FontVariation.Setting>? = null, fontVariationSettings: List<FontVariation.Setting>? = null,
) { ) {
Box(modifier = modifier.size(size)) { // CENTER the glyph in the icon box. The glyph's natural text box is TALLER than
// `size` - its height is the font's ascent+descent, and Material Symbols' em box
// overshoots the nominal icon size - so the glyph used to render ~13% of the icon
// size too LOW. This mirrors the JVM actual's explicit baseline math
// (`y = height/2 - (ascent+descent)/2` in MaterialSymbolsIcon.jvm.kt): IconText
// sets no lineHeight, so its box height IS ascent+descent and centering that box
// centers the same span the JVM centers.
//
// `wrapContentSize(unbounded = true)` is load-bearing: Modifier.size() hands the
// child FIXED constraints, so without it the text is measured at exactly `size`,
// nothing overflows, and contentAlignment has nothing to align.
Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) {
com.compose.sdl.text.IconText( com.compose.sdl.text.IconText(
text = codepointToString(codepoint), text = codepointToString(codepoint),
modifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true),
fontFamily = fontFamily, fontFamily = fontFamily,
color = tint, color = tint,
fontSize = size.value.sp, fontSize = size.value.sp,
@@ -10,6 +10,7 @@ import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.PathSensitivity
import org.gradle.work.DisableCachingByDefault
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import java.io.File import java.io.File
import java.util.zip.Inflater import java.util.zip.Inflater
@@ -91,10 +92,23 @@ internal fun installAppIcon(inProject: Project, inExt: ComposeDesktopNativeExten
} }
// Link the object last: the mingw link tasks must run windres first (the // Link the object last: the mingw link tasks must run windres first (the
// path is referenced via linkerOpts, added reflectively in NativeApplication). // path is referenced via linkerOpts, added reflectively in NativeApplication).
//
// `inputs.files(task)` rather than a bare `dependsOn`: dependsOn only orders
// the two, it does NOT make the object's CONTENT part of the link's up-to-date
// check. The .o path reaches the linker as a linkerOpts STRING, which is an
// input only as a string - so editing an icon PNG rebuilt the .ico and the .o
// and then left the link UP-TO-DATE, silently shipping an .exe with the OLD
// icon embedded. Declaring the task's outputs as link inputs carries the
// dependency AND invalidates on content.
inProject.tasks.matching { inProject.tasks.matching {
it.name == "linkDebugExecutableMingwX64" || it.name == "linkReleaseExecutableMingwX64" || it.name == "linkDebugExecutableMingwX64" || it.name == "linkReleaseExecutableMingwX64" ||
it.name == "runDebugExecutableMingwX64" || it.name == "runReleaseExecutableMingwX64" it.name == "runDebugExecutableMingwX64" || it.name == "runReleaseExecutableMingwX64"
}.configureEach { it.dependsOn(vWindresTask) } }.configureEach { task ->
task.inputs.files(vWindresTask)
.withPropertyName("composeNativeAppIconObject")
.withPathSensitivity(PathSensitivity.NONE)
task.dependsOn(vWindresTask)
}
} }
} }
@@ -102,6 +116,7 @@ internal fun installAppIcon(inProject: Project, inExt: ComposeDesktopNativeExten
// MARK: Tasks // MARK: Tasks
// ================== // ==================
@DisableCachingByDefault(because = "trivial PNG decode; caching would cost more than it saves")
abstract class GenerateRgbaIconsTask : DefaultTask() { abstract class GenerateRgbaIconsTask : DefaultTask() {
@get:InputFiles @get:InputFiles
@get:PathSensitive(PathSensitivity.NONE) @get:PathSensitive(PathSensitivity.NONE)
@@ -124,6 +139,7 @@ abstract class GenerateRgbaIconsTask : DefaultTask() {
} }
} }
@DisableCachingByDefault(because = "trivial byte concatenation of already-local PNGs")
abstract class GenerateIcoTask : DefaultTask() { abstract class GenerateIcoTask : DefaultTask() {
@get:InputFiles @get:InputFiles
@get:PathSensitive(PathSensitivity.NONE) @get:PathSensitive(PathSensitivity.NONE)
@@ -140,6 +156,7 @@ abstract class GenerateIcoTask : DefaultTask() {
} }
} }
@DisableCachingByDefault(because = "shells out to the host windres; not portable across machines")
abstract class CompileWindowsIconResourceTask : DefaultTask() { abstract class CompileWindowsIconResourceTask : DefaultTask() {
@get:InputFiles @get:InputFiles
@get:PathSensitive(PathSensitivity.NONE) @get:PathSensitive(PathSensitivity.NONE)
@@ -88,18 +88,39 @@ private fun registerDataKresTasks(project: Project) {
val compress = project.providers.gradleProperty("compressResources").orNull?.toBoolean() ?: false val compress = project.providers.gradleProperty("compressResources").orNull?.toBoolean() ?: false
task.entryCompression = if (compress) ZipEntryCompression.DEFLATED else ZipEntryCompression.STORED task.entryCompression = if (compress) ZipEntryCompression.DEFLATED else ZipEntryCompression.STORED
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
val resPackage = project.resolveResourcePackage() // MULTI-MODULE: source from Kotlin's per-target resource AGGREGATE, which
val prepareNames = sourceSets // already merges this project's OWN composeResources with those of every
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } } // dependency module (each under its own `composeResources/<package>/`).
.filter { it in project.tasks.names } // Reading the project's `preparedResources/` instead - as this did - silently
for (prepareName in prepareNames) { // dropped every library module's resources from data.kres, and forced a
val sourceSet = prepareName.removePrefix("prepareComposeResourcesTaskFor") // single `resPackage` onto the lot, which is wrong the moment more than one
.replaceFirstChar { it.lowercase() } // module contributes.
val prepared = project.layout.buildDirectory.dir( //
"generated/compose/resourceGenerator/preparedResources/$sourceSet/composeResources" // The aggregate directory is already rooted at `composeResources/`, so it is
// copied in with NO `into()` remapping.
val aggregateName = "${target.replaceFirstChar { it.lowercase() }}AggregateResources"
if (aggregateName in project.tasks.names) {
val aggregated = project.layout.buildDirectory.dir(
"kotlin-multiplatform-resources/aggregated-resources/${target.replaceFirstChar { it.lowercase() }}"
) )
task.from(prepared) { spec -> spec.into("composeResources/$resPackage") } task.from(aggregated)
task.dependsOn(project.tasks.named(prepareName)) task.dependsOn(project.tasks.named(aggregateName))
} else {
// Fallback for Kotlin versions without the resources-aggregation tasks:
// this project's own resources only (the pre-existing behaviour).
val resPackage = project.resolveResourcePackage()
val prepareNames = sourceSets
.map { "prepareComposeResourcesTaskFor" + it.replaceFirstChar { c -> c.uppercase() } }
.filter { it in project.tasks.names }
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.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) } project.tasks.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) }