diff --git a/docs/SPEC.md b/docs/SPEC.md index 91395f10..09f217f3 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -289,7 +289,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. function result, or a callback argument, gives the Python scalar for a Kotlin `Int`/`Long`/`Short`/`Byte` (`int`), `Double`/`Float` (`float`), `Boolean` (`bool`), `String`/`Char` (`str`), and a proxy for any other Kotlin object. `Status: implemented`, - `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`, + `PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonAppViewRenderTest.kt`, `AnySlotScalarRenderTest.kt`, `MaterialIconsRenderTest.kt`. - **U-11** A KSP-bound function, method or property whose declared return type is a Kotlin class that has a generated proxy returns an instance of **that class's proxy** (#94), not a generic owner: its @@ -388,13 +388,13 @@ All in `PM/commonTest`, so they run wherever the interpreter loads. `CallbackDrivenRenderTest.kt`, pointer/drag render tests), `GP/artifact/ComposableBindingTest.kt`; `planned` on Android, iOS and wasm. - **B-8** A host draws a Python-declared application root with - `python.multiplatform.compose.PythonContent(root: PyObject)` or `PythonContent(module, attribute)`, + `python.multiplatform.compose.PythonAppView(modifier, module, attribute)` (or `PythonWidget(composableName, modifier, moduleName)` or `PythonWidget(root: PyObject)`, inside a `PythonLauncher { }` that starts the interpreter), from the `python-multiplatform-compose` module (`python-multiplatform` itself does not depend on Compose). The root is a Python callable, or a Compose `State` that Python holds whose value is that callable. The state is read inside the composition, so a Python write into it replaces the root on the next frame with no host call; Python callables a root passed into composables are released when that root is replaced or the composition is disposed. The entry point names no library. - `Status: implemented` on desktop, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module + `Status: implemented` on desktop, `ksp-fixtures/compose/.../PythonAppViewRenderTest.kt`; the module compiles for Android, nothing runs there yet. - **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names plus their U-12 Pythonic aliases (`fill_max_width = fillMaxWidth` beside each module `def`, a second diff --git a/ksp-fixtures/compose/build.gradle.kts b/ksp-fixtures/compose/build.gradle.kts index 9a111a31..b05cf146 100644 --- a/ksp-fixtures/compose/build.gradle.kts +++ b/ksp-fixtures/compose/build.gradle.kts @@ -100,7 +100,7 @@ pythonBindings { // 6. Compose state, from Python (issue #38): `mutableStateOf` (a generic function, its `T` // read as `kotlin.Any?`) and the `value` property of `State`/`MutableState`. Classes, not // the package: `androidx.compose.runtime` is the whole runtime, and this is the one - // facade and two interfaces `PythonContentRenderTest` writes its root through. + // facade and two interfaces `PythonAppViewRenderTest` writes its root through. "androidx.compose.runtime.SnapshotStateKt", "androidx.compose.runtime.State", "androidx.compose.runtime.MutableState", diff --git a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt index 28ce7a08..31d8dda1 100644 --- a/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt +++ b/ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt @@ -16,9 +16,9 @@ import python.multiplatform.ffi.pythonx.PythonCallables * * The composer plumbing and the callable holder are no longer this fixture's: they are * `python-multiplatform-compose`'s `withPythonComposer` and `PythonCallableArena`, which the host - * entry point `PythonContent` is built on too. What stays is executing a *source string* per pass, + * entry point `PythonAppView` is built on too. What stays is executing a *source string* per pass, * which is a test harness rather than an entry point -- a host draws a Python-declared root with - * `PythonContent` (`PythonContentRenderTest`). + * `PythonAppView` (`PythonAppViewRenderTest`). * * ### Why exactly one, and not one per component * diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt index 80b873ad..9071ee23 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/AnySlotScalarRenderTest.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.unit.Density -import python.multiplatform.compose.PythonContent +import python.multiplatform.compose.PythonWidget import python.multiplatform.ffi.Python3 import python.multiplatform.ffi.pythonx.PythonxAdapter import python.multiplatform.ffi.upcall.PythonProxySource @@ -25,7 +25,7 @@ import kotlin.test.assertTrue * `Int`, and incrementing it from Python (`counter.value = counter.value + 1`, the walked * `MutableState.value` getter and setter) recomposes the root that read it. * - * Modelled on [PythonContentRenderTest]: `ImageComposeScene` has no frame clock, so the test is the + * Modelled on [PythonAppViewRenderTest]: `ImageComposeScene` has no frame clock, so the test is the * clock (`Snapshot.sendApplyNotifications()` and a render), and the root counts its own invocations so * a frame can be attributed. * @@ -81,7 +81,7 @@ class AnySlotScalarRenderTest { val root = Python3.import("__main__").getAttr("_asr_root") val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { - PythonContent(root = root) + PythonWidget(root = root) } try { scene.render() diff --git a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonAppViewRenderTest.kt similarity index 81% rename from ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt rename to ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonAppViewRenderTest.kt index 95bf6abd..8c5e45aa 100644 --- a/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt +++ b/ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonAppViewRenderTest.kt @@ -5,7 +5,9 @@ import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.unit.Density import org.jetbrains.skia.Bitmap import org.jetbrains.skia.Image -import python.multiplatform.compose.PythonContent +import python.multiplatform.compose.PythonAppView +import python.multiplatform.compose.PythonLauncher +import python.multiplatform.compose.PythonWidget import python.multiplatform.ffi.Python3 import python.multiplatform.ffi.pythonx.PythonCallables import python.multiplatform.ffi.pythonx.PythonxAdapter @@ -35,13 +37,13 @@ import kotlin.test.assertTrue * the next frame shows the new root, and nothing on the Kotlin side was called to make it so. The * only thing the test does between the write and the frame is what a window's frame clock does on * its own -- deliver the snapshot's apply notification -- and the per-root call counters show that the - * host's `PythonContent` was not re-entered by anything but that. + * host's `PythonAppView` was not re-entered by anything but that. * * The lifetime half follows `ComposableRenderTest.aDisposedCompositionGivesEveryPythonCallableBack`: * a named Python function crosses as a `content=` slot, its `sys.getrefcount` rises while composed, * and comes back to its baseline exactly -- above means a leak, below means a double release. */ -class PythonContentRenderTest { +class PythonAppViewRenderTest { @BeforeTest fun installBothProducers() { @@ -63,7 +65,7 @@ class PythonContentRenderTest { /** * Declare, render, replace by a state write, render, replace back, render, dispose. * - * Reached through `PythonContent(module, attribute)`, the spelling a host that knows only a module + * Reached through `PythonAppView(module, attribute)`, the spelling a host that knows only a module * name uses. */ @Test @@ -73,7 +75,7 @@ class PythonContentRenderTest { val baseInner = pyInt("sys.getrefcount(_inner)") val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { - PythonContent(module = "__main__", attribute = "_root_state") + PythonAppView(module = "__main__", attribute = "_root_state") } val inkA: Int val inkB: Int @@ -130,7 +132,7 @@ class PythonContentRenderTest { val state = Python3.import("__main__").getAttr("_root_state") val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { - PythonContent(root = state) + PythonWidget(root = state) } val ink: Int val held: Int @@ -191,7 +193,7 @@ class PythonContentRenderTest { fun aPlainPythonCallableIsAFixedRoot() { val root = Python3.import("__main__").getAttr("_root_a") val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { - PythonContent(root = root) + PythonWidget(root = root) } val ink: Int try { @@ -204,6 +206,60 @@ class PythonContentRenderTest { assertTrue(ink > 0, "the callable root drew nothing") } + /** `PythonWidget(name, moduleName = ...)` draws exactly what `PythonWidget(root = )` draws. */ + @Test + fun aNamedWidgetDrawsTheSamePixelsAsThePassedCallable() { + val root = Python3.import("__main__").getAttr("_root_a") + val byObject = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonWidget(root = root) + } + val inkObject: Int + try { + inkObject = inkOfImage(byObject.render()) + } finally { + byObject.close() + } + root.close() + val byName = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonWidget("_root_a", moduleName = "__main__") + } + val inkName: Int + try { + inkName = inkOfImage(byName.render()) + } finally { + byName.close() + } + assertTrue(inkObject > 0, "the callable drew nothing") + assertEquals(inkObject, inkName, "the named widget drew different pixels than the callable it names") + assertEquals(2, pyInt("_calls['a']"), "each of the two compositions should have called the root once") + } + + /** + * `PythonLauncher` composes its content. The interpreter is already initialised here (the + * `@BeforeTest` of this class, and earlier classes in the same JVM, did it), so what is tested is + * that the launcher leaves a running interpreter alone and draws `PythonAppView` inside it; the + * not-initialised start cannot be arranged safely, as Python cannot be re-initialised after a + * finalize. + */ + @Test + fun aLauncherComposesItsContent() { + Python3.exec("_root_state.value = _root_a") + val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) { + PythonLauncher { + PythonAppView(module = "__main__", attribute = "_root_state") + } + } + val ink: Int + try { + ink = inkOfImage(scene.render()) + } finally { + scene.close() + } + assertTrue(Python3.isInitialized) + assertTrue(ink > 0, "the content of the launcher drew nothing") + assertEquals(1, pyInt("_calls['a']")) + } + /** * What a window's frame clock does by itself: deliver the global snapshot's pending writes to the * recomposer, then draw. `ImageComposeScene` has no clock, so the test is the clock. diff --git a/python-multiplatform-compose/build.gradle.kts b/python-multiplatform-compose/build.gradle.kts index bee7b950..0b25b3f0 100644 --- a/python-multiplatform-compose/build.gradle.kts +++ b/python-multiplatform-compose/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget /** * The Compose host for python-multiplatform: the `@Composable` entry point that draws a Python-declared - * root (`PythonContent`) and the composer/callable plumbing it shares with every Python call into a + * root (`PythonAppView`, `PythonWidget`, `PythonLauncher`) and the composer/callable plumbing it shares with every Python call into a * composable. * * A separate module so that `:python-multiplatform` itself never depends on Compose (same naming as @@ -35,6 +35,7 @@ kotlin { commonMain.dependencies { api(projects.pythonMultiplatform) api(compose.runtime) + api(compose.ui) } } } diff --git a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonAppView.kt similarity index 58% rename from python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt rename to python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonAppView.kt index 8de24e3e..59cb1622 100644 --- a/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt +++ b/python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonAppView.kt @@ -5,6 +5,8 @@ import androidx.compose.runtime.RememberObserver import androidx.compose.runtime.State import androidx.compose.runtime.key import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout import python.multiplatform.ffi.PyObject import python.multiplatform.ffi.Python3 import python.multiplatform.ffi.pythonx.PythonCallables @@ -12,7 +14,70 @@ import python.multiplatform.reflection.HandleTable import python.native.ffi.PyLong_AsLongLong /** - * Composes the Python application root found at [root], and recomposes whenever Python replaces it. + * Starts the Python interpreter if it is not running yet, then composes [content]. + * + * This is the first of the three host composables (`PythonLauncher`, [PythonWidget], [PythonAppView]) + * and its job is the interpreter start only: wrap the part of the UI that reaches Python in it. + * Registering the composer with the Python runtime is **not** done here once for the whole tree: it + * is done per Python call, by [withPythonComposer], each time a root or widget is composed, so that + * the composer a bound `@Composable` sees is always the one of the composition that called it. + */ +@Composable +fun PythonLauncher(content: @Composable () -> Unit) { + if (!Python3.isInitialized) { + Python3.initialize() + } + content() +} + +/** + * The module [PythonAppView] and [PythonWidget] look in when none is named: Python's `__main__`. + */ +const val DEFAULT_PYTHON_MODULE: String = "__main__" + +/** + * Composes the Python application root found at `module.attribute`, and recomposes whenever Python + * replaces it. The declarative app root: `PythonAppView(module = "main", attribute = "App")` for a + * Python `main.App`. + * + * `attribute` is a Python callable taking no arguments, or a Compose [State] that Python holds whose + * value is such a callable (or `None` for "nothing yet"); see [PythonAppView] with a `root` for the + * semantics. The attribute is resolved **once**, when this enters the composition (and again only if + * [module] or [attribute] change). Rebinding the module attribute afterwards is not observed; + * replacing the root is a write into the state the attribute names, which is. + * + * [modifier] sizes and places the area the root draws into. The original design's `Surface` and + * colour parameters are left out on purpose: that needs material3, which this module does not depend + * on. Wrap the call in a `Surface` where one is wanted. + */ +@Composable +fun PythonAppView( + modifier: Modifier = Modifier, + module: String = DEFAULT_PYTHON_MODULE, + attribute: String = "App", +) { + val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) } + PythonAppView(resolved.value, modifier) +} + +/** + * Composes the named Python composable `moduleName.composableName`: a callable taking no arguments, + * or a Compose [State] that Python holds whose value is one. Same semantics as [PythonAppView] with + * a `root`. Unlike the original design, no Kotlin `content` slot is passed to the callable, and the + * `Surface` parameters are left out (see [PythonAppView]). + */ +@Composable +fun PythonWidget( + composableName: String, + modifier: Modifier = Modifier, + moduleName: String = DEFAULT_PYTHON_MODULE, +) { + val resolved = remember(moduleName, composableName) { ResolvedAttribute(moduleName, composableName) } + PythonWidget(resolved.value, modifier) +} + +/** + * [PythonWidget] for a Python object the host already holds. * * [root] is one of: * @@ -37,28 +102,32 @@ import python.native.ffi.PyLong_AsLongLong * [root] is the caller's: it is neither consumed nor closed. */ @Composable -fun PythonContent(root: PyObject) { +fun PythonWidget(root: PyObject, modifier: Modifier = Modifier) { val source = remember(root) { RootSource.of(root) } val current = source.current() - if (current != null) { - key(current) { - PythonRoot(current) + Layout( + content = { + if (current != null) { + key(current) { + PythonRoot(current) + } + } + }, + modifier = modifier, + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val width = placeables.maxOfOrNull { it.width } ?: constraints.minWidth + val height = placeables.maxOfOrNull { it.height } ?: constraints.minHeight + layout(width, height) { + placeables.forEach { it.place(0, 0) } } } } -/** - * [PythonContent] for a root state that lives at `module.attribute` -- `PythonContent("main", "app")` - * for a Python `main.app`. - * - * The attribute is resolved **once**, when this enters the composition (and again only if [module] - * or [attribute] change). Rebinding the module attribute afterwards is not observed; replacing the - * root is a write into the state the attribute names, which is. - */ +/** [PythonWidget] with a `root`, under the app-root name. */ @Composable -fun PythonContent(module: String, attribute: String) { - val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) } - PythonContent(resolved.value) +private fun PythonAppView(root: PyObject, modifier: Modifier) { + PythonWidget(root, modifier) } /**