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
10 changes: 5 additions & 5 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`
| 상태 | 항목 |
|---|---|
| 구현 | 매핑 매니페스트 `pythonx-map.toml`, `@Composable` 항등 데코레이터, 실제 디스크 패키지 `pythonx` 와 모듈 수준 규칙 기반 재노출(snake_case 이름·키워드·시그니처, 오버로드 디스패치, 값 클래스 허용 목록), 실제 Compose 1.11.1 에서 생성한 `.pyi` 스텁과 `py.typed`(wheel 포함, mypy 로 검사), `Alignment`/`Arrangement` 상수(평면 `Alignment.End` 와 묶음 `Alignment.Horizontal.End` 모두, #9), proxy 메서드의 snake_case 키워드 인자(`m.padding(padding_values=...)`, python-multiplatform `describe_member` 필요; Kotlin 이름도 실행 시 그대로 통하고 스텁은 snake_case 만) |
| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 E2E 모듈 #19; 숫자·문자열 상태는 python-multiplatform #69 부터 왕복됨), 배포 설정, Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`(import 만 됨)·`DefaultIcons`(`Icons.Default` alias, 가짜 호스트로만 테스트됨, 스텁에서는 `DefaultIcons.Add` 가 `ImageVector`; `DefaultIcons.Add` 로 쓴다 — 노트북의 `DefaultIcons.Add()` 가 아님) |
| 부분 | 선언형 앱 루트 `@app`·`state`·`app_root`(#11; 로직은 테스트됨, 바인더 경로는 #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 증거는 E2E 모듈 #19; 숫자·문자열 상태는 python-multiplatform #69 부터 왕복됨), 배포 설정, Material 3 위젯(렌더 증거는 python-multiplatform 에만), `Icon`(import 만 됨)·`DefaultIcons`(`Icons.Default` alias, 가짜 호스트로만 테스트됨, 스텁에서는 `DefaultIcons.Add` 가 `ImageVector`; `DefaultIcons.Add` 로 쓴다 — 노트북의 `DefaultIcons.Add()` 가 아님), `TextField(state=...)`·`TextFieldState`(#10; `pythonx.compose.foundation.text.input`, 가짜 호스트로 테스트됨, 노트북의 `text_state=`/`padding=` 는 `state=`/`modifier=Modifier.padding(8)`; IME 조합 증거는 python-multiplatform E2E #26) |
| 계획 | `remember_saveable`·색 스킴·코루틴 스코프 |

### 테스트 기준선

```
python3 -m pytest tests -q
PythonMultiplatform 체크아웃 없음 132 passed, 99 skipped
python-multiplatform develop 31c092f0+ 230 passed, 1 skipped
PythonMultiplatform 체크아웃 없음 134 passed, 112 skipped
python-multiplatform develop 31c092f0+ 245 passed, 1 skipped
describe_member 없는 체크아웃 같은 수, 그중 7 개 건너뜀 (python-multiplatform #54 이전)
describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전)
그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (member resolver 없음)
Expand All @@ -45,7 +45,7 @@ python3 -m pytest tests -q
`describe_member`(python-multiplatform #54)가 없는 바인더에서는 메서드 키워드를 확인하는 7 개가(Kotlin 키워드로 되돌아가는 2 개는 실행됨), `describe(module, name)`(python-multiplatform #36)이 없는 바인더에서는 묶음 상수를 확인하는 13 개가,
member resolver(python-multiplatform `ba4c6f49`)가 없는 바인더에서는 proxy 의 snake_case 메서드를 부르는
5 개가 더 건너뛴다. 두 환경 모두의 건너뜀 1 개는 worktree 에 없는 `UI.ipynb` 의 테스트다. 건너뜀은 통과가 아니다.
체크아웃 없이 통과하는 132 개 중 59 개는 타입 스텁과 wheel 을 검사하고(SPEC S1.2), 나머지 다수는 *부재*(옛 토큰·삭제된
체크아웃 없이 통과하는 134 개 중 61 개는 타입 스텁과 wheel 을 검사하고(SPEC S1.2), 나머지 다수는 *부재*(옛 토큰·삭제된
파일이 없음)를 확인하는 것이라 기능 진척으로 세지 않는다.

### 마일스톤
Expand All @@ -61,7 +61,7 @@ GitHub 마일스톤과 같은 내용이다. 날짜는 2026-10-03 에 정했고,
날짜 근거:

- **M1 (2주):** 바인더 계약(`inspect.signature`, `python_multiplatform.describe`)이 PythonMultiplatform `d00f413f` 에 착지했다. 남은 일은 이 저장소의 순수 Python 과 가짜 호스트 하네스뿐이라 JVM 빌드가 필요 없다.
- **M2 (M1 뒤 3주):** 위젯마다 JVM 렌더 테스트가 필요하다. 처음엔 Gradle 캐시 링크가 끊겼다고 보고 4주로 잡았으나, 캐시는 `PythonMultiplatform/.caches` 로 다시 걸려 Compose 픽스처가 통과하고 있었고, 노트북 관련 질문도 INTENT §5 로 결정됐다. 남은 위험은 Compose 1.11 의 `TextFieldState` 확인과, 앱 루트 교체를 Kotlin 컴포지션이 관찰하는 진입점(바인더 몫일 수 있음)이다.
- **M2 (M1 뒤 3주):** 위젯마다 JVM 렌더 테스트가 필요하다. 처음엔 Gradle 캐시 링크가 끊겼다고 보고 4주로 잡았으나, 캐시는 `PythonMultiplatform/.caches` 로 다시 걸려 Compose 픽스처가 통과하고 있었고, 노트북 관련 질문도 INTENT §5 로 결정됐다. 남은 위험은 `TextField(state=...)` 의 IME 조합 증거(python-multiplatform E2E #26)와, 앱 루트 교체를 Kotlin 컴포지션이 관찰하는 진입점(바인더 몫일 수 있음)이다.
- **M3 (M2 뒤 2주):** 스텁은 M1 규칙만 있으면 만들 수 있어 M2 와 겹쳐 진행할 수 있다. 다만 노트북 위젯의 최종 이름이 M2 에서 정해지므로 마감은 M2 뒤로 둔다.

## 4. 구조
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ standalone desktop toolkit.
| Type stubs (`.pyi`) from real Compose 1.11.1, `py.typed` | ✅ implemented and checked with mypy; many types are still `Any` (#12) |
| Distribution (`pythonx-compose`) | 🟡 partial — the wheel carries the manifest, the re-export rule, the stubs and `py.typed` |
| Declared app root (`@app`) and Pythonic state (`state`), no update call | 🟡 partial — binder path tested against a fake host shaped after python-multiplatform #38; real Compose is the E2E module (#11, #19); numbers and strings round-trip through `state` |
| `TextField` input-method (IME) handling | ⏳ planned (#10) |
| `TextField(state=...)` with `TextFieldState` (`pythonx.compose.foundation.text.input`) | 🟡 partial — tested against a fake host shaped after python-multiplatform #73; the input-method (IME) composing proof is python-multiplatform E2E #26 (#10) |
| `DefaultIcons` (`Icons.Default`), written `DefaultIcons.Add` | 🟡 partial — tested against a fake host shaped after python-multiplatform #37/#38; `Icon(DefaultIcons.Add, …)` is drawn in python-multiplatform's render test |
| Colour schemes | ⏳ planned |
| `remember_saveable`, coroutine scopes | ⏳ planned |
Expand Down
11 changes: 11 additions & 0 deletions docs/INTENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,14 @@ them.
intent is written `Spacer(modifier=Modifier.padding(...))`.
7. **`DefaultIcons` is `Icons.Default`,** served by name once the binder walks
`material-icons-core` (python-multiplatform #37).
8. **`TextField` takes a `TextFieldState`; no `text_state`, no `padding`.** The notebook's
`TextField(text_state=..., padding=8)` has no Kotlin counterpart, so under 5.6 it is written
`TextField(state=..., modifier=Modifier.padding(8))`. Compose 1.11's state-based overload keeps
the text buffer and the input method's composing region inside Compose, so nothing crosses into
Python per keystroke; the `value` / `on_value_change` overloads send a `str` back on every key
and lose the composing range. Python makes the state with `remember_text_field_state("")` (inside
composition) or `TextFieldState("hi")` (outside, such as a notebook cell), from
`pythonx.compose.foundation.text.input`, and reads or writes it on demand: `field.text`,
`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.
51 changes: 46 additions & 5 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,22 @@ Run from a worktree with `python -m pytest tests -q -rs` (pytest 8, mypy 2.4, CP

| Environment | Result |
|---|---|
| No `PythonMultiplatform` checkout found | **132 passed, 99 skipped**, 165 subtests passed |
| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **230 passed, 1 skipped**, 196 subtests passed |
| No `PythonMultiplatform` checkout found | **134 passed, 112 skipped**, 173 subtests passed |
| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **245 passed, 1 skipped**, 204 subtests passed |
| an older checkout, without `describe_member` (python-multiplatform #54) | the same, with 7 more skipped |
| an older one, without `describe(module, name)` (python-multiplatform #36) | with 13 more skipped again |
| an older one still, without `add_member_resolver` | with 5 more skipped again |

Measured in a worktree, which has `.tmp/kotlin-stubs.zip` but no `UI.ipynb`. Without a checkout, the
97 tests that install the binder's layers through `tests/adapter.py` skip, plus the notebook test.
110 tests that install the binder's layers through `tests/adapter.py` skip, plus the notebook test.
Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1)
skip. Against a binder without `describe_member`, the 7 that check a method's keywords (S4.1) skip; the 2
that check the fallback (Kotlin keywords, names resolved) run. Against a binder older than `ba4c6f49`, the 5 that call a snake_case method on a proxy the
binder returned skip as well, because that needs its member resolver (python-multiplatform #17). The skip against a current checkout is `UI.ipynb`'s (§2). A binder older than #38 skips the 5
`TheBinderPath` tests (S5.4). `tests/test_typing.py` skips where mypy is not installed. A skip is not a
`TheBinderPath` tests (S5.4). A binder without the `@Composable` binding (`push_composer`) skips the 13 text-field tests (S5.5). `tests/test_typing.py` skips where mypy is not installed. A skip is not a
pass.

Of the 132 that pass without a checkout, 59 check the type stubs and the wheel (S1.2); most of the
Of the 134 that pass without a checkout, 61 check the type stubs and the wheel (S1.2); most of the
rest assert **absence** (a retired token, a deleted file, a docstring that exists). Those are listed
in §9 and are not counted as features.

Expand Down Expand Up @@ -168,6 +168,7 @@ Current mapping:
| `pythonx.compose.layout` | `androidx.compose.foundation.layout` |
| `pythonx.compose.material3` | `androidx.compose.material3` |
| `pythonx.compose.material.icons` | `androidx.compose.material.icons` |
| `pythonx.compose.foundation.text.input` | `androidx.compose.foundation.text.input` (S5.5) |

**`[aliases]`** lends a module names from another mapped module, as data. Two forms, both under
`[aliases."<owner module>"]` keyed by the source module: a list of names lent under the **same**
Expand Down Expand Up @@ -321,6 +322,45 @@ Still `planned`: the two colour-scheme factories take 36 `Color` parameters agai
the binding's omission cap (python-multiplatform `a6742a1c`). The hand-written `icon.py` /
`color_scheme.py` that recorded this were dead code and are deleted (#31).

### S5.5 `TextField(state=...)` and `TextFieldState` — `partial`

INTENT §5.8. `TextField` uses Compose 1.11's state-based overload, so Compose owns the text buffer
and the IME composing region and no Python callback runs per keystroke. There is no per-widget
wrapper: `pythonx.compose.material3.TextField` is androidx's, re-exported by §3's rule, and its
keywords are `snake_case`. The state comes from `pythonx.compose.foundation.text.input`
(`androidx.compose.foundation.text.input`, a manifest row; a package file with the one-line
re-export, plus a bare `foundation/text/__init__.py` so the wheel finds it):

```python
field = remember_text_field_state("") # in composition; TextFieldState("hi") outside it
TextField(state=field, modifier=Modifier.padding(8), label=lambda scope: Text("Message"))
field.text # the committed text, a str, read on demand
field.set_text_and_place_cursor_at_end("x") # extension members, snake_case methods
field.clear_text()
```

`TextField(text_state=...)` and `TextField(..., padding=8)` are a `TypeError` (the binder reports no such
parameter) and a type error in the stubs; the notebook's spelling is `state=` and `modifier=Modifier.padding(8)`
(INTENT §5.8). The `value` / `on_value_change` overloads still exist and are reached by those keywords.
A `TextRange` parameter is the binder's, and `TextRange(2)` is shadowed by a companion module
(python-multiplatform #78); `TextRange__Int(2)` is the spelling meanwhile.

| Behaviour | Test |
|---|---|
| `TextFieldState("hi").text == "hi"`, a `str`; the initial text is optional; Kotlin's `initialText=` works | `tests/test_text_field.py::TheTextFieldState` |
| `set_text_and_place_cursor_at_end` and `clear_text` write | `…::test_set_text_and_place_cursor_at_end_writes_it`, `test_clear_text_empties_it` |
| `remember_text_field_state` is a composable: it needs a composer | `…::test_remember_text_field_state_is_the_composable_that_makes_one`, `…_needs_a_composition` |
| `state=` selects the state overload; keywords are snake_case; `modifier=` reaches it; `value=` selects the `String` overload | `tests/test_text_field.py::TheStateOverload` |
| `text_state=` and `padding=` are a `TypeError` | `…::test_text_state_is_not_a_parameter`, `test_padding_is_not_a_parameter` |
| The stubs type `TextFieldState`, `.text` as `str`, and `TextField(state=...)`; `text_state=` fails | `tests/test_typing.py::test_a_text_field_state_types_the_state_overload`, `test_a_text_field_without_a_kotlin_counterpart_fails` |

Status `partial`: this is wired and tested against the fake host, whose rows are shaped after the stubs of
python-multiplatform `26485a02` (#73), not a walked jar (`tests/fake_host.py` says which fields). The
proof that matters, typing a composing input-method sequence with no Python callback per keystroke
and the field's text asserted afterwards, is python-multiplatform's E2E test (#26), judged over up to
four frames; it is not run here. Not modelled: a function-typed slot (`label`, `on_value_change`), which needs
the binder's `NewFunction` rows.

## 6. Modifiers — extension functions as methods

### S6.1 `Modifier` extensions are methods on the receiver proxy — `implemented`
Expand Down Expand Up @@ -453,6 +493,7 @@ the binder lists no member that would win over these groups.
| `color=0xFFFF0000` | ARGB integer for a colour | INTENT §5.5: not accepted; written `Color(0xFFFF0000)` |
| `Spacer(start=..., top=...)` | spacing parameters | INTENT §5.6: not supported; written `Spacer(modifier=Modifier.padding(...))` |
| `Column`, `Row`, `Spacer` | imported from `pythonx.compose.material3` | INTENT §5.2: served from both, by the manifest's `[aliases]` (`tests/test_chain.py::ManifestAliases`); render proof pending (#9) |
| `TextField(text_state=..., padding=8)` | state object and spacing parameter | INTENT §5.8: written `TextField(state=..., modifier=Modifier.padding(8))`; S5.5, `partial` (#10) |
| `Card`, `Button`, `Text`, `TextField` | as above | S5.2 |
| `main.App`, `App.update(...)` | live screen replacement from a cell | INTENT §5.1: `@app` declares the root, a redeclaration replaces it, no update function (S5.4, `partial`, issue #11) |

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/getting-started.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ <h2><span data-lang="en">1. Get the source</span><span data-lang="ko">1. 소스
<section>
<h2><span data-lang="en">2. Run the tests</span><span data-lang="ko">2. 테스트 실행</span></h2>
<pre><code class="language-bash">python3 -m pytest tests -q</code></pre>
<p data-lang="en">Expect <strong>132 passed, 99 skipped</strong>. One skipped test reads the maintainer's <code>UI.ipynb</code>, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> checkout beside this one, or:</p><p data-lang="ko"><strong>132 passed, 99 skipped</strong> 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 <code>UI.ipynb</code> 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> 체크아웃이 있거나 다음이 필요합니다:</p>
<p data-lang="en">Expect <strong>134 passed, 112 skipped</strong>. One skipped test reads the maintainer's <code>UI.ipynb</code>, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> checkout beside this one, or:</p><p data-lang="ko"><strong>134 passed, 112 skipped</strong> 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 <code>UI.ipynb</code> 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 <a href="https://github.com/thisisthepy/python-multiplatform">python-multiplatform</a> 체크아웃이 있거나 다음이 필요합니다:</p>
<pre><code class="language-bash">PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q</code></pre>
<p data-lang="en">With a current one (<code>31c092f0</code> or later) <strong>230 pass</strong> and 1 skips (the <code>UI.ipynb</code> test); an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.</p><p data-lang="ko">최신 체크아웃(<code>31c092f0</code> 이후)이면 <strong>230 개가 통과</strong>하고 1 개(<code>UI.ipynb</code> 테스트)를 건너뛰며, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 <strong>건너뜁니다</strong>. 예상된 상태이며 기록되어 있습니다.</p>
<p data-lang="en">With a current one (<code>31c092f0</code> or later) <strong>245 pass</strong> and 1 skips (the <code>UI.ipynb</code> test); an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.</p><p data-lang="ko">최신 체크아웃(<code>31c092f0</code> 이후)이면 <strong>230 개가 통과</strong>하고 1 개(<code>UI.ipynb</code> 테스트)를 건너뛰며, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 <strong>건너뜁니다</strong>. 예상된 상태이며 기록되어 있습니다.</p>
</section>

<section>
Expand Down
Loading
Loading