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
11 changes: 8 additions & 3 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,19 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
- **U-8** A function on a Kotlin-named module carries Kotlin's own surface and nothing else: the
Kotlin declaration name, keyword arguments by **Kotlin parameter names**, Kotlin defaults for omitted
parameters, overload sets under the base name, and its signature as public metadata
(`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc). No
(`inspect.signature`, `python_multiplatform.describe`; contract in `KotlinSurface.kt`'s KDoc).
`describe(module, name)` describes any bound name, a named constant (`STATIC_GETTER`) included,
without evaluating it (#36). A module's `dir()` lists its direct child packages/objects and reading
one as an attribute imports it (#35) — Kotlin names only. No
member or parameter is renamed; the binder creates no `pythonx` module and a real `pythonx` package
on disk is what `import pythonx` loads. The answer is the same whichever installer
(`PythonProxySource`, `PythonxAdapter`) ran first for a table. `Status: implemented` on desktop —
`PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`.
`PM/desktopTest/.../pythonx/KotlinNamedSurfaceTest.kt`, `BinderNamespaceTest.kt`, `ksp-fixtures/compose/.../KotlinSignatureMetadataTest.kt`.
- **U-9** A Pythonic package can serve extra member names on a Kotlin proxy through one hook,
`python_multiplatform.binding.add_member_resolver(fn)`, `fn(kotlin_type_name, requested_name,
kotlin_member_names) -> kotlin_name | None`, asked only when no Kotlin member of that name exists.
kotlin_member_names) -> kotlin_name | (kotlin_name, keyword_map) | None`, asked when no Kotlin member
of that name exists — and, for a Kotlin-named member, only when a call passes keywords and only for
its `{python_kw: kotlinParam}` keyword map (#34).
The binder renames nothing itself (no resolver: `AttributeError`), and aliases are cached in the
registry, not written on the proxy class (`dir()` stays Kotlin-only). Contract in `KotlinSurface.kt`'s
KDoc. `Status: implemented` on desktop — `PM/desktopTest/.../pythonx/MemberResolverTest.kt`,
Expand Down
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ androidx-lifecycle = "2.10.0"
androidx-material = "1.12.0"
androidx-test-junit = "1.2.1"
compose-plugin = "1.11.1"
# JetBrains stopped publishing material-icons with Compose after 1.7.3 (Maven Central: newest is 1.7.3);
# it is the version Compose Multiplatform 1.11.x points to, resolved against 1.11.1's ui/ui-graphics.
compose-material-icons = "1.7.3"
graalvm-buildtools = "0.10.6"
junit = "4.13.2"
kotlin = "2.4.20-Beta2"
Expand All @@ -20,6 +23,7 @@ kotlinx-coroutines = "1.8.1"
ksp = "2.3.11"

[libraries]
compose-material-icons-core = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "compose-material-icons" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
Expand Down
4 changes: 4 additions & 0 deletions ksp-fixtures/compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ kotlin {
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
// `Icons.Default.Add` and friends (issue #37): the one place an `ImageVector` is built.
implementation(libs.compose.material.icons.core)
implementation(compose.desktop.currentOs)
}
}
Expand Down Expand Up @@ -93,6 +95,8 @@ pythonBindings {
"androidx.compose.ui.graphics",
"androidx.compose.ui.res",
"androidx.compose.ui",
// 5. `material.icons` -- `Icons.Default` and the icon properties behind it (issue #37).
"androidx.compose.material.icons",
),
)
// Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package fixture.compose

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

