Skip to content

Commit 274a374

Browse files
authored
Merge pull request #84 from thisisthepy/work/textfieldstate
Feat: TextFieldState from Python; bind value-class constructors and CharSequence results
2 parents 81c347a + 01dfbc8 commit 274a374

13 files changed

Lines changed: 688 additions & 31 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,15 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
260260
static (`Arrangement.spacedBy`, #53), except `Any`'s members, ones that also exist as a
261261
`@JvmStatic` static, and `@Composable` ones (declined); an `internal` or file-private object is
262262
skipped. `Status: implemented` — `GP/artifact/ObjectMemberBindingTest.kt`.
263+
A public constructor of a public, non-abstract, non-generic class binds under the class's own name
264+
(`pkg.Class`), including one with a value-class parameter, which `kotlinc` compiles to a private
265+
`<init>` behind a public synthetic bridge ending in `DefaultConstructorMarker` (the descriptor
266+
`@Metadata` records); a `@Deprecated(level = HIDDEN)` constructor, whose bridge carries that
267+
annotation, is skipped (#73). A declared `kotlin.CharSequence` **result** — of a function, a property
268+
getter or an object constant — crosses as a Python `str` (the generated body returns Kotlin's
269+
`toString()` of it; `null` stays `None` for `CharSequence?`); a `CharSequence` parameter is still
270+
declined (#73). `Status: implemented` — `GP/artifact/TextStateBindingTest.kt`,
271+
`ksp-fixtures/compose/.../TextFieldStateRenderTest.kt`.
263272
- **B-2** The walker on **klibs** (Kotlin/Native libraries). `Status: partial` —
264273
`GP/artifact/KlibScannerTest.kt` and `ksp-fixtures/klib-artifact` assert that the scanned klib's
265274
declarations are declined with reasons; no klib declaration is bound at run time yet.
@@ -298,7 +307,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
298307
`GP/stubs/TypedStubTest.kt`, `GP/stubs/KotlinNamesOnlyStubTest.kt`,
299308
`ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`, `.../StubSignatureAgreesWithRuntimeTest.kt`,
300309
`tools/stubs/check-stubs.sh` (mypy over the Compose stubs). A class whose name is also a function in
301-
its module is `Any`. A property is a `@property` (with a setter for a `var`) of its receiver's stub
310+
its module is `Any` — except that a class's own bound constructor is that class's `__init__`
311+
(`@overload`ed for several), so the class keeps its stub, members and properties; a single one's
312+
docstring carries its table key after `Kotlin constructor: `; its `__`-suffixed table-key spellings
313+
stay module functions; a value class bound as its primitive keeps its constructor as a module function
314+
returning the primitive (#73, `GP/artifact/TextStateBindingTest.kt`,
315+
`ksp-fixtures/artifact/.../WalkedArtifactStubTest.kt`). In Python the class name is the constructor,
316+
as `Typography(...)` already was (`M3ProofRenderTest.kt`). A property is a `@property` (with a setter for a `var`) of its receiver's stub
302317
class, its docstring carrying its table key after `Kotlin property: `; where that class has no stub,
303318
a comment with the same marker says so (`GP/stubs/PropertyStubTest.kt`). An object constant is annotated with its
304319
declared type even when that type is nested in the object (`Alignment.End: Horizontal`, a class of the

‎docs/roadmap/ROADMAP.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,9 @@ description, §16e/§16f's open remainder is below.
366366
- **16.7 Compose through Python on Android, iOS and wasm — open.** SPEC B-6/N-2: the render proofs
367367
(`ksp-fixtures/compose/src/desktopTest/`) are desktop only.
368368
- **16.8 Two material3 components not reachable from Python — open.** The dynamic `ColorScheme`
369-
factory is Android-only and the class name resolves to a proxy whose constructor wants a handle
370-
(`M3ProofRenderTest.colorSchemeResolvesToItsProxyTypeRatherThanAnythingCallable`). `DropdownMenu`
369+
factory is Android-only. Since #73 the class name resolves to `ColorScheme`'s own constructor (its
370+
`Color` parameters no longer hide it), which needs every colour written out; nothing renders with one
371+
yet (`M3ProofRenderTest.colorSchemeResolvesToItsConstructorAndABareCallNamesItsOverloads`). `DropdownMenu`
371372
renders into a popup layer `ImageComposeScene` does not capture, so it is unverified rather than
372373
known broken (`M3ProofRenderTest.popupLayersAreNotCapturedByImageComposeScene`).
373374

‎ksp-fixtures/artifact/src/desktopTest/kotlin/fixture/artifact/WalkedArtifactStubTest.kt‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,24 @@ class WalkedArtifactStubTest {
211211
assertEquals(emptySet(), properties - propertyKeys, "a property stub names no property key")
212212
assertEquals(emptySet(), propertyKeys - properties, "a property key has no stub")
213213

214+
// A fourth shape (issue #73): a class's own constructor, whose key (`kotlin.text.MatchGroup`) is a
215+
// module attribute at run time, is stubbed as that class's `__init__` so the class keeps its stub
216+
// -- and its docstring carries the key after `Kotlin constructor: `. Matched by key, like a
217+
// property, and counted as stubbed only in the direction "this key has a stub": a marker naming
218+
// no installed key is still "stubbed but not callable" below.
219+
val constructors = generatedPaths()
220+
.filter { it.endsWith(".pyi") }
221+
.flatMap { path ->
222+
Regex("""Kotlin constructor: ([^\s"]+)""").findAll(stub(path)).map { it.groupValues[1] }.toList()
223+
}
224+
.toSet()
225+
assertEquals(emptySet(), constructors - installed, "a constructor stub names no table key")
226+
214227
// The base name of an overload set (`padding` for `padding__Dp`, ...) is served by the binding
215228
// layer's dispatcher and is stubbed as `@overload`s; it is not itself a table key.
216229
val overloadBases = installed.map { it.substringBeforeLast('.') + "." + it.substringAfterLast('.').substringBefore("__") }
217230
.filter { it !in installed }.toSet()
218231
assertEquals(emptySet(), stubbed - installed - overloadBases, "stubbed but not callable")
219-
assertEquals(emptySet(), installed - propertyKeys - stubbed, "callable but not stubbed")
232+
assertEquals(emptySet(), installed - propertyKeys - stubbed - constructors, "callable but not stubbed")
220233
}
221234
}

‎ksp-fixtures/compose/build.gradle.kts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ pythonBindings {
104104
"androidx.compose.runtime.SnapshotStateKt",
105105
"androidx.compose.runtime.State",
106106
"androidx.compose.runtime.MutableState",
107+
// 7. `TextField(state = ...)`'s state (issue #73): the `TextFieldState` class (its constructor
108+
// and its `text`) and its file facade (`rememberTextFieldState`,
109+
// `setTextAndPlaceCursorAtEnd`, `clearText`). Classes, not the package, for item 6's
110+
// reason: `androidx.compose.foundation.text.input` is the whole new text stack.
111+
// `TextFieldStateRenderTest` is what each one is for.
112+
"androidx.compose.foundation.text.input.TextFieldState",
113+
"androidx.compose.foundation.text.input.TextFieldStateKt",
107114
),
108115
)
109116
// Stubs on: `generatePythonStubs` over the real Compose jars is what `tools/stubs/check-stubs.sh` (the mypy

‎ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/M3ProofRenderTest.kt‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -798,32 +798,38 @@ class M3ProofRenderTest {
798798
}
799799

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

829835
// ── Helpers ───────────────────────────────────────────────────────────────
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package fixture.compose
2+
3+
import androidx.compose.runtime.snapshots.Snapshot
4+
import androidx.compose.ui.ImageComposeScene
5+
import androidx.compose.ui.unit.Density
6+
import org.jetbrains.skia.Bitmap
7+
import org.jetbrains.skia.Image
8+
import python.multiplatform.ffi.Python3
9+
import python.multiplatform.ffi.pythonx.PythonxAdapter
10+
import python.multiplatform.ffi.upcall.UpcallBootstrap
11+
import python.multiplatform.generated.artifacts.ArtifactTable
12+
import python.multiplatform.reflection.UpcallTable
13+
import kotlin.test.AfterTest
14+
import kotlin.test.BeforeTest
15+
import kotlin.test.Test
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertTrue
18+
19+
/**
20+
* **`TextField(state = ...)` from Python** (issue #73): the state-based text field of Compose 1.11, the
21+
* one pythonx-compose's `TextField` design uses so that typing stays inside Compose.
22+
*
23+
* Everything here is reached through the walk under Kotlin names, with no fixture function between
24+
* Python and Compose:
25+
*
26+
* | Python | Kotlin |
27+
* |---|---|
28+
* | `TextFieldState()`, `TextFieldState('seed')` | the constructor `TextFieldState(initialText = "", initialSelection = TextRange(...))`, outside any composition |
29+
* | `rememberTextFieldState('abc')` | the `@Composable` of that name, inside one |
30+
* | `s.text` | the `CharSequence` property, read as a Python `str` |
31+
* | `s.setTextAndPlaceCursorAtEnd(...)`, `s.clearText()` | the two extension functions, as methods of the state's proxy |
32+
* | `TextField(state=s)` | material3's state overload |
33+
*
34+
* ### Red before the fix
35+
*
36+
* `from androidx.compose.foundation.text.input import TextFieldState` (or `rememberTextFieldState`)
37+
* finds no binding: the fixture did not walk the package, and once it does, the walker dropped the
38+
* constructor (a value-class parameter compiles to a synthetic bridge) and declined `text`
39+
* (`kotlin.CharSequence` had no boundary type). Each test therefore fails at its first `exec` with an
40+
* `ImportError`/`AttributeError`/`TypeError` from Python -- never at a pixel assertion, which is what
41+
* tells that red from a rendering regression.
42+
*/
43+
class TextFieldStateRenderTest {
44+
45+
@BeforeTest
46+
fun installProducers() {
47+
Python3.initialize(silent = true)
48+
UpcallTable.clear()
49+
UpcallTable.install(ArtifactTable.fragments)
50+
check(UpcallBootstrap.publishToGlobals()) { "UpcallBootstrap.publishToGlobals() failed" }
51+
PythonxAdapter.install(PYTHONX_RAW_VALUE_CLASSES)
52+
}
53+
54+
@AfterTest
55+
fun cleanup() {
56+
Python3.exec(
57+
"""
58+
for _name in [n for n in globals() if n.startswith('_tfs')]:
59+
del globals()[_name]
60+
""".trimIndent(),
61+
)
62+
UpcallTable.clear()
63+
}
64+
65+
/**
66+
* Made outside a composition, both ways (all defaults left out, and `initialText` given), read back
67+
* as `str`, written through the two extensions -- and drawn: the same `TextField(state=...)` body
68+
* over the empty state and over the written one gives different pixels.
69+
*/
70+
@Test
71+
fun aStateMadeOutsideACompositionIsReadAsStrWrittenByItsExtensionsAndDrawn() {
72+
Python3.exec(
73+
"""
74+
from androidx.compose.foundation.text.input import TextFieldState
75+
76+
_tfs_empty = TextFieldState()
77+
assert type(_tfs_empty)._kotlin_type_name == 'androidx.compose.foundation.text.input.TextFieldState', \
78+
type(_tfs_empty)._kotlin_type_name
79+
assert type(_tfs_empty.text) is str, type(_tfs_empty.text)
80+
assert _tfs_empty.text == '', repr(_tfs_empty.text)
81+
82+
_tfs_seeded = TextFieldState('seed')
83+
assert _tfs_seeded.text == 'seed', repr(_tfs_seeded.text)
84+
_tfs_seeded.clearText()
85+
assert _tfs_seeded.text == '', repr(_tfs_seeded.text)
86+
87+
_tfs = TextFieldState()
88+
_tfs.setTextAndPlaceCursorAtEnd('hello from python')
89+
assert type(_tfs.text) is str and _tfs.text == 'hello from python', repr(_tfs.text)
90+
""".trimIndent(),
91+
)
92+
93+
val empty = pixelsOf(textFieldOver("_tfs_empty"))
94+
val written = pixelsOf(textFieldOver("_tfs"))
95+
val moved = differing(empty, written)
96+
println("compose TextFieldState: ink empty ${inkOf(empty)} -> written ${inkOf(written)} px, $moved pixels differ")
97+
assertTrue(inkOf(empty) > 0, "TextField(state=...) never composed")
98+
assertTrue(moved > 10, "only $moved pixels differ, so the text written into the state never reached the field")
99+
}
100+
101+
/**
102+
* The `initialSelection` slot: `TextRange` is a value class whose constructor is `internal`, so it
103+
* crosses as a handle made by its walked factory `TextRange(index: Int)` -- separate from the test
104+
* above so a failure here says it is this slot.
105+
*
106+
* Spelled by its table key, `TextRange__Int`. The bare name `TextRange` is not the factory's overload
107+
* set in Python: `TextRange.Zero` (a companion constant) makes `androidx.compose.ui.text.TextRange` a
108+
* module, and that module is what `from ... import TextRange` finds -- observed as
109+
* "'_PmModule_androidx_compose_ui_text_TextRange' object is not callable". That collision predates
110+
* issue #73 and is not this test's subject.
111+
*/
112+
@Test
113+
fun theInitialSelectionCrossesAsATextRangeHandle() {
114+
Python3.exec(
115+
"""
116+
from androidx.compose.foundation.text.input import TextFieldState
117+
from androidx.compose.ui.text import TextRange__Int
118+
119+
_tfs_selected = TextFieldState('hello', TextRange__Int(2))
120+
assert _tfs_selected.text == 'hello', repr(_tfs_selected.text)
121+
_tfs_kw = TextFieldState(initialText='kw', initialSelection=TextRange__Int(0))
122+
assert _tfs_kw.text == 'kw', repr(_tfs_kw.text)
123+
""".trimIndent(),
124+
)
125+
}
126+
127+
/**
128+
* Made inside a composition by `rememberTextFieldState`, drawn by `TextField(state=...)` in the same
129+
* body, then written from Python **outside** the composition: the state is Compose snapshot state,
130+
* so the next frame of the *same* scene shows the new text with no host call and no Python body
131+
* re-run being needed. Every value the body saw is the one remembered state (each reads the written
132+
* text).
133+
*/
134+
@Test
135+
fun aRememberedStateIsDrawnAndAWriteFromPythonShowsOnTheNextFrame() {
136+
Python3.exec("_tfs_seen = []")
137+
val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) {
138+
PythonComposition(REMEMBERED)
139+
}
140+
val before: IntArray
141+
val after: IntArray
142+
try {
143+
before = pixelsOf(scene.render())
144+
Python3.exec(
145+
"""
146+
assert len(_tfs_seen) >= 1, _tfs_seen
147+
assert type(_tfs_seen[0].text) is str and _tfs_seen[0].text == 'abc', repr(_tfs_seen[0].text)
148+
_tfs_seen[0].setTextAndPlaceCursorAtEnd('a much longer line of text')
149+
""".trimIndent(),
150+
)
151+
Snapshot.sendApplyNotifications()
152+
after = pixelsOf(scene.render())
153+
Python3.exec(
154+
"""
155+
assert all(_s.text == 'a much longer line of text' for _s in _tfs_seen), \
156+
[_s.text for _s in _tfs_seen]
157+
""".trimIndent(),
158+
)
159+
} finally {
160+
scene.close()
161+
}
162+
val moved = differing(before, after)
163+
println("compose rememberTextFieldState: ink ${inkOf(before)} -> ${inkOf(after)} px, $moved pixels differ")
164+
assertTrue(inkOf(before) > 0, "TextField(state=rememberTextFieldState(...)) never composed")
165+
assertTrue(moved > 10, "only $moved pixels differ, so the write from Python never reached the next frame")
166+
}
167+
168+
private fun textFieldOver(stateName: String): String = """
169+
from androidx.compose.material3 import TextField
170+
TextField(state=$stateName)
171+
""".trimIndent()
172+
173+
private fun pixelsOf(body: String): IntArray {
174+
val scene = ImageComposeScene(width = FIELD_WIDTH, height = FIELD_HEIGHT, density = Density(1f)) {
175+
PythonComposition(body)
176+
}
177+
try {
178+
return pixelsOf(scene.render())
179+
} finally {
180+
scene.close()
181+
}
182+
}
183+
184+
private fun pixelsOf(image: Image): IntArray {
185+
val bitmap = Bitmap.makeFromImage(image)
186+
return IntArray(bitmap.width * bitmap.height) { bitmap.getColor(it % bitmap.width, it / bitmap.width) }
187+
}
188+
189+
private fun inkOf(pixels: IntArray): Int = pixels.count { it != 0 }
190+
191+
private fun differing(a: IntArray, b: IntArray): Int {
192+
assertEquals(a.size, b.size)
193+
return a.indices.count { a[it] != b[it] }
194+
}
195+
196+
private companion object {
197+
/** Wide enough for material3's `TextField` and a line of text at density 1. */
198+
const val FIELD_WIDTH = 280
199+
const val FIELD_HEIGHT = 64
200+
201+
val REMEMBERED = """
202+
from androidx.compose.foundation.text.input import rememberTextFieldState
203+
from androidx.compose.material3 import TextField
204+
_tfs_state = rememberTextFieldState('abc')
205+
_tfs_seen.append(_tfs_state)
206+
TextField(state=_tfs_state)
207+
""".trimIndent()
208+
}
209+
}

0 commit comments

Comments
 (0)