Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,9 +396,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int, Int> {
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()
}
}
4 changes: 2 additions & 2 deletions python-multiplatform-compose/fixtures/notebook-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. A proxy **class** passed as an argument is that type's registered empty value (SPEC U-7, #308), so `Card(modifier=Modifier)` works once the host registers `Modifier`'s empty factory with `register_empty`; it is the same value as `Modifier.empty()`, and equals leaving the argument out because Kotlin's default for `modifier` is `Modifier`. |
| 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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?> = 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
Expand Down Expand Up @@ -203,6 +209,9 @@ internal fun generateThunkClass(fragmentObjectName: String, specs: List<ThunkSpe
)
method.visitCode()
val (parameters, returns) = splitMethodDescriptor(spec.descriptor)
if (spec.onObjectInstance) {
method.visitFieldInsn(Opcodes.GETSTATIC, spec.ownerInternalName, "INSTANCE", "L${spec.ownerInternalName};")
}
parameters.forEachIndexed { slot, descriptor ->
method.visitVarInsn(Opcodes.ALOAD, 0)
method.pushInt(slot)
Expand All @@ -220,7 +229,10 @@ internal fun generateThunkClass(fragmentObjectName: String, specs: List<ThunkSpe
)
}
}
method.visitMethodInsn(Opcodes.INVOKESTATIC, spec.ownerInternalName, spec.methodName, spec.descriptor, false)
method.visitMethodInsn(
if (spec.onObjectInstance) Opcodes.INVOKEVIRTUAL else Opcodes.INVOKESTATIC,
spec.ownerInternalName, spec.methodName, spec.descriptor, false,
)
method.box(returns)
method.visitInsn(Opcodes.ARETURN)
method.visitMaxs(0, 0)
Expand Down
Loading