/**
* Compose's packed-ARGB `Color` factories, `Color(color: Long)` and `Color(color: Int)`, called from
* Python under their bound overload spellings. `toArgb` is the judge: `0xFFFF0000` is opaque red,
* `-65536` as a Kotlin `Int`, whichever factory built it.
*/
class ColorFactoryTest {

@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 theLongAndIntColorFactoriesAreBoundAndBuildTheSameColor() {
Python3.exec(
"""
import python_multiplatform as _cf_pm
import androidx.compose.ui.graphics as _cf_g
from androidx.compose.ui.graphics import Color__Long, Color__Int, toArgb

assert [(_p['name'], _p['type']) for _p in _cf_pm.describe(_cf_g, 'Color__Long')['parameters']] == \
[('color', 'kotlin.Long')], _cf_pm.describe(_cf_g, 'Color__Long')
assert [(_p['name'], _p['type']) for _p in _cf_pm.describe(_cf_g, 'Color__Int')['parameters']] == \
[('color', 'kotlin.Int')], _cf_pm.describe(_cf_g, 'Color__Int')

_cf_long = Color__Long(0xFFFF0000)
_cf_int = Color__Int(-65536)
assert type(_cf_long)._kotlin_type_name == 'androidx.compose.ui.graphics.Color', type(_cf_long)
assert toArgb(_cf_long) == -65536, toArgb(_cf_long)
assert toArgb(_cf_int) == -65536, toArgb(_cf_int)
assert toArgb(Color__Long(0xFF00FF00)) == -16711936, 'green through the Long factory'
# Keyword by the Kotlin parameter name.
assert toArgb(Color__Long(color=0x800000FF)) == -2147483393
""".trimIndent(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -826,10 +826,14 @@ class ComposableRenderTest {
assert _icon_param_types == _icon_image_types, \
"Icon's overloads no longer match what this test recorded: " + repr(_icon_param_types)

# `materialIcon` (material-icons-core, walked since issue #37) returns an ImageVector,
# but only out of a `(ImageVector.Builder) -> ImageVector.Builder` lambda, which Python
# cannot write (`IconRenderTest.imageVectorIsUnreachable...` checks that refusal). So it is
# not a producer a Python caller can use, and it is the only one.
_produced = {
d.return_type_name
for table in _pm_binding._BY_PACKAGE.values() for decls in table.values() for d in decls
if d.return_type_name in _icon_image_types
if d.return_type_name in _icon_image_types and d.kotlin_name != 'androidx.compose.material.icons.materialIcon'
}
assert _produced == {
'androidx.compose.ui.graphics.ImageBitmap',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,21 @@ class IconRenderTest {
_all = [d for table in _pm_binding._BY_PACKAGE.values() for decls in table.values() for d in decls]

_producers = [d.kotlin_name for d in _all if d.return_type_name == _vector]
assert _producers == [], \
'something now returns an ImageVector -- the builder is reachable: ' + repr(_producers)
# material-icons-core (issue #37) brings one: `materialIcon(name, ..., imageVectorBuilder)`.
# It builds through a `(ImageVector.Builder) -> ImageVector.Builder` lambda, and a Python
# callable cannot stand in for a lambda that has to give something back -- refused below,
# so it opens nothing. Anything else returning an ImageVector still fails this.
assert _producers == ['androidx.compose.material.icons.materialIcon'], \
'something else now returns an ImageVector -- the builder may be reachable: ' + repr(_producers)
import androidx.compose.material.icons as _mi
_mi_decl = [d for d in _all if d.kotlin_name == 'androidx.compose.material.icons.materialIcon'][0]
_mi_slot = [n for n, t in zip(_mi_decl.param_names, _mi_decl.param_type_names)
if t.startswith('kotlin.Function')][0]
try:
_mi.materialIcon('x', **{_mi_slot: lambda builder: builder})
raise AssertionError('materialIcon accepted a Python builder lambda')
except TypeError as _mi_e:
assert 'cannot be written in Python yet' in str(_mi_e), str(_mi_e)

# The extension *on* the builder is bound; the type that would produce one is not. That
# gap is the nested-class skip and nothing else.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package fixture.compose

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
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.assertTrue

/**
* Issue #37: `material-icons-core` in the walk, so `Icon` has an `ImageVector` to draw -- **partly**:
* `Icons.Default` is bound, the icons themselves are not (see
* [iconsDefaultAddIsUnreachableBecauseTheWalkerBindsNoExtensionProperty]).
*
* The artifact is `org.jetbrains.compose.material:material-icons-core:1.7.3` -- the newest JetBrains
* published (Maven Central), and the one Compose Multiplatform 1.11.x points to; its `ui`
* dependencies resolve up to 1.11.1.
*/
class MaterialIconsRenderTest {

@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()
}

/** The control: the same icon drawn from Kotlin, so a 0 below means "the crossing failed". */
@Test
fun theAddIconDrawsFromKotlin() {
val ink = inkOfScene { Icon(Icons.Default.Add, contentDescription = null) }
println("compose render: Kotlin Icon(Icons.Default.Add) -> $ink px")
assertTrue(ink > 0, "the control icon drew nothing")
}

/** `Icons.Default` is bound, and `describe()` says what it is without reading it. */
@Test
fun iconsDefaultIsBoundAndDescribed() {
Python3.exec(
"""
import python_multiplatform as _mi_pm
import androidx.compose.material.icons as _mi_icons
assert 'Icons' in dir(_mi_icons), dir(_mi_icons)
_mi_d = _mi_pm.describe(_mi_icons.Icons, 'Default')
assert _mi_d['kind'] == 'STATIC_GETTER', _mi_d
assert _mi_d['returns'] == 'androidx.compose.material.icons.Icons.Filled', _mi_d
assert 'Default' in dir(_mi_icons.Icons), dir(_mi_icons.Icons)
""".trimIndent(),
)
}

/**
* **Not reached yet -- pinned, with the reason.** Issue #37's completion statement is
* `Icon(Icons.Default.Add, contentDescription=None)` from Python, and adding the artifact is not
* enough for it: `Icons.Default` is bound (above), but `Add` is a top-level **extension property**,
* `val Icons.Filled.Add: ImageVector` (`AddKt.getAdd(Icons$Filled)` in the jar), and the walker
* binds a file facade's *functions* only (`ArtifactScanner.scanClassNode` reads
* `kmPackage.functions`, never `kmPackage.properties`). So no binding has `Icons.Filled` as its
* receiver and the attribute is an `AttributeError`.
*
* The day the walker binds extension properties, this fails, and it should become the render
* proof: `Icon(androidx.compose.material.icons.Icons.Default.Add, contentDescription=None)`
* drawing the same ink as [theAddIconDrawsFromKotlin].
*/
@Test
fun iconsDefaultAddIsUnreachableBecauseTheWalkerBindsNoExtensionProperty() {
Python3.exec(
"""
import androidx
import python_multiplatform.binding as _mi_b
_mi_filled = 'androidx.compose.material.icons.Icons.Filled'
_mi_default = androidx.compose.material.icons.Icons.Default
assert type(_mi_default)._kotlin_type_name == _mi_filled, type(_mi_default)
try:
_mi_default.Add
raise AssertionError('Icons.Default.Add is reachable now -- turn this pin into the render proof')
except AttributeError:
pass
_mi_on_filled = [d.kotlin_name for d in _mi_b._TABLE.values() if d.receiver_type_name == _mi_filled]
assert _mi_on_filled == [], repr(_mi_on_filled)
assert 'androidx.compose.material.icons.filled' not in _mi_b._BY_PACKAGE, \
sorted(_mi_b._BY_PACKAGE['androidx.compose.material.icons.filled'])
""".trimIndent(),
)
}

private fun inkOfScene(content: @Composable () -> Unit): Int {
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f), content = content)
try {
val bitmap = Bitmap.makeFromImage(scene.render())
var ink = 0
for (y in 0 until bitmap.height) {
for (x in 0 until bitmap.width) {
if (bitmap.getColor(x, y) != 0) ink++
}
}
return ink
} finally {
scene.close()
}
}
}
Loading
Loading