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%
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"
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
@@ -2,7 +2,9 @@ package com.compose.sdl.icons
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontVariation
@@ -36,9 +38,21 @@ fun IconFontIcon(
size: Dp = IconDefaults.DefaultIconSize,
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(
text = codepointToString(codepoint),
modifier = Modifier.wrapContentSize(Alignment.Center, unbounded = true),
fontFamily = fontFamily,
color = tint,
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.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.work.DisableCachingByDefault
import org.gradle.api.tasks.TaskAction
import java.io.File
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
// 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 {
it.name == "linkDebugExecutableMingwX64" || it.name == "linkReleaseExecutableMingwX64" ||
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
// ==================
@DisableCachingByDefault(because = "trivial PNG decode; caching would cost more than it saves")
abstract class GenerateRgbaIconsTask : DefaultTask() {
@get:InputFiles
@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() {
@get:InputFiles
@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() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.NONE)
@@ -88,6 +88,26 @@ private fun registerDataKresTasks(project: Project) {
val compress = project.providers.gradleProperty("compressResources").orNull?.toBoolean() ?: false
task.entryCompression = if (compress) ZipEntryCompression.DEFLATED else ZipEntryCompression.STORED
task.duplicatesStrategy = DuplicatesStrategy.EXCLUDE
// MULTI-MODULE: source from Kotlin's per-target resource AGGREGATE, which
// already merges this project's OWN composeResources with those of every
// dependency module (each under its own `composeResources/<package>/`).
// Reading the project's `preparedResources/` instead - as this did - silently
// dropped every library module's resources from data.kres, and forced a
// single `resPackage` onto the lot, which is wrong the moment more than one
// module contributes.
//
// 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(aggregated)
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() } }
@@ -102,6 +122,7 @@ private fun registerDataKresTasks(project: Project) {
task.dependsOn(project.tasks.named(prepareName))
}
}
}
project.tasks.matching { it.name == linkName }.configureEach { it.dependsOn(zipTask) }
}
}