Skip to content

Commit e6db167

Browse files
authored
Merge pull request #55 from thisisthepy/work/textfield
Feat: TextField(state=...) with Compose owning the text, so IME composition never crosses Python
2 parents 8c93493 + 8e9d764 commit e6db167

16 files changed

Lines changed: 454 additions & 24 deletions

File tree

‎PROJECT.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`
2828
| 상태 | 항목 |
2929
|---|---|
3030
| 구현 | 매핑 매니페스트 `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 만) |
31-
| 부분 | 선언형 앱 루트 `@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()` 가 아님) |
31+
| 부분 | 선언형 앱 루트 `@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) |
3232
| 계획 | `remember_saveable`·색 스킴·코루틴 스코프 |
3333

3434
### 테스트 기준선
3535

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

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

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

6767
## 4. 구조

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ standalone desktop toolkit.
159159
| Type stubs (`.pyi`) from real Compose 1.11.1, `py.typed` | ✅ implemented and checked with mypy; many types are still `Any` (#12) |
160160
| Distribution (`pythonx-compose`) | 🟡 partial — the wheel carries the manifest, the re-export rule, the stubs and `py.typed` |
161161
| 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` |
162-
| `TextField` input-method (IME) handling | ⏳ planned (#10) |
162+
| `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) |
163163
| `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 |
164164
| Colour schemes | ⏳ planned |
165165
| `remember_saveable`, coroutine scopes | ⏳ planned |

‎docs/INTENT.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,14 @@ them.
162162
intent is written `Spacer(modifier=Modifier.padding(...))`.
163163
7. **`DefaultIcons` is `Icons.Default`,** served by name once the binder walks
164164
`material-icons-core` (python-multiplatform #37).
165+
8. **`TextField` takes a `TextFieldState`; no `text_state`, no `padding`.** The notebook's
166+
`TextField(text_state=..., padding=8)` has no Kotlin counterpart, so under 5.6 it is written
167+
`TextField(state=..., modifier=Modifier.padding(8))`. Compose 1.11's state-based overload keeps
168+
the text buffer and the input method's composing region inside Compose, so nothing crosses into
169+
Python per keystroke; the `value` / `on_value_change` overloads send a `str` back on every key
170+
and lose the composing range. Python makes the state with `remember_text_field_state("")` (inside
171+
composition) or `TextFieldState("hi")` (outside, such as a notebook cell), from
172+
`pythonx.compose.foundation.text.input`, and reads or writes it on demand: `field.text`,
173+
`field.set_text_and_place_cursor_at_end("x")`, `field.clear_text()`. `TextField` itself is
174+
androidx's, re-exported by rule, with no per-widget wrapper (§2.4). The input field's state is
175+
separate from state a screen streams output into.

‎docs/SPEC.md‎

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,22 @@ Run from a worktree with `python -m pytest tests -q -rs` (pytest 8, mypy 2.4, CP
2121

2222
| Environment | Result |
2323
|---|---|
24-
| No `PythonMultiplatform` checkout found | **132 passed, 99 skipped**, 165 subtests passed |
25-
| 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 |
24+
| No `PythonMultiplatform` checkout found | **134 passed, 112 skipped**, 173 subtests passed |
25+
| 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 |
2626
| an older checkout, without `describe_member` (python-multiplatform #54) | the same, with 7 more skipped |
2727
| an older one, without `describe(module, name)` (python-multiplatform #36) | with 13 more skipped again |
2828
| an older one still, without `add_member_resolver` | with 5 more skipped again |
2929

3030
Measured in a worktree, which has `.tmp/kotlin-stubs.zip` but no `UI.ipynb`. Without a checkout, the
31-
97 tests that install the binder's layers through `tests/adapter.py` skip, plus the notebook test.
31+
110 tests that install the binder's layers through `tests/adapter.py` skip, plus the notebook test.
3232
Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1)
3333
skip. Against a binder without `describe_member`, the 7 that check a method's keywords (S4.1) skip; the 2
3434
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
3535
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
36-
`TheBinderPath` tests (S5.4). `tests/test_typing.py` skips where mypy is not installed. A skip is not a
36+
`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
3737
pass.
3838

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

@@ -168,6 +168,7 @@ Current mapping:
168168
| `pythonx.compose.layout` | `androidx.compose.foundation.layout` |
169169
| `pythonx.compose.material3` | `androidx.compose.material3` |
170170
| `pythonx.compose.material.icons` | `androidx.compose.material.icons` |
171+
| `pythonx.compose.foundation.text.input` | `androidx.compose.foundation.text.input` (S5.5) |
171172

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

325+
### S5.5 `TextField(state=...)` and `TextFieldState` — `partial`
326+
327+
INTENT §5.8. `TextField` uses Compose 1.11's state-based overload, so Compose owns the text buffer
328+
and the IME composing region and no Python callback runs per keystroke. There is no per-widget
329+
wrapper: `pythonx.compose.material3.TextField` is androidx's, re-exported by §3's rule, and its
330+
keywords are `snake_case`. The state comes from `pythonx.compose.foundation.text.input`
331+
(`androidx.compose.foundation.text.input`, a manifest row; a package file with the one-line
332+
re-export, plus a bare `foundation/text/__init__.py` so the wheel finds it):
333+
334+
```python
335+
field = remember_text_field_state("") # in composition; TextFieldState("hi") outside it
336+
TextField(state=field, modifier=Modifier.padding(8), label=lambda scope: Text("Message"))
337+
field.text # the committed text, a str, read on demand
338+
field.set_text_and_place_cursor_at_end("x") # extension members, snake_case methods
339+
field.clear_text()
340+
```
341+
342+
`TextField(text_state=...)` and `TextField(..., padding=8)` are a `TypeError` (the binder reports no such
343+
parameter) and a type error in the stubs; the notebook's spelling is `state=` and `modifier=Modifier.padding(8)`
344+
(INTENT §5.8). The `value` / `on_value_change` overloads still exist and are reached by those keywords.
345+
A `TextRange` parameter is the binder's, and `TextRange(2)` is shadowed by a companion module
346+
(python-multiplatform #78); `TextRange__Int(2)` is the spelling meanwhile.
347+
348+
| Behaviour | Test |
349+
|---|---|
350+
| `TextFieldState("hi").text == "hi"`, a `str`; the initial text is optional; Kotlin's `initialText=` works | `tests/test_text_field.py::TheTextFieldState` |
351+
| `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` |
352+
| `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` |
353+
| `state=` selects the state overload; keywords are snake_case; `modifier=` reaches it; `value=` selects the `String` overload | `tests/test_text_field.py::TheStateOverload` |
354+
| `text_state=` and `padding=` are a `TypeError` | `…::test_text_state_is_not_a_parameter`, `test_padding_is_not_a_parameter` |
355+
| 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` |
356+
357+
Status `partial`: this is wired and tested against the fake host, whose rows are shaped after the stubs of
358+
python-multiplatform `26485a02` (#73), not a walked jar (`tests/fake_host.py` says which fields). The
359+
proof that matters, typing a composing input-method sequence with no Python callback per keystroke
360+
and the field's text asserted afterwards, is python-multiplatform's E2E test (#26), judged over up to
361+
four frames; it is not run here. Not modelled: a function-typed slot (`label`, `on_value_change`), which needs
362+
the binder's `NewFunction` rows.
363+
324364
## 6. Modifiers — extension functions as methods
325365

326366
### S6.1 `Modifier` extensions are methods on the receiver proxy — `implemented`
@@ -453,6 +493,7 @@ the binder lists no member that would win over these groups.
453493
| `color=0xFFFF0000` | ARGB integer for a colour | INTENT §5.5: not accepted; written `Color(0xFFFF0000)` |
454494
| `Spacer(start=..., top=...)` | spacing parameters | INTENT §5.6: not supported; written `Spacer(modifier=Modifier.padding(...))` |
455495
| `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) |
496+
| `TextField(text_state=..., padding=8)` | state object and spacing parameter | INTENT §5.8: written `TextField(state=..., modifier=Modifier.padding(8))`; S5.5, `partial` (#10) |
456497
| `Card`, `Button`, `Text`, `TextField` | as above | S5.2 |
457498
| `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) |
458499

‎docs/guide/getting-started.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ <h2><span data-lang="en">1. Get the source</span><span data-lang="ko">1. 소스
4343
<section>
4444
<h2><span data-lang="en">2. Run the tests</span><span data-lang="ko">2. 테스트 실행</span></h2>
4545
<pre><code class="language-bash">python3 -m pytest tests -q</code></pre>
46-
<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>
46+
<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>
4747
<pre><code class="language-bash">PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q</code></pre>
48-
<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>
48+
<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>
4949
</section>
5050

5151
<section>

0 commit comments

Comments
 (0)