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
19 changes: 15 additions & 4 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)는
Expand Down Expand Up @@ -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` 둘 다 지원한다.
Expand Down
46 changes: 35 additions & 11 deletions docs/INTENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
62 changes: 57 additions & 5 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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)` |
Expand Down
Loading