diff --git a/PROJECT.md b/PROJECT.md index 38c78da..424419d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -109,7 +109,7 @@ uv run python .github/scripts/check_guide.py # 가이드: HTML 파싱, 링크 ## 7. 열린 질문 (메인테이너 결정 필요) -1. **코루틴 스코프**: 노트북이 import 하는 `DefaultCoroutineScope`·`MainCoroutineScope`. +1. *(결정됨, 아래.)* 코루틴 스코프와 `state()` 유지 여부. 2. **`pythonx-map.toml` 의 이중 표기**: `pythonx.compose.layout` 과 `pythonx.compose.foundation.layout` 를 둘 다 유지할지. 3. **릴리스·Pages 활성화**: `.github/scripts/release/` 와 `.github/workflows/`(release-sync, pages)는 @@ -139,9 +139,20 @@ uv run python .github/scripts/check_guide.py # 가이드: HTML 파싱, 링크 ### 결정됨 (2026-10-03, `docs/INTENT.md` §5) -- **선언형 앱 루트, 갱신 함수 없음.** 노트북의 `main.App.update(...)`·`getValue()`/`setValue()` 는 - 당시 구현의 제약이었다. 루트는 선언하고 다시 정의하면 화면이 따라가며, 상태는 Compose 상태 모델을 - 따른다(#11). +- **선언형 앱 루트, 갱신 함수 없음.** 노트북의 `main.App.update(...)` 는 당시 구현의 제약이었다. 루트는 + 선언하고 다시 정의하면 화면이 따라간다(#11). 결정은 여기까지다. **정정(2026-10-04):** 상태를 모듈 + 수준(`main.messages`)으로 옮긴 것, `remember_saveable` 을 "제공하지 않음"으로 둔 것, `getValue()`/ + `setValue()` 를 구현 제약으로 본 것은 메인테이너 결정이 아니었다(전달 과정의 과장, #100). +- **`remember_saveable` 과 노트북의 상태 표기 그대로**(2026-10-04, INTENT §5.9). Compose + `rememberSaveable` 의미(회전·프로세스 재시작 후 유지), int/long/bool/float/str 부터. 앱이 2024 데모처럼 + `App.messages = messages = remember_saveable("")` 로 루트에 직접 붙이고, 노트북 9~13 셀이 + `main.App.messages.getValue()`/`setValue(...)` 로 쓰인 그대로 동작한다. `.value` 는 별칭. Kotlin 쪽은 + python-multiplatform-compose 의 `@Composable rememberSaveableWrapper`(리더의 PMP 이슈). +- **코루틴 스코프**(2026-10-04, INTENT §5.10): `DefaultCoroutineScope`·`MainCoroutineScope` 는 + pythonx-concurrent 위에 얹는 이름이며, pythonx-concurrent 이후에 구현한다. +- **`state()` 폐기**(2026-10-04, INTENT §5.11, #101). Kotlin 의 `mutable_state_of` 가 같은 일을 하므로 공개 + API 에서 지우며 별칭·호환은 두지 않는다. 화면 상태는 `App` 안의 `remember_saveable` 이나 + `mutable_state_of` 로 둔다. `app_root` 의 상태는 패키지 내부 함수로 남긴다. - **`Column`·`Row`·`Spacer`** 는 `pythonx.compose.material3` 와 `pythonx.compose.layout` 양쪽에서 import 된다. - **정렬 상수** 는 `Alignment.End` 와 `Alignment.Horizontal.End` 둘 다 지원한다. diff --git a/docs/INTENT.md b/docs/INTENT.md index 65177f4..d0cde2d 100644 --- a/docs/INTENT.md +++ b/docs/INTENT.md @@ -128,30 +128,33 @@ types) even though the runtime resolves bindings on demand. These are recorded so that `docs/SPEC.md` does not settle them by accident. -1. **Coroutine scopes** the notebook imports, `DefaultCoroutineScope` / `MainCoroutineScope`. - (The other convenience spellings are decided, §5.4–5.7.) +1. *(Decided, §5.10.)* **Coroutine scopes** the notebook imports, `DefaultCoroutineScope` / + `MainCoroutineScope`. 2. *(Decided, #60.)* **The `test/` directory**: a 2023–2024 Kotlin Multiplatform sample (`pycomposeui`). Deleted; the tag `archive/pre-restructure` keeps it. +3. *(Decided, §5.11.)* **Whether `state()` stays.** -## 5. Decided (2026-10-03) +## 5. Decided (2026-10-03, 2026-10-04) Settled by the maintainer, relayed through the ecosystem lead, and recorded here so the spec follows them. -1. **A declared app root, no update function.** The notebook's `main.App.update(...)` and - `getValue()` / `setValue()` calls were the constraints of the implementation at the time, not the - specification. The maintainer, verbatim: +1. **A declared app root, no update function.** The notebook's `main.App.update(...)` was the + constraint of the implementation at the time, not the specification. The maintainer, verbatim: > "main.App.update() 말고 좀 더 선언형으로 갈 수 있는 API로 해줘. UI.ipynb에 그렇게 되어 있는건 > 어쩔 수 없는 구현이었고 내가 원한는건 좀 더 선언형 형식이었어. 업데이트 함수가 명시적으로 > 존재하면 안되는거잖아." - What the notebook shows is the intent: redefining the UI in a cell changes the screen, and the - notebook and the screen see the same state. So the public API has **no explicit refresh call**. - The root is *declared*; redefining it is what changes the screen. State follows Compose's state - model: a composable that reads a state object recomposes when it changes, and state is read and - written through Pythonic attributes rather than Java-style accessors. `main` itself is the + The decision is exactly that: the root is *declared*, redefining it in a cell is what changes + the screen, and the public API has **no explicit update or refresh call**. `main` itself is the application's module, not this package; this package provides the mechanism. + + **Correction (2026-10-04).** An earlier version of this item went further than the words above. + It treated the notebook's `getValue()` / `setValue()` as the old implementation's constraint, it + moved the state from the root (`main.App.messages`) to module level (`main.messages`), and it + made `remember_saveable` "not provided". The maintainer decided none of these; the relay + overstated the instruction. The state's spelling and place are §5.9. 2. **`Column`, `Row`, `Spacer` are importable from `pythonx.compose.material3`** as the notebook writes, and from their Kotlin home (`pythonx.compose.layout`) as well. 3. **Alignment constants in both spellings:** Kotlin's flat `Alignment.End`, and the notebook's @@ -178,3 +181,24 @@ them. `field.set_text_and_place_cursor_at_end("x")`, `field.clear_text()`. `TextField` itself is androidx's, re-exported by rule, with no per-widget wrapper (§2.4). The input field's state is separate from state a screen streams output into. +9. **`remember_saveable`, and the notebook's state spelling as written** (2026-10-04). In 2024 the + demo the notebook ran against (PyREPL `abc6952`, `main.py`) attached its state to its root + itself: `cls.messages = messages = remember_saveable("")`. + - `remember_saveable(initial)` has Compose `rememberSaveable`'s meaning: the value survives + rotation and process restart. `int`, `long` (an `int` beyond 32 bits), `bool`, `float` and + `str` come first, as in pycomposeui. It works only inside a composition, as in Kotlin; called + outside one, it is an error. + - Notebook cells 9 to 13 work as written. The app attaches the state to its root, as the 2024 + demo did (`App.messages = messages = remember_saveable("")` inside `def App():`; no framework + lookup), and the notebook reads and writes it as `main.App.messages.getValue()` and + `main.App.messages.setValue(...)`. `.value` reads and writes the same state, as the Pythonic + alias. + - The root stays declared with `@app`, with no update function (§5.1). +10. **Coroutine scopes are built on pythonx-concurrent** (2026-10-04). `DefaultCoroutineScope` and + `MainCoroutineScope` are names in `pythonx.compose.runtime` on top of `pythonx.concurrent` + (scopes, jobs, `Dispatchers`). They are implemented after pythonx-concurrent. +11. **`state()` is removed** (2026-10-04). Kotlin's `mutableStateOf` (`mutable_state_of`) does the + same job, so `pythonx.compose.runtime` does not export a second name for it, and there is no + alias or compatibility shim. A screen keeps state the notebook's way, with `remember_saveable` + inside `App` (§5.9), or with `mutable_state_of`. The app root's own state stays internal to this + package. diff --git a/docs/SPEC.md b/docs/SPEC.md index 213580c..a4589a7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -297,10 +297,11 @@ python-multiplatform #26 diagnosis). `pythonx.compose.runtime` provides: is imported; afterwards it is an ordinary module attribute, the same object every time. - `@app`: `app(fn)` sets `app_root.value = fn` and returns `fn` unchanged. Declaring the root again replaces the value, so the screen follows. There is no update or refresh function (INTENT §5.1). -- `state(initial)`: `mutableStateOf(initial)`, read and written through `.value`; no `getValue` or - `setValue`. +- `state(initial)` is **removed** (INTENT §5.11, issue #101; `planned` until that lands): Kotlin's + `mutable_state_of` does the same job. A screen keeps state with `remember_saveable` inside `App` + (S5.6) or with `mutable_state_of`. `_new_state` stays as the internal function behind `app_root`. -Both states come from one internal function, `_new_state`. When the binder cannot supply +Until #101 lands, both states come from one internal function, `_new_state`. When the binder cannot supply `mutableStateOf` it raises `RuntimeError` naming python-multiplatform #38. No pure-Python state is a fallback: it would not make Compose recompose. Names other than `app_root` still resolve through the re-export rule, and the module's own names win. @@ -402,6 +403,57 @@ reads the committed text. Not exercised there: the AWT layer that decodes an `In which needs a window. Not modelled here: a function-typed slot (`label`, `on_value_change`), which needs the binder's `NewFunction` rows. +### S5.6 `remember_saveable`, and the notebook's state spelling: `planned` + +INTENT §5.9 (2026-10-04). The 2024 demo the notebook ran against (PyREPL `abc6952`) wrote +`cls.messages = messages = remember_saveable("")` in its root, and `UI.ipynb` cells 9 to 13 read +`main.App.messages`, `main.App.messages.getValue()` and `main.App.messages.setValue(...)`. + +- `remember_saveable(initial)`, from `pythonx.compose.runtime`, calls the host's `@Composable` + `rememberSaveableWrapper(init, type)` (python-multiplatform's `python-multiplatform-compose`, + package `python.multiplatform.compose`, exposed through the binder; python-multiplatform #174; + this repository's #102). It follows pycomposeui's `rememberSaveableWrapper`: `type` is + chosen in Python from the initial value, and Kotlin wraps the matching state in + `rememberSaveable { ... }`: + + | Python value | `type` | Kotlin state | + |---|---|---| + | `bool` | `"bool"` | `mutableStateOf(Boolean)` | + | `int` within 32 bits | `"int"` | `mutableIntStateOf` | + | `int` beyond 32 bits, within 64 | `"long"` | `mutableLongStateOf` | + | `float` | `"float"` | `mutableDoubleStateOf` | + | `str` | `"str"` | `mutableStateOf(String)` | + + `bool` is tested before `int`, because a Python `bool` is an `int`. (pycomposeui sent `"bool"` + from Python but matched `"boolean"` in Kotlin, so its booleans fell through to the generic + branch.) Any other value is a `TypeError` here until a saver for it is specified, and an `int` + beyond 64 bits is a `TypeError` too. +- It returns a thin wrapper over the Kotlin `MutableState`, with `getValue()`, `setValue(value)` and + `.value` (read and write). Every read goes through `MutableState.value`, so Compose records the + snapshot read and recomposes on a write; the wrapper holds nothing of its own. +- It works only inside a composition, as in Kotlin: called outside one, it raises `RuntimeError`. +- How `main.App.messages` exists: the app attaches it, as the 2024 demo did. Inside + `def App():` it writes `App.messages = messages = remember_saveable("")`. `@app` returns the + function unchanged, so `main.App` is that function and `main.App.messages` is the state from its + latest composition. There is no framework lookup by variable name. Before the first composition, + `main.App.messages` does not exist yet. +- Surviving rotation and process restart is Compose's `SaveableStateRegistry`. The host places + `PythonAppView` under it, and the save-and-restore proof is python-multiplatform's (the same + issue). + +Tests here (planned): the type rule, the wrapper's three spellings over one state, the error outside +a composition, and an app that attaches `App.messages` as above. They run against fake host rows +shaped after the agreed `rememberSaveableWrapper`. + +### S5.7 `DefaultCoroutineScope`, `MainCoroutineScope`: `planned` (after pythonx-concurrent) + +INTENT §5.10. In 2024 `DefaultCoroutineScope()` was Kotlin's `CoroutineScope(Dispatchers.Default)` +and `MainCoroutineScope()` was `CoroutineScope(Dispatchers.Main)`, each with `.launch(block)` +running a Python callable (PyREPL `abc6952`, `Runtime.kt`). Here they are names in +`pythonx.compose.runtime` on top of `pythonx.concurrent`: a `pythonx.concurrent` scope on +`Dispatchers.Default` and one on `Dispatchers.Main`, with `launch`. Nothing is implemented before +pythonx-concurrent provides scopes and dispatchers. + ## 6. Modifiers: extension functions as methods ### S6.1 `Modifier` extensions are methods on the receiver proxy: `implemented` @@ -534,8 +586,8 @@ the binder lists no member that would win over these groups. | Name | Notebook use | Note | |---|---|---| -| `remember_saveable` | imported from `pythonx.compose.runtime`; state read/written with `getValue()` / `setValue()` | INTENT §5.1: `state(initial)` read and written through `.value` (S5.4, `partial`, #11; scalars round-trip since python-multiplatform #69); keeping a value across recreation is not provided | -| `DefaultCoroutineScope`, `MainCoroutineScope` | imported from `pythonx.compose.runtime` | INTENT §4.1, open | +| `remember_saveable` | imported from `pythonx.compose.runtime`; `main.App.messages` read/written with `getValue()` / `setValue()` (cells 9 to 13) | INTENT §5.9: supported as written, `.value` as the alias; S5.6, `planned` | +| `DefaultCoroutineScope`, `MainCoroutineScope` | imported from `pythonx.compose.runtime` | INTENT §5.10: built on pythonx-concurrent; S5.7, `planned` | | `DefaultIcons` | `DefaultIcons.Add()` | INTENT §5.7, `implemented` as `DefaultIcons.Add`. The notebook's `Add()` is the spelling of its time; the current spelling is `Add`, a property read without parentheses, as for every constant (decided with the ecosystem lead; S5.3, `tests/test_chain.py::DefaultIconsAlias`, fake host) | | `modifier` | lower-case instance from `pythonx.compose.ui` | INTENT §5.4: not provided; written `Modifier` | | `color=0xFFFF0000` | ARGB integer for a colour | INTENT §5.5: not accepted; written `Color(0xFFFF0000)` |