From bb9e3ad0ae5c08c6b462921e81d33992d57ade2b Mon Sep 17 00:00:00 2001 From: BREW <81044362+b-re-w@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:39:24 +0900 Subject: [PATCH] Feat: @Composable members of objects bind (ButtonDefaults.buttonColors); walk foundation.shape (#307) The walker declined an object's composable member because its thunk was a static call. The thunk now loads the object's INSTANCE and calls the member with INVOKEVIRTUAL, using the walked MethodNode's own name and descriptor (no name lookup); value-returning composables already crossed as OBJECT handles. The compose and notebook-e2e fixtures walk androidx.compose.foundation.shape, so Button/Card take shape=RoundedCornerShape__Dp(..) and colors=ButtonDefaults.buttonColors(..) / CardDefaults.cardColors(..). ComposableValueClassSlotTest now selects the top-level Icon overloads by FQN, since SegmentedButtonDefaults.Icon binds too. --- docs/SPEC.md | 9 +- .../fixtures/compose/build.gradle.kts | 2 + .../compose/ShapeAndColorsRenderTest.kt | 126 ++++++++++++++++++ .../fixtures/notebook-e2e/README.md | 4 +- .../fixtures/notebook-e2e/build.gradle.kts | 2 + .../gradle/artifact/ArtifactScanner.kt | 13 +- .../gradle/artifact/ComposableThunks.kt | 14 +- .../gradle/artifact/ComposableBindingTest.kt | 68 ++++++++++ .../artifact/ComposableValueClassSlotTest.kt | 4 +- 9 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ShapeAndColorsRenderTest.kt diff --git a/docs/SPEC.md b/docs/SPEC.md index c4315b0d9..4fc1c1223 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -389,9 +389,12 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. part in overload naming or in a constructor's name check; a property key another binding already holds is declined, and so is a property or an extension function whose receiver has a supertype missing from the consumer's compile classpath (kotlinc cannot build its member scope; the reason names the missing supertype, #66). `Status: implemented`, `GP/artifact/PropertyBindingTest.kt` (properties and, #66, extension functions). The public instance functions of a Kotlin-public `object` bind under the object's name like a - static (`Arrangement.spacedBy`, #53), except `Any`'s members, ones that also exist as a - `@JvmStatic` static, and `@Composable` ones (declined); an `internal` or file-private object is - skipped. `Status: implemented`, `GP/artifact/ObjectMemberBindingTest.kt`. + static (`Arrangement.spacedBy`, #53), except `Any`'s members and ones that also exist as a + `@JvmStatic` static; an `internal` or file-private object is skipped. A `@Composable` one + (`ButtonDefaults.buttonColors`, `CardDefaults.cardColors`, #307) binds like a top-level composable, + synthetic `$composer`/`$changed`/`$default` slots included, through a generated thunk that loads the + object's `INSTANCE` and calls the member with `INVOKEVIRTUAL` (the name and descriptor are copied from + the walked method, never looked up); its return value crosses as any other. `Status: implemented`, `GP/artifact/ObjectMemberBindingTest.kt`. The public, non-suspend **member functions** of a Kotlin-public `class`, `interface` or `enum class` that crosses as an object handle bind as `METHOD` entries `Owner.name` (#215), the shape KSP's members already have: `args[0]` is the receiver, uncounted in `arity`, and the generated call is diff --git a/python-multiplatform-compose/fixtures/compose/build.gradle.kts b/python-multiplatform-compose/fixtures/compose/build.gradle.kts index 7a80c433a..95aeb900a 100644 --- a/python-multiplatform-compose/fixtures/compose/build.gradle.kts +++ b/python-multiplatform-compose/fixtures/compose/build.gradle.kts @@ -98,6 +98,8 @@ pythonBindings { listOf( "androidx.compose.material3", "androidx.compose.foundation.layout", + // `RoundedCornerShape` for `Button(shape=...)` / `Card(shape=...)` (issue #307). + "androidx.compose.foundation.shape", "androidx.compose.ui.graphics", "androidx.compose.ui.res", "androidx.compose.ui", diff --git a/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ShapeAndColorsRenderTest.kt b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ShapeAndColorsRenderTest.kt new file mode 100644 index 000000000..fab557936 --- /dev/null +++ b/python-multiplatform-compose/fixtures/compose/src/desktopTest/kotlin/fixture/compose/ShapeAndColorsRenderTest.kt @@ -0,0 +1,126 @@ +package fixture.compose + +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.Bitmap +import python.multiplatform.ffi.Python3 +import python.multiplatform.ffi.pythonx.PythonxAdapter +import python.multiplatform.ffi.upcall.UpcallBootstrap +import python.multiplatform.generated.artifacts.ArtifactTable +import python.multiplatform.reflection.UpcallTable +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Issue #307: `Button(shape=RoundedCornerShape(...), colors=ButtonDefaults.buttonColors(...))` and the + * `Card` equivalent, written from Python. + * + * Two bindings are needed. `androidx.compose.foundation.shape` is walked (`RoundedCornerShape`), and + * `ButtonDefaults.buttonColors` / `CardDefaults.cardColors`, `@Composable` member functions of a Kotlin + * `object` that return a value, bind through a thunk that loads the object's `INSTANCE` and calls the + * member (`ThunkSpec.onObjectInstance`). + * + * What is observed is pixels, so that a shape or a colour that never reached Compose fails: with an + * empty content a `Button` is a plain surface, so its centre pixel is the container colour and the + * corner pixel is transparent only when the corner radius is large. The radius-zero control keeps the + * corner assertion from passing for a reason other than the shape. + */ +class ShapeAndColorsRenderTest { + + @BeforeTest + fun installProducers() { + Python3.initialize(silent = true) + UpcallTable.clear() + UpcallTable.install(ArtifactTable.fragments) + check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" } + PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES) + } + + @AfterTest + fun cleanup() { + UpcallTable.clear() + } + + @Test + fun buttonTakesARoundedCornerShapeAndButtonDefaultsColours() { + val round = frame(buttonBody(radiusDp = 20.0)) + val square = frame(buttonBody(radiusDp = 0.0)) + println("compose render: Button(shape=RoundedCornerShape(20dp)) corner=${square.topLeftOf(RED)} centre=${round.pixel(25, 15)}; " + + "radius 0 corner=${square.pixel(0, 0)}") + assertEquals(RED, round.pixel(25, 15), "the container colour of buttonColors(container_color=Red) did not reach the Button") + // The square control's painted box is where the component sits; its corner must be red there + // (control) and not red in the rounded frame. + val (cx, cy) = square.topLeftOf(RED) + assertEquals(RED, square.pixel(cx, cy), "control: a square button must paint its corner") + assertTrue(round.pixel(cx, cy) != RED, "a 20dp corner radius must leave the corner pixel unpainted") + } + + @Test + fun cardTakesARoundedCornerShapeAndCardDefaultsColours() { + val round = frame(cardBody(radiusDp = 20.0)) + val square = frame(cardBody(radiusDp = 0.0)) + println("compose render: Card(shape=RoundedCornerShape(20dp)) red=${round.count(RED)} corner=${square.topLeftOf(RED)}; " + + "radius 0 corner=${square.pixel(0, 0)}") + assertTrue(round.count(RED) > 0, "the container colour of cardColors(container_color=Red) did not reach the Card") + // The square control's painted box is where the component sits; its corner must be red there + // (control) and not red in the rounded frame. + val (cx, cy) = square.topLeftOf(RED) + assertEquals(RED, square.pixel(cx, cy), "control: a square card must paint its corner") + assertTrue(round.pixel(cx, cy) != RED, "a 20dp corner radius must leave the corner pixel unpainted") + } + + private fun buttonBody(radiusDp: Double) = """ + from androidx.compose.material3 import Button, ButtonDefaults + from androidx.compose.foundation.shape import RoundedCornerShape__Dp + from androidx.compose.ui.graphics import Color__Int_Int_Int_Int as _Color + Button( + on_click=lambda: None, + shape=RoundedCornerShape__Dp($radiusDp), + colors=ButtonDefaults.buttonColors(container_color=_Color(255, 0, 0)), + content=lambda: None, + ) + """.trimIndent() + + private fun cardBody(radiusDp: Double) = """ + from androidx.compose.material3 import Card, CardDefaults, Text + from androidx.compose.foundation.shape import RoundedCornerShape__Dp + from androidx.compose.ui.graphics import Color__Int_Int_Int_Int as _Color + Card( + shape=RoundedCornerShape__Dp($radiusDp), + colors=CardDefaults.cardColors(container_color=_Color(255, 0, 0)), + content=lambda scope: Text('hi hi hi hi hi hi'), + ) + """.trimIndent() + + private class Frame(val width: Int, val pixels: IntArray) { + fun pixel(x: Int, y: Int) = pixels[y * width + x] + fun count(color: Int) = pixels.count { it == color } + + /** Top-left of the box the colour covers: where the component sits, which a minimum touch + * target can inset from (0, 0). */ + fun topLeftOf(color: Int): Pair { + val idx = pixels.indices.filter { pixels[it] == color } + check(idx.isNotEmpty()) { "no pixel of the colour" } + return idx.minOf { it % width } to idx.minOf { it / width } + } + } + + private fun frame(body: String): Frame { + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonComposition(body) + } + try { + val bitmap = Bitmap.makeFromImage(scene.render()) + return Frame(bitmap.width, IntArray(bitmap.width * bitmap.height) { bitmap.getColor(it % bitmap.width, it / bitmap.width) }) + } finally { + scene.close() + } + } + + private companion object { + const val RED = 0xFFFF0000.toInt() + } +} diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/README.md b/python-multiplatform-compose/fixtures/notebook-e2e/README.md index 0a7ab678a..c9201fa46 100644 --- a/python-multiplatform-compose/fixtures/notebook-e2e/README.md +++ b/python-multiplatform-compose/fixtures/notebook-e2e/README.md @@ -124,8 +124,8 @@ if (composing.isNotEmpty()) setComposingText(composing, 1) }`. Not exercised: th | cells 2–3 (`print`, numpy) | not UI | | cell 5: `remember_saveable`, `DefaultCoroutineScope`, `MainCoroutineScope` | undecided in pythonx-compose (INTENT §4.1, SPEC §8); nothing provides them | | cell 5/25/35/…: lower-case `modifier`, `modifier=modifier` | INTENT §5.4 replaces it with `modifier=Modifier`. The binder has no conversion for a proxy **class** passed as an argument (`Modifier.empty()` is used only for the class-spelling *method call*, `PythonxAdapter.kt` `_BoundMember`), so `Card(modifier=Modifier)` is not expected to work; the argument is left out, which is the same Kotlin default (`Modifier`). Not run to confirm. | -| cell 22/23/30/35: `corner_radius=` on `Button`; cell 25/40: on `Card` | Kotlin's counterpart is `shape=RoundedCornerShape(...)` from `androidx.compose.foundation.shape`, which this fixture does not walk (its package list is `:python-multiplatform-compose:fixtures:compose`'s). Adding `"androidx.compose.foundation.shape"` to `artifactIncludePackages` is the next step; not tried. | -| cell 22/23/25/…: `color=` on `Button`/`Card` | written as Kotlin's `colors=ButtonColors(...)` / `CardColors(...)` constructors. `ButtonDefaults.buttonColors(...)`, the usual Kotlin spelling, is a `@Composable` object function, which the walker declines (SPEC B-1). | +| cell 22/23/30/35: `corner_radius=` on `Button`; cell 25/40: on `Card` | Kotlin's counterpart is `shape=RoundedCornerShape(...)` from `androidx.compose.foundation.shape`, which is walked since #307 (`"androidx.compose.foundation.shape"` in `artifactIncludePackages`). Rendered in `:python-multiplatform-compose:fixtures:compose`'s `ShapeAndColorsRenderTest`; not repeated in this module's notebook tests. | +| cell 22/23/25/…: `color=` on `Button`/`Card` | Kotlin's spelling is `colors=ButtonDefaults.buttonColors(container_color=...)` / `CardDefaults.cardColors(...)`. Since #307 a `@Composable` member of an `object` binds (SPEC B-1) and `ShapeAndColorsRenderTest` renders it; the `ButtonColors(...)` / `CardColors(...)` constructors also still work. | | cell 27: `help(DefaultIcons)` | prints documentation; nothing to assert on screen | | cells 42, 48: empty; "채팅 화면 디자인하기" | no content | | cells 44–47: `from ws import client` | a websocket client outside pythonx-compose; marked "재 구현 필요" in the notebook | diff --git a/python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts b/python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts index 9a58951b4..4f267ccb9 100644 --- a/python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts +++ b/python-multiplatform-compose/fixtures/notebook-e2e/build.gradle.kts @@ -86,6 +86,8 @@ pythonBindings { listOf( "androidx.compose.material3", "androidx.compose.foundation.layout", + // `RoundedCornerShape` for `Button(shape=...)` / `Card(shape=...)` (issue #307). + "androidx.compose.foundation.shape", "androidx.compose.ui.graphics", "androidx.compose.ui.res", "androidx.compose.ui", diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt index 7d0f38cc6..8984d5cd2 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ArtifactScanner.kt @@ -620,7 +620,8 @@ internal object ArtifactScanner { val bySignature = functions.associateBy { it.jvmSignature.name to it.jvmSignature.descriptor } // A Kotlin `object`'s functions are instance methods of its singleton (issue #53: // `Arrangement.spacedBy`); generated source reaches them as `Owner.name(...)`, so they bind - // like a static. A composable one is declined below -- its thunk is a static call. + // like a static. A composable one binds through a thunk that loads `INSTANCE` first and calls + // the member with INVOKEVIRTUAL ([ThunkSpec.onObjectInstance]). val isObject = ownerIsClass && bindObjectMembers && ownerNode.fields.any { it.name == "INSTANCE" && it.access.hasFlag(Opcodes.ACC_STATIC) } val staticSignatures = ownerNode.methods.filter { it.access.hasFlag(Opcodes.ACC_STATIC) }.map { it.name to it.desc }.toSet() return ownerNode.methods @@ -661,12 +662,6 @@ internal object ArtifactScanner { declinedCandidate(owner, ownerIsClass, suspending, classpath, "suspend", isComposable(method)) } if (function.isSuspend) return@mapNotNull declinedCandidate(owner, ownerIsClass, function, classpath, "suspend", isComposable(method)) - if (!method.access.hasFlag(Opcodes.ACC_STATIC) && isComposable(method)) { - return@mapNotNull declinedCandidate( - owner, ownerIsClass, function, classpath, - "a @Composable member of an object: its generated call is a static invocation", true, - ) - } // A member whose Kotlin-declared parameter count does not match its JVM parameter // count has an implicit JVM parameter metadata does not account for -- a value // class's own instance turned into an unboxed receiver, or a composable's synthetic @@ -683,7 +678,7 @@ internal object ArtifactScanner { if (isComposable(method)) { composableCandidate( owner, ownerIsClass, function, classpath, paramDescriptors, returnDescriptor, method.name, - ownerNode.name, + ownerNode.name, onObjectInstance = !method.access.hasFlag(Opcodes.ACC_STATIC), )?.let { return@mapNotNull it } } return@mapNotNull declinedCandidate( @@ -1575,6 +1570,7 @@ internal object ArtifactScanner { returnDescriptor: String, jvmMethodName: String, ownerInternalName: String, + onObjectInstance: Boolean = false, ): Candidate? { // A composable's bounded type parameter is left exactly as it was before issue #38: its // declared slots are typed from the erased descriptor where metadata names no class, and its @@ -1633,6 +1629,7 @@ internal object ArtifactScanner { jvmMethodName, "(${paramDescriptors.joinToString("")})$returnDescriptor", valueClassUnboxOwners = declaredSlots.map { it.unboxOwner } + List(syntheticTags.size) { null }, + onObjectInstance = onObjectInstance, ), ), // `paramTags` is slot-aligned, so an extension's receiver is slot 0 and `model.parameters` diff --git a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ComposableThunks.kt b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ComposableThunks.kt index 2e30f0976..4ea38b8cb 100644 --- a/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ComposableThunks.kt +++ b/python-multiplatform-gradle-plugin/src/main/kotlin/python/multiplatform/gradle/artifact/ComposableThunks.kt @@ -71,6 +71,12 @@ internal data class ThunkSpec( * the carrier's, read off its own class file together with the descriptor that method returns. */ val valueClassUnboxOwners: List = emptyList(), + /** + * The target is an instance method of a Kotlin `object` (`ButtonDefaults.buttonColors`): the thunk + * pushes the owner's static `INSTANCE` first and calls with `INVOKEVIRTUAL`. The name and + * descriptor are still copied from the walked `MethodNode`, never looked up. + */ + val onObjectInstance: Boolean = false, ) : Serializable /** Where generated thunk classes live. Under [ARTIFACTS_PACKAGE] so that @@ -203,6 +209,9 @@ internal fun generateThunkClass(fragmentObjectName: String, specs: List method.visitVarInsn(Opcodes.ALOAD, 0) method.pushInt(slot) @@ -220,7 +229,10 @@ internal fun generateThunkClass(fragmentObjectName: String, specs: List(1L))) } + /** + * Issue #307: a `@Composable` member of a Kotlin `object` (`ButtonDefaults.buttonColors`, + * `CardDefaults.cardColors`) binds like a top-level composable, and returns a value. It was + * declined ("a @Composable member of an object: its generated call is a static invocation"). + * The thunk loads the object's `INSTANCE` and calls the member with `INVOKEVIRTUAL`. + */ + @Test + fun aComposableMemberOfAnObjectBindsThroughAnInstanceThunkAndReturnsAValue() { + val material3 = jarUnder("org.jetbrains.compose.material3", "material3-desktop") ?: return + val entries = ArtifactScanner.scanJar( + material3, + includePrefixes = listOf("androidx.compose.material3"), + classpath = composeClasspath(), + ) + for ((owner, member) in listOf("ButtonDefaults" to "buttonColors", "CardDefaults" to "cardColors")) { + val bound = entries.filter { + it.name.substringBeforeLast('.').endsWith(".$owner") && + it.name.substringAfterLast('.').substringBefore("__") == member + } + assertTrue(bound.isNotEmpty(), "no $owner.$member among ${entries.size} entries") + bound.forEach { entry -> + val thunk = assertNotNull(entry.thunk, "${entry.name} must carry a thunk spec") + assertTrue(thunk.onObjectInstance, "${entry.name}: an object member is called on INSTANCE") + assertEquals("androidx/compose/material3/$owner", thunk.ownerInternalName) + assertTrue(thunk.methodName.startsWith(member), thunk.methodName) + assertEquals("OBJECT", entry.returnTag, "${entry.name} returns ButtonColors/CardColors") + assertTrue("\$composer" in entry.paramNames, entry.paramNames.toString()) + } + } + } + + /** The instance thunk really links: `INSTANCE` is pushed, then the arguments, then `INVOKEVIRTUAL`. */ + @Test + fun aGeneratedInstanceThunkCallsAnInstanceMethodOfTheSingleton() { + val targetInternal = "fixture/thunk/InstanceProbe" + val writer = org.objectweb.asm.ClassWriter(org.objectweb.asm.ClassWriter.COMPUTE_MAXS) + writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC or Opcodes.ACC_FINAL, targetInternal, null, "java/lang/Object", null) + writer.visitField(Opcodes.ACC_PUBLIC or Opcodes.ACC_STATIC or Opcodes.ACC_FINAL, "INSTANCE", "L$targetInternal;", null, null).visitEnd() + writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null).apply { + visitCode(); visitVarInsn(Opcodes.ALOAD, 0) + visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false) + visitInsn(Opcodes.RETURN); visitMaxs(0, 0); visitEnd() + } + writer.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null).apply { + visitCode() + visitTypeInsn(Opcodes.NEW, targetInternal); visitInsn(Opcodes.DUP) + visitMethodInsn(Opcodes.INVOKESPECIAL, targetInternal, "", "()V", false) + visitFieldInsn(Opcodes.PUTSTATIC, targetInternal, "INSTANCE", "L$targetInternal;") + visitInsn(Opcodes.RETURN); visitMaxs(0, 0); visitEnd() + } + // `plus-abc123(int, long): long` is an instance method, so a static call could not reach it. + writer.visitMethod(Opcodes.ACC_PUBLIC, "plus-abc123", "(IJ)J", null, null).apply { + visitCode() + visitVarInsn(Opcodes.ILOAD, 1); visitInsn(Opcodes.I2L) + visitVarInsn(Opcodes.LLOAD, 2); visitInsn(Opcodes.LADD) + visitInsn(Opcodes.LRETURN); visitMaxs(0, 0); visitEnd() + } + writer.visitEnd() + val bytes = generateThunkClass("InstanceProbe", listOf(ThunkSpec(targetInternal, "plus-abc123", "(IJ)J", onObjectInstance = true))) + val loader = object : ClassLoader(javaClass.classLoader) { + fun define(name: String, data: ByteArray): Class<*> = defineClass(name, data, 0, data.size) + } + loader.define(targetInternal.replace('/', '.'), writer.toByteArray()) + val cls = loader.define("$THUNK_PACKAGE.InstanceProbeThunks", bytes) + val t0 = cls.getMethod(thunkMethodName(0), Array::class.java) + assertEquals(5L + 6L, t0.invoke(null, arrayOf(5L, 6L))) + } + /** * A class whose static method is called `sum-abc123`. Emitted rather than compiled on purpose: * a hyphen is Kotlin's value-class mangling suffix, is not a Java identifier, and the exact diff --git a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableValueClassSlotTest.kt b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableValueClassSlotTest.kt index 740f2e65f..6a67c5171 100644 --- a/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableValueClassSlotTest.kt +++ b/python-multiplatform-gradle-plugin/src/test/kotlin/python/multiplatform/gradle/artifact/ComposableValueClassSlotTest.kt @@ -98,7 +98,9 @@ class ComposableValueClassSlotTest { includePrefixes = listOf("androidx.compose.material3"), classpath = composeClasspath(), ) - val icons = entries.filter { it.name.substringAfterLast('.').substringBefore("__") == "Icon" } + // The top-level `androidx.compose.material3.Icon` overloads only: an object's composable member + // named Icon (SegmentedButtonDefaults.Icon, bound since #307) has no tint. + val icons = entries.filter { it.name.substringBefore("__") == "androidx.compose.material3.Icon" } assertTrue(icons.isNotEmpty(), "no Icon binding among ${entries.size} entries") // Not every `Icon` overload tints with a `Color`: 1.7.0 added one taking a