Skip to content

Commit 6de021a

Browse files
authored
Merge pull request #167 from thisisthepy/feat/host-api-names
Refactor: Restore the host API names PythonLauncher, PythonWidget and PythonAppView
2 parents 22fd95c + 3ca65f4 commit 6de021a

7 files changed

Lines changed: 159 additions & 33 deletions

File tree

‎docs/SPEC.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
289289
function result, or a callback argument, gives the Python scalar for a Kotlin `Int`/`Long`/`Short`/`Byte`
290290
(`int`), `Double`/`Float` (`float`), `Boolean` (`bool`), `String`/`Char` (`str`), and a proxy for any
291291
other Kotlin object. `Status: implemented`,
292-
`PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`,
292+
`PM/commonTest/.../pythonx/PythonxPropertyTest.kt`, `ksp-fixtures/compose/.../PythonAppViewRenderTest.kt`,
293293
`AnySlotScalarRenderTest.kt`, `MaterialIconsRenderTest.kt`.
294294
- **U-11** A KSP-bound function, method or property whose declared return type is a Kotlin class that
295295
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.
388388
`CallbackDrivenRenderTest.kt`, pointer/drag render tests), `GP/artifact/ComposableBindingTest.kt`;
389389
`planned` on Android, iOS and wasm.
390390
- **B-8** A host draws a Python-declared application root with
391-
`python.multiplatform.compose.PythonContent(root: PyObject)` or `PythonContent(module, attribute)`,
391+
`python.multiplatform.compose.PythonAppView(modifier, module, attribute)` (or `PythonWidget(composableName, modifier, moduleName)` or `PythonWidget(root: PyObject)`, inside a `PythonLauncher { }` that starts the interpreter),
392392
from the `python-multiplatform-compose` module (`python-multiplatform` itself does not depend on
393393
Compose). The root is a Python callable, or a Compose `State` that Python holds whose value is that
394394
callable. The state is read inside the composition, so a Python write into it replaces the root on
395395
the next frame with no host call; Python callables a root passed into composables are released when
396396
that root is replaced or the composition is disposed. The entry point names no library.
397-
`Status: implemented` on desktop, `ksp-fixtures/compose/.../PythonContentRenderTest.kt`; the module
397+
`Status: implemented` on desktop, `ksp-fixtures/compose/.../PythonAppViewRenderTest.kt`; the module
398398
compiles for Android, nothing runs there yet.
399399
- **B-7** The plugin generates `.pyi` stubs for the Kotlin-named modules only, under Kotlin names
400400
plus their U-12 Pythonic aliases (`fill_max_width = fillMaxWidth` beside each module `def`, a second

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ pythonBindings {
100100
// 6. Compose state, from Python (issue #38): `mutableStateOf` (a generic function, its `T`
101101
// read as `kotlin.Any?`) and the `value` property of `State`/`MutableState`. Classes, not
102102
// the package: `androidx.compose.runtime` is the whole runtime, and this is the one
103-
// facade and two interfaces `PythonContentRenderTest` writes its root through.
103+
// facade and two interfaces `PythonAppViewRenderTest` writes its root through.
104104
"androidx.compose.runtime.SnapshotStateKt",
105105
"androidx.compose.runtime.State",
106106
"androidx.compose.runtime.MutableState",

‎ksp-fixtures/compose/src/desktopMain/kotlin/fixture/compose/PythonComposition.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ import python.multiplatform.ffi.pythonx.PythonCallables
1616
*
1717
* The composer plumbing and the callable holder are no longer this fixture's: they are
1818
* `python-multiplatform-compose`'s `withPythonComposer` and `PythonCallableArena`, which the host
19-
* entry point `PythonContent` is built on too. What stays is executing a *source string* per pass,
19+
* entry point `PythonAppView` is built on too. What stays is executing a *source string* per pass,
2020
* which is a test harness rather than an entry point -- a host draws a Python-declared root with
21-
* `PythonContent` (`PythonContentRenderTest`).
21+
* `PythonAppView` (`PythonAppViewRenderTest`).
2222
*
2323
* ### Why exactly one, and not one per component
2424
*

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import androidx.compose.runtime.MutableState
44
import androidx.compose.runtime.snapshots.Snapshot
55
import androidx.compose.ui.ImageComposeScene
66
import androidx.compose.ui.unit.Density
7-
import python.multiplatform.compose.PythonContent
7+
import python.multiplatform.compose.PythonWidget
88
import python.multiplatform.ffi.Python3
99
import python.multiplatform.ffi.pythonx.PythonxAdapter
1010
import python.multiplatform.ffi.upcall.PythonProxySource
@@ -25,7 +25,7 @@ import kotlin.test.assertTrue
2525
* `Int`, and incrementing it from Python (`counter.value = counter.value + 1`, the walked
2626
* `MutableState.value` getter and setter) recomposes the root that read it.
2727
*
28-
* Modelled on [PythonContentRenderTest]: `ImageComposeScene` has no frame clock, so the test is the
28+
* Modelled on [PythonAppViewRenderTest]: `ImageComposeScene` has no frame clock, so the test is the
2929
* clock (`Snapshot.sendApplyNotifications()` and a render), and the root counts its own invocations so
3030
* a frame can be attributed.
3131
*
@@ -81,7 +81,7 @@ class AnySlotScalarRenderTest {
8181

8282
val root = Python3.import("__main__").getAttr("_asr_root")
8383
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
84-
PythonContent(root = root)
84+
PythonWidget(root = root)
8585
}
8686
try {
8787
scene.render()

ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonContentRenderTest.kt renamed to ksp-fixtures/compose/src/desktopTest/kotlin/fixture/compose/PythonAppViewRenderTest.kt

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import androidx.compose.ui.ImageComposeScene
55
import androidx.compose.ui.unit.Density
66
import org.jetbrains.skia.Bitmap
77
import org.jetbrains.skia.Image
8-
import python.multiplatform.compose.PythonContent
8+
import python.multiplatform.compose.PythonAppView
9+
import python.multiplatform.compose.PythonLauncher
10+
import python.multiplatform.compose.PythonWidget
911
import python.multiplatform.ffi.Python3
1012
import python.multiplatform.ffi.pythonx.PythonCallables
1113
import python.multiplatform.ffi.pythonx.PythonxAdapter
@@ -35,13 +37,13 @@ import kotlin.test.assertTrue
3537
* the next frame shows the new root, and nothing on the Kotlin side was called to make it so. The
3638
* only thing the test does between the write and the frame is what a window's frame clock does on
3739
* its own -- deliver the snapshot's apply notification -- and the per-root call counters show that the
38-
* host's `PythonContent` was not re-entered by anything but that.
40+
* host's `PythonAppView` was not re-entered by anything but that.
3941
*
4042
* The lifetime half follows `ComposableRenderTest.aDisposedCompositionGivesEveryPythonCallableBack`:
4143
* a named Python function crosses as a `content=` slot, its `sys.getrefcount` rises while composed,
4244
* and comes back to its baseline exactly -- above means a leak, below means a double release.
4345
*/
44-
class PythonContentRenderTest {
46+
class PythonAppViewRenderTest {
4547

4648
@BeforeTest
4749
fun installBothProducers() {
@@ -63,7 +65,7 @@ class PythonContentRenderTest {
6365
/**
6466
* Declare, render, replace by a state write, render, replace back, render, dispose.
6567
*
66-
* Reached through `PythonContent(module, attribute)`, the spelling a host that knows only a module
68+
* Reached through `PythonAppView(module, attribute)`, the spelling a host that knows only a module
6769
* name uses.
6870
*/
6971
@Test
@@ -73,7 +75,7 @@ class PythonContentRenderTest {
7375
val baseInner = pyInt("sys.getrefcount(_inner)")
7476

7577
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
76-
PythonContent(module = "__main__", attribute = "_root_state")
78+
PythonAppView(module = "__main__", attribute = "_root_state")
7779
}
7880
val inkA: Int
7981
val inkB: Int
@@ -130,7 +132,7 @@ class PythonContentRenderTest {
130132
val state = Python3.import("__main__").getAttr("_root_state")
131133

132134
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
133-
PythonContent(root = state)
135+
PythonWidget(root = state)
134136
}
135137
val ink: Int
136138
val held: Int
@@ -191,7 +193,7 @@ class PythonContentRenderTest {
191193
fun aPlainPythonCallableIsAFixedRoot() {
192194
val root = Python3.import("__main__").getAttr("_root_a")
193195
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
194-
PythonContent(root = root)
196+
PythonWidget(root = root)
195197
}
196198
val ink: Int
197199
try {
@@ -204,6 +206,60 @@ class PythonContentRenderTest {
204206
assertTrue(ink > 0, "the callable root drew nothing")
205207
}
206208

209+
/** `PythonWidget(name, moduleName = ...)` draws exactly what `PythonWidget(root = <that callable>)` draws. */
210+
@Test
211+
fun aNamedWidgetDrawsTheSamePixelsAsThePassedCallable() {
212+
val root = Python3.import("__main__").getAttr("_root_a")
213+
val byObject = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
214+
PythonWidget(root = root)
215+
}
216+
val inkObject: Int
217+
try {
218+
inkObject = inkOfImage(byObject.render())
219+
} finally {
220+
byObject.close()
221+
}
222+
root.close()
223+
val byName = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
224+
PythonWidget("_root_a", moduleName = "__main__")
225+
}
226+
val inkName: Int
227+
try {
228+
inkName = inkOfImage(byName.render())
229+
} finally {
230+
byName.close()
231+
}
232+
assertTrue(inkObject > 0, "the callable drew nothing")
233+
assertEquals(inkObject, inkName, "the named widget drew different pixels than the callable it names")
234+
assertEquals(2, pyInt("_calls['a']"), "each of the two compositions should have called the root once")
235+
}
236+
237+
/**
238+
* `PythonLauncher` composes its content. The interpreter is already initialised here (the
239+
* `@BeforeTest` of this class, and earlier classes in the same JVM, did it), so what is tested is
240+
* that the launcher leaves a running interpreter alone and draws `PythonAppView` inside it; the
241+
* not-initialised start cannot be arranged safely, as Python cannot be re-initialised after a
242+
* finalize.
243+
*/
244+
@Test
245+
fun aLauncherComposesItsContent() {
246+
Python3.exec("_root_state.value = _root_a")
247+
val scene = ImageComposeScene(width = 200, height = 60, density = Density(1f)) {
248+
PythonLauncher {
249+
PythonAppView(module = "__main__", attribute = "_root_state")
250+
}
251+
}
252+
val ink: Int
253+
try {
254+
ink = inkOfImage(scene.render())
255+
} finally {
256+
scene.close()
257+
}
258+
assertTrue(Python3.isInitialized)
259+
assertTrue(ink > 0, "the content of the launcher drew nothing")
260+
assertEquals(1, pyInt("_calls['a']"))
261+
}
262+
207263
/**
208264
* What a window's frame clock does by itself: deliver the global snapshot's pending writes to the
209265
* recomposer, then draw. `ImageComposeScene` has no clock, so the test is the clock.

‎python-multiplatform-compose/build.gradle.kts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
33

44
/**
55
* The Compose host for python-multiplatform: the `@Composable` entry point that draws a Python-declared
6-
* root (`PythonContent`) and the composer/callable plumbing it shares with every Python call into a
6+
* root (`PythonAppView`, `PythonWidget`, `PythonLauncher`) and the composer/callable plumbing it shares with every Python call into a
77
* composable.
88
*
99
* A separate module so that `:python-multiplatform` itself never depends on Compose (same naming as
@@ -35,6 +35,7 @@ kotlin {
3535
commonMain.dependencies {
3636
api(projects.pythonMultiplatform)
3737
api(compose.runtime)
38+
api(compose.ui)
3839
}
3940
}
4041
}

python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonContent.kt renamed to python-multiplatform-compose/src/commonMain/kotlin/python/multiplatform/compose/PythonAppView.kt

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,79 @@ import androidx.compose.runtime.RememberObserver
55
import androidx.compose.runtime.State
66
import androidx.compose.runtime.key
77
import androidx.compose.runtime.remember
8+
import androidx.compose.ui.Modifier
9+
import androidx.compose.ui.layout.Layout
810
import python.multiplatform.ffi.PyObject
911
import python.multiplatform.ffi.Python3
1012
import python.multiplatform.ffi.pythonx.PythonCallables
1113
import python.multiplatform.reflection.HandleTable
1214
import python.native.ffi.PyLong_AsLongLong
1315

1416
/**
15-
* Composes the Python application root found at [root], and recomposes whenever Python replaces it.
17+
* Starts the Python interpreter if it is not running yet, then composes [content].
18+
*
19+
* This is the first of the three host composables (`PythonLauncher`, [PythonWidget], [PythonAppView])
20+
* and its job is the interpreter start only: wrap the part of the UI that reaches Python in it.
21+
* Registering the composer with the Python runtime is **not** done here once for the whole tree: it
22+
* is done per Python call, by [withPythonComposer], each time a root or widget is composed, so that
23+
* the composer a bound `@Composable` sees is always the one of the composition that called it.
24+
*/
25+
@Composable
26+
fun PythonLauncher(content: @Composable () -> Unit) {
27+
if (!Python3.isInitialized) {
28+
Python3.initialize()
29+
}
30+
content()
31+
}
32+
33+
/**
34+
* The module [PythonAppView] and [PythonWidget] look in when none is named: Python's `__main__`.
35+
*/
36+
const val DEFAULT_PYTHON_MODULE: String = "__main__"
37+
38+
/**
39+
* Composes the Python application root found at `module.attribute`, and recomposes whenever Python
40+
* replaces it. The declarative app root: `PythonAppView(module = "main", attribute = "App")` for a
41+
* Python `main.App`.
42+
*
43+
* `attribute` is a Python callable taking no arguments, or a Compose [State] that Python holds whose
44+
* value is such a callable (or `None` for "nothing yet"); see [PythonAppView] with a `root` for the
45+
* semantics. The attribute is resolved **once**, when this enters the composition (and again only if
46+
* [module] or [attribute] change). Rebinding the module attribute afterwards is not observed;
47+
* replacing the root is a write into the state the attribute names, which is.
48+
*
49+
* [modifier] sizes and places the area the root draws into. The original design's `Surface` and
50+
* colour parameters are left out on purpose: that needs material3, which this module does not depend
51+
* on. Wrap the call in a `Surface` where one is wanted.
52+
*/
53+
@Composable
54+
fun PythonAppView(
55+
modifier: Modifier = Modifier,
56+
module: String = DEFAULT_PYTHON_MODULE,
57+
attribute: String = "App",
58+
) {
59+
val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) }
60+
PythonAppView(resolved.value, modifier)
61+
}
62+
63+
/**
64+
* Composes the named Python composable `moduleName.composableName`: a callable taking no arguments,
65+
* or a Compose [State] that Python holds whose value is one. Same semantics as [PythonAppView] with
66+
* a `root`. Unlike the original design, no Kotlin `content` slot is passed to the callable, and the
67+
* `Surface` parameters are left out (see [PythonAppView]).
68+
*/
69+
@Composable
70+
fun PythonWidget(
71+
composableName: String,
72+
modifier: Modifier = Modifier,
73+
moduleName: String = DEFAULT_PYTHON_MODULE,
74+
) {
75+
val resolved = remember(moduleName, composableName) { ResolvedAttribute(moduleName, composableName) }
76+
PythonWidget(resolved.value, modifier)
77+
}
78+
79+
/**
80+
* [PythonWidget] for a Python object the host already holds.
1681
*
1782
* [root] is one of:
1883
*
@@ -37,28 +102,32 @@ import python.native.ffi.PyLong_AsLongLong
37102
* [root] is the caller's: it is neither consumed nor closed.
38103
*/
39104
@Composable
40-
fun PythonContent(root: PyObject) {
105+
fun PythonWidget(root: PyObject, modifier: Modifier = Modifier) {
41106
val source = remember(root) { RootSource.of(root) }
42107
val current = source.current()
43-
if (current != null) {
44-
key(current) {
45-
PythonRoot(current)
108+
Layout(
109+
content = {
110+
if (current != null) {
111+
key(current) {
112+
PythonRoot(current)
113+
}
114+
}
115+
},
116+
modifier = modifier,
117+
) { measurables, constraints ->
118+
val placeables = measurables.map { it.measure(constraints) }
119+
val width = placeables.maxOfOrNull { it.width } ?: constraints.minWidth
120+
val height = placeables.maxOfOrNull { it.height } ?: constraints.minHeight
121+
layout(width, height) {
122+
placeables.forEach { it.place(0, 0) }
46123
}
47124
}
48125
}
49126

50-
/**
51-
* [PythonContent] for a root state that lives at `module.attribute` -- `PythonContent("main", "app")`
52-
* for a Python `main.app`.
53-
*
54-
* The attribute is resolved **once**, when this enters the composition (and again only if [module]
55-
* or [attribute] change). Rebinding the module attribute afterwards is not observed; replacing the
56-
* root is a write into the state the attribute names, which is.
57-
*/
127+
/** [PythonWidget] with a `root`, under the app-root name. */
58128
@Composable
59-
fun PythonContent(module: String, attribute: String) {
60-
val resolved = remember(module, attribute) { ResolvedAttribute(module, attribute) }
61-
PythonContent(resolved.value)
129+
private fun PythonAppView(root: PyObject, modifier: Modifier) {
130+
PythonWidget(root, modifier)
62131
}
63132

64133
/**

0 commit comments

Comments
 (0)