|
| 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