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
17 changes: 16 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
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`.
A public constructor of a public, non-abstract, non-generic class binds under the class's own name
(`pkg.Class`), including one with a value-class parameter, which `kotlinc` compiles to a private
`<init>` behind a public synthetic bridge ending in `DefaultConstructorMarker` (the descriptor
`@Metadata` records); a `@Deprecated(level = HIDDEN)` constructor, whose bridge carries that
annotation, is skipped (#73). A declared `kotlin.CharSequence` **result** — of a function, a property
getter or an object constant — crosses as a Python `str` (the generated body returns Kotlin's
`toString()` of it; `null` stays `None` for `CharSequence?`); a `CharSequence` parameter is still
declined (#73). `Status: implemented` — `GP/artifact/TextStateBindingTest.kt`,
`ksp-fixtures/compose/.../TextFieldStateRenderTest.kt`.
- **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial` —
`GP/artifact/KlibScannerTest.kt` and `ksp-fixtures/klib-artifact` assert that the scanned klib's
declarations are declined with reasons; no klib declaration is bound at run time yet.
Expand Down Expand Up @@ -290,7 +299,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
`GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`,
`ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`,
`tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in
its module is `Any`. A property is a `@property` (with a setter for a `var`) of its receiver's stub
its module is `Any` — except that a class's own bound constructor is that class's `__init__`
(`@overload`ed for several), so the class keeps its stub, members and properties; a single one's
docstring carries its table key after `Kotlin constructor: `; its `__`-suffixed table-key spellings
stay module functions; a value class bound as its primitive keeps its constructor as a module function
returning the primitive (#73, `GP/artifact/TextStateBindingTest.kt`,
`ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`). In Python the class name is the constructor,
as `Typography(...)` already was (`M3ProofRenderTest.kt`). A property is a `@property` (with a setter for a `var`) of its receiver's stub
class, its docstring carrying its table key after `Kotlin property: `; where that class has no stub,
a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`). An object constant is annotated with its
declared type even when that type is nested in the object (`Alignment.End: Horizontal`, a class of the
Expand Down
5 changes: 3 additions & 2 deletions docs/roadmap/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,9 @@ description, §16e/§16f's open remainder is below.
- **16.7 Compose through Python on Android, iOS and wasm — open.** SPEC B-6/N-2: the render proofs
(`ksp-fixtures/compose/src/desktopTest/`) are desktop only.
- **16.8 Two material3 components not reachable from Python — open.** The dynamic `ColorScheme`
factory is Android-only and the class name resolves to a proxy whose constructor wants a handle
(`M3ProofRenderTest.colorSchemeResolvesToItsProxyTypeRatherThanAnythingCallable`). `DropdownMenu`
factory is Android-only. Since #73 the class name resolves to `ColorScheme`'s own constructor (its
`Color` parameters no longer hide it), which needs every colour written out; nothing renders with one
yet (`M3ProofRenderTest.colorSchemeResolvesToItsConstructorAndABareCallNamesItsOverloads`). `DropdownMenu`
renders into a popup layer `ImageComposeScene` does not capture, so it is unverified rather than
known broken (`M3ProofRenderTest.popupLayersAreNotCapturedByImageComposeScene`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,24 @@ class WalkedArtifactStubTest {
assertEquals(emptySet(), properties - propertyKeys, "a property stub names no property key")
assertEquals(emptySet(), propertyKeys - properties, "a property key has no stub")

// A fourth shape (issue #73): a class's own constructor, whose key (`kotlin.text.MatchGroup`) is a
// module attribute at run time, is stubbed as that class's `__init__` so the class keeps its stub
// -- and its docstring carries the key after `Kotlin constructor: `. Matched by key, like a
// property, and counted as stubbed only in the direction "this key has a stub": a marker naming
// no installed key is still "stubbed but not callable" below.
val constructors = generatedPaths()
.filter { it.endsWith(".pyi") }
.flatMap { path ->
Regex("""Kotlin constructor: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList()
}
.toSet()
assertEquals(emptySet(), constructors - installed, "a constructor stub names no table key")

// The base name of an overload set (`padding` for `padding__Dp`, ...) is served by the binding
// layer's dispatcher and is stubbed as `@overload`s; it is not itself a table key.
val overloadBases = installed.map { it.substringBeforeLast('.') + "." + it.substringAfterLast('.').substringBefore("__") }
.filter { it !in installed }.toSet()
assertEquals(emptySet(), stubbed - installed - overloadBases, "stubbed but not callable")
assertEquals(emptySet(), installed - propertyKeys - stubbed, "callable but not stubbed")
assertEquals(emptySet(), installed - propertyKeys - stubbed - constructors, "callable but not stubbed")
}
}
7 changes: 7 additions & 0 deletions ksp-fixtures/compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ pythonBindings {
"androidx.compose.runtime.SnapshotStateKt",
"androidx.compose.runtime.State",
"androidx.compose.runtime.MutableState",
// 7. `TextField(state = ...)`'s state (issue #73): the `TextFieldState` class (its constructor
// and its `text`) and its file facade (`rememberTextFieldState`,
// `setTextAndPlaceCursorAtEnd`, `clearText`). Classes, not the package, for item 6's
// reason: `androidx.compose.foundation.text.input` is the whole new text stack.
// `TextFieldStateRenderTest` is what each one is for.
"androidx.compose.foundation.text.input.TextFieldState",
"androidx.compose.foundation.text.input.TextFieldStateKt",
),
)
// 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
Expand Up @@ -798,32 +798,38 @@ class M3ProofRenderTest {
}

/**
* `ColorScheme` is unreachable, and the message says which of the two reasons reaches Python
* first.
* `ColorScheme` resolves to its **constructor**, and calling it with nothing fails as a call of that
* constructor -- not as a proxy type asking for a handle.
*
* The documented reason is arithmetic: the factory takes thirty-six colour parameters and the
* omission mask holds thirty-one bits, so it cannot be called with defaults. That is true of
* `lightColorScheme`. But the *name* `ColorScheme` resolves to the class, and a bound class is
* a proxy type whose constructor wants the handle of an existing Kotlin instance -- so calling
* it bare fails one step earlier than the parameter count, asking for `handle`.
* This pinned the other answer until issue #73, and said why it might change: "Pinned as observed
* rather than as expected. When this message changes, something about how classes bind changed with
* it." What changed is exactly that. `ColorScheme`'s public constructors take `Color`, a value class,
* so `kotlinc` compiles each to a private `<init>` behind a synthetic `DefaultConstructorMarker`
* bridge, and the walker used to refuse every synthetic `<init>`. It now refuses only a
* `@Deprecated(level = HIDDEN)` one, so `ColorScheme(...)` is bound -- the same way `Typography(...)`
* already was ([typographyChangesFontRender] calls it by its class name).
*
* Pinned as observed rather than as expected. When this message changes, something about how
* classes bind changed with it.
* Why the constructor and not the type: in Kotlin, `ColorScheme(...)` *is* the constructor call, and
* the binder carries Kotlin's surface (U-8). The proxy type answers "build me from a handle", which no
* Python caller can use -- this test was the evidence -- and stays reachable as `type(value)` of
* anything Kotlin returns. `ColorScheme()` with no arguments still fails, because no constructor
* declares a default for its colours; the message now names the constructor's overloads.
*/
@Test
fun colorSchemeResolvesToItsProxyTypeRatherThanAnythingCallable() {
fun colorSchemeResolvesToItsConstructorAndABareCallNamesItsOverloads() {
val error = try {
Python3.exec("from androidx.compose.material3 import ColorScheme; ColorScheme()")
null
} catch (e: Throwable) {
e.message ?: ""
}
println("compose render: ColorScheme() -> ${error?.take(120)}")
assertTrue(error != null, "ColorScheme() succeeded but nothing in the table can build one")
assertTrue(error != null, "ColorScheme() succeeded, but every ColorScheme constructor requires its colours")
assertTrue(
error.contains("handle"),
"expected the proxy type's own constructor to ask for a handle, got: $error",
"ColorScheme__" in error,
"expected the bare call to be refused by ColorScheme's constructor overloads, got: $error",
)
assertTrue("handle" !in error, "ColorScheme still resolves to the proxy type: $error")
}

// ── Helpers ───────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package fixture.compose

import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.ImageComposeScene
import androidx.compose.ui.unit.Density
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.Image
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

/**
* **`TextField(state = ...)` from Python** (issue #73): the state-based text field of Compose 1.11, the
* one pythonx-compose's `TextField` design uses so that typing stays inside Compose.
*
* Everything here is reached through the walk under Kotlin names, with no fixture function between
* Python and Compose:
*
* | Python | Kotlin |
* |---|---|
* | `TextFieldState()`, `TextFieldState('seed')` | the constructor `TextFieldState(initialText = "", initialSelection = TextRange(...))`, outside any composition |
* | `rememberTextFieldState('abc')` | the `@Composable` of that name, inside one |
* | `s.text` | the `CharSequence` property, read as a Python `str` |
* | `s.setTextAndPlaceCursorAtEnd(...)`, `s.clearText()` | the two extension functions, as methods of the state's proxy |
* | `TextField(state=s)` | material3's state overload |
*
* ### Red before the fix
*
* `from androidx.compose.foundation.text.input import TextFieldState` (or `rememberTextFieldState`)
* finds no binding: the fixture did not walk the package, and once it does, the walker dropped the
* constructor (a value-class parameter compiles to a synthetic bridge) and declined `text`
* (`kotlin.CharSequence` had no boundary type). Each test therefore fails at its first `exec` with an
* `ImportError`/`AttributeError`/`TypeError` from Python -- never at a pixel assertion, which is what
* tells that red from a rendering regression.
*/
class TextFieldStateRenderTest {

@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() {
Python3.exec(
"""
for _name in [n for n in globals() if n.startswith('_tfs')]:
del globals()[_name]
""".trimIndent(),
)
UpcallTable.clear()
}

/**
* Made outside a composition, both ways (all defaults left out, and `initialText` given), read back
* as `str`, written through the two extensions -- and drawn: the same `TextField(state=...)` body
* over the empty state and over the written one gives different pixels.
*/
@Test
fun aStateMadeOutsideACompositionIsReadAsStrWrittenByItsExtensionsAndDrawn() {
Python3.exec(
"""
from androidx.compose.foundation.text.input import TextFieldState

_tfs_empty = TextFieldState()
assert type(_tfs_empty)._kotlin_type_name == 'androidx.compose.foundation.text.input.TextFieldState', \
type(_tfs_empty)._kotlin_type_name
assert type(_tfs_empty.text) is str, type(_tfs_empty.text)
assert _tfs_empty.text == '', repr(_tfs_empty.text)

_tfs_seeded = TextFieldState('seed')
assert _tfs_seeded.text == 'seed', repr(_tfs_seeded.text)
_tfs_seeded.clearText()
assert _tfs_seeded.text == '', repr(_tfs_seeded.text)

_tfs = TextFieldState()
_tfs.setTextAndPlaceCursorAtEnd('hello from python')
assert type(_tfs.text) is str and _tfs.text == 'hello from python', repr(_tfs.text)
""".trimIndent(),
)

val empty = pixelsOf(textFieldOver("_tfs_empty"))
val written = pixelsOf(textFieldOver("_tfs"))
val moved = differing(empty, written)
println("compose TextFieldState: ink empty ${inkOf(empty)} -> written ${inkOf(written)} px, $moved pixels differ")
assertTrue(inkOf(empty) > 0, "TextField(state=...) never composed")
assertTrue(moved > 10, "only $moved pixels differ, so the text written into the state never reached the field")
}

/**
* The `initialSelection` slot: `TextRange` is a value class whose constructor is `internal`, so it
* crosses as a handle made by its walked factory `TextRange(index: Int)` -- separate from the test
* above so a failure here says it is this slot.
*
* Spelled by its table key, `TextRange__Int`. The bare name `TextRange` is not the factory's overload
* set in Python: `TextRange.Zero` (a companion constant) makes `androidx.compose.ui.text.TextRange` a
* module, and that module is what `from ... import TextRange` finds -- observed as
* "'_PmModule_androidx_compose_ui_text_TextRange' object is not callable". That collision predates
* issue #73 and is not this test's subject.
*/
@Test
fun theInitialSelectionCrossesAsATextRangeHandle() {
Python3.exec(
"""
from androidx.compose.foundation.text.input import TextFieldState
from androidx.compose.ui.text import TextRange__Int

_tfs_selected = TextFieldState('hello', TextRange__Int(2))
assert _tfs_selected.text == 'hello', repr(_tfs_selected.text)
_tfs_kw = TextFieldState(initialText='kw', initialSelection=TextRange__Int(0))
assert _tfs_kw.text == 'kw', repr(_tfs_kw.text)
""".trimIndent(),
)
}

/**
* Made inside a composition by `rememberTextFieldState`, drawn by `TextField(state=...)` in the same
* body, then written from Python **outside** the composition: the state is Compose snapshot state,
* so the next frame of the *same* scene shows the new text with no host call and no Python body
* re-run being needed. Every value the body saw is the one remembered state (each reads the written
* text).
*/
@Test
fun aRememberedStateIsDrawnAndAWriteFromPythonShowsOnTheNextFrame() {
Python3.exec("_tfs_seen = []")
val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) {
PythonComposition(REMEMBERED)
}
val before: IntArray
val after: IntArray
try {
before = pixelsOf(scene.render())
Python3.exec(
"""
assert len(_tfs_seen) >= 1, _tfs_seen
assert type(_tfs_seen[0].text) is str and _tfs_seen[0].text == 'abc', repr(_tfs_seen[0].text)
_tfs_seen[0].setTextAndPlaceCursorAtEnd('a much longer line of text')
""".trimIndent(),
)
Snapshot.sendApplyNotifications()
after = pixelsOf(scene.render())
Python3.exec(
"""
assert all(_s.text == 'a much longer line of text' for _s in _tfs_seen), \
[_s.text for _s in _tfs_seen]
""".trimIndent(),
)
} finally {
scene.close()
}
val moved = differing(before, after)
println("compose rememberTextFieldState: ink ${inkOf(before)} -> ${inkOf(after)} px, $moved pixels differ")
assertTrue(inkOf(before) > 0, "TextField(state=rememberTextFieldState(...)) never composed")
assertTrue(moved > 10, "only $moved pixels differ, so the write from Python never reached the next frame")
}

private fun textFieldOver(stateName: String): String = """
from androidx.compose.material3 import TextField
TextField(state=$stateName)
""".trimIndent()

private fun pixelsOf(body: String): IntArray {
val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) {
PythonComposition(body)
}
try {
return pixelsOf(scene.render())
} finally {
scene.close()
}
}

private fun pixelsOf(image: Image): IntArray {
val bitmap = Bitmap.makeFromImage(image)
return IntArray(bitmap.width * bitmap.height) { bitmap.getColor(it % bitmap.width, it / bitmap.width) }
}

private fun inkOf(pixels: IntArray): Int = pixels.count { it != 0 }

private fun differing(a: IntArray, b: IntArray): Int {
assertEquals(a.size, b.size)
return a.indices.count { a[it] != b[it] }
}

private companion object {
/** Wide enough for material3's `TextField` and a line of text at density 1. */
const val FIELD_WIDTH = 280
const val FIELD_HEIGHT = 64

val REMEMBERED = """
from androidx.compose.foundation.text.input import rememberTextFieldState
from androidx.compose.material3 import TextField
_tfs_state = rememberTextFieldState('abc')
_tfs_seen.append(_tfs_state)
TextField(state=_tfs_state)
""".trimIndent()
}
}
Loading
Loading