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
6 changes: 3 additions & 3 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ksp-fixtures/compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -204,6 +206,60 @@ class PythonContentRenderTest {
assertTrue(ink > 0, "the callable root drew nothing")
}

/** `PythonWidget(name, moduleName = ...)` draws exactly what `PythonWidget(root = <that callable>)` 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.
Expand Down
3 changes: 2 additions & 1 deletion python-multiplatform-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -35,6 +35,7 @@ kotlin {
commonMain.dependencies {
api(projects.pythonMultiplatform)
api(compose.runtime)
api(compose.ui)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,79 @@ 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
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:
*
Expand All @@ -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)
}

/**
Expand Down
Loading