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
4 changes: 3 additions & 1 deletion docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,9 @@ All in `PM/commonTest`, so they run wherever the interpreter loads.
the composition reaches the screen within four frames. Notebook content with no decided or Kotlin
counterpart is listed, not imitated. `Status: implemented` on desktop: `ksp-fixtures/notebook-e2e/` (20 tests, and a
README with the cell map, the wheel commands and the gaps), 20 passed, 0 failed against the
pythonx-compose `develop` wheel on 2026-10-04 (after #146, #168 and pythonx-compose #88). No CI
pythonx-compose `develop` wheel on 2026-10-04 (after #146, #168 and pythonx-compose #88), and again
with the notebook's own state spelling (`main.App.messages.getValue()` / `setValue(...)`, #174)
against pythonx-compose f6dd3e2. No CI
workflow runs the module yet.

## 7. Threading and builds
Expand Down
4 changes: 3 additions & 1 deletion ksp-fixtures/notebook-e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ regression fails under the notebook cell's number.
The specification is the user's notebook, `pythonx-compose/UI.ipynb` (read in place, never copied).
Where pythonx-compose has since decided a different spelling (its `docs/INTENT.md` §5 and
`docs/SPEC.md`), the tests use the decided one: `@app` and an `app_root` state instead of
`main.App.update(...)`; `.value` instead of `getValue()`/`setValue()`; Kotlin parameter names in
`main.App.update(...)`. The UI state is the notebook's own: `remember_saveable` inside the root,
attached as `App.messages`, read and written with `getValue()`/`setValue()` (`.value` is an alias),
which needs `python.multiplatform.compose.RememberSaveableKt` in the bindings. Kotlin parameter names in
snake_case; `Color(0xFF...)`; `DefaultIcons.Add` without parentheses;
`TextField(state=..., modifier=Modifier.padding(8))`.

Expand Down
1 change: 1 addition & 0 deletions ksp-fixtures/notebook-e2e/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ pythonBindings {
"androidx.compose.runtime.MutableState",
"androidx.compose.foundation.text.input.TextFieldState",
"androidx.compose.foundation.text.input.TextFieldStateKt",
"python.multiplatform.compose.RememberSaveableKt",
),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,15 @@ class NotebookRootTest {
try {
assertEquals(0, differing(pixelsOf(scene.render()), initial), "the app's first frame is not its initial message")

// Cells 9-10.
cell("message_backup = main.messages.value")
// Cell 9: the state the root remembered is reachable as main.App.messages.
assertEquals("True", pyStr("hasattr(main.App, 'messages')"), "cell 9: main.App.messages does not exist after the first frame")
// Cell 10.
cell("message_backup = main.App.messages.getValue()")
assertEquals("<class 'str'>", pyStr("type(message_backup)"), "the state did not read back as a str")
assertEquals(INITIAL_MESSAGE, pyStr("message_backup"), "cell 10 read something other than what is on screen")

// Cell 12.
cell("main.messages.value = '$CHANGED_MESSAGE'")
cell("main.App.messages.setValue('$CHANGED_MESSAGE')")
val (afterWrite, writeFrames) = framesUntil({ scene.render() }) { it.contentEquals(changed) }
assertEquals(
0, differing(afterWrite, changed),
Expand All @@ -106,13 +108,14 @@ class NotebookRootTest {
assertEquals(2, pyInt("main.calls['App']"), "the write did not recompose the root exactly once")

// Cell 13.
cell("main.messages.value = message_backup")
cell("main.App.messages.setValue(message_backup) # 복원")
val (afterRestore, restoreFrames) = framesUntil({ scene.render() }) { it.contentEquals(initial) }
assertEquals(
0, differing(afterRestore, initial),
"cell 13: after $restoreFrames frame(s) the screen does not show the restored message",
)
assertEquals(INITIAL_MESSAGE, pyStr("main.messages.value"))
assertEquals(INITIAL_MESSAGE, pyStr("main.App.messages.getValue()"))
assertEquals(INITIAL_MESSAGE, pyStr("main.App.messages.value"), ".value is not an alias of getValue()")
} finally {
scene.close()
}
Expand Down
19 changes: 7 additions & 12 deletions ksp-fixtures/notebook-e2e/src/desktopTest/python/main.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,25 @@
"""The application module `UI.ipynb` imports as `main` (cell 6), written with pythonx-compose's API.

The notebook predates two decisions (pythonx-compose `docs/INTENT.md` section 5.1), and this module
follows them rather than the notebook's text:

- The screen is *declared* with `@app`; there is no `main.App.update(...)`. Redeclaring a root in a
notebook cell is what replaces the screen.
- State is a Compose state read and written through `.value` (`messages.value`), not
`getValue()` / `setValue()`, and it lives at module level (`main.messages`) rather than as an
attribute of the root function (`main.App.messages`).
It follows the notebook's own spelling, as pythonx-compose ships it: the screen is *declared* with
`@app` (there is no `main.App.update(...)`), and the UI state is the root's `remember_saveable`
value, which the app attaches to the function itself (`App.messages`), so the notebook reads and
writes it as `main.App.messages.getValue()` / `setValue(...)` (cells 9-13).

`calls` counts how often each root ran, so a test can tell a recomposition from a redeclaration and
an idle frame from a poll. It is the test's instrument, not part of the notebook.
"""

from pythonx.compose.runtime import Composable, app, state
from pythonx.compose.runtime import Composable, app, remember_saveable
from pythonx.compose.material3 import Text

INITIAL_MESSAGE = "안녕하세요, Python 앱입니다."

messages = state(INITIAL_MESSAGE)

calls = {"App": 0}


@app
@Composable
def App():
calls["App"] += 1
Text(messages.value)
App.messages = messages = remember_saveable(INITIAL_MESSAGE)
Text(messages.getValue())
Loading