From 8e9d764f185c08e1938cd23684fefa6506a32d0d Mon Sep 17 00:00:00 2001
From: BREW <81044362+b-re-w@users.noreply.github.com>
Date: Sat, 3 Oct 2026 18:38:40 +0900
Subject: [PATCH] Feat: TextField(state=...) with Compose owning the text, so
IME composition never crosses Python
Issue #10. Of material3's three TextField overloads, the state-based one is
the one where Compose keeps the buffer and the IME composing region and no
keystroke crosses into Python, which Korean/CJK input needs. python-
multiplatform #73 (in 26485a02) binds what it takes from Python:
- a new manifest module pythonx.compose.foundation.text.input (one-line
re-export) serves TextFieldState("hi") / TextFieldState() and
remember_text_field_state("") (inside composition);
- field.text reads the committed text as a str; field.set_text_and_place_
cursor_at_end(...) and field.clear_text() write;
- TextField(state=field, ...) is material3's own TextField through the
re-export rule, no wrapper: state= selects the state overload, value=
the String one.
INTENT 5.8 records the notebook's spelling, agreed with the ecosystem lead:
TextField(text_state=..., padding=8) has no Kotlin counterpart and is written
TextField(state=..., modifier=Modifier.padding(8)); text_state= is the
binder's TypeError and a mypy error.
foundation/text/__init__.py is a bare container so the wheel finds the input
package; no row maps androidx.compose.foundation.text itself.
Tested against the fake host, whose rows are read from the run 37103737430
stubs (the export holds the binder's Python, not its table); label= and
onValueChange= are not modelled. The IME proof -- typing a composing sequence
with no Python callback per keystroke, render judged over up to four frames --
is python-multiplatform's E2E module (#26). SPEC S5.5 is therefore partial.
Drafted by a subagent in a worktree; reviewed and re-run here. During its
disable check it moved a file to /tmp for a few seconds, outside the
worktree; it was moved straight back.
feature added: the text.input module and TextFieldState surface.
test added: tests/test_text_field.py (13), mypy cases (2). Red: 13 failed
(no fake-host rows), 2 mypy failed (module not found). Removing the manifest
row fails 12 of 13.
documentation corrected: INTENT 5.8, SPEC S5.5/0/2/8, PROJECT.md, guide
(bilingual TextField example and spelling note), README and README_ko.
Suite with python-multiplatform 26485a02 (worktree): 230 passed, 1 skipped ->
245 passed, 1 skipped; without a checkout, 132 passed, 99 skipped -> 134
passed, 112 skipped. check_guide.py: 0.
Refs #10
---
PROJECT.md | 10 +-
README.md | 2 +-
docs/INTENT.md | 11 ++
docs/SPEC.md | 51 ++++++-
docs/guide/getting-started.html | 4 +-
docs/guide/guide-composables.html | 21 ++-
docs/guide/status.html | 4 +-
docs/locale/README_ko.md | 2 +-
pythonx/compose/foundation/text/__init__.py | 5 +
.../compose/foundation/text/input/__init__.py | 5 +
.../foundation/text/input/__init__.pyi | 78 +++++++++++
pythonx/compose/material3/__init__.pyi | 9 +-
pythonx/compose/pythonx-map.toml | 5 +
tests/fake_host.py | 125 +++++++++++++++++-
tests/test_text_field.py | 116 ++++++++++++++++
tests/test_typing.py | 30 +++++
16 files changed, 454 insertions(+), 24 deletions(-)
create mode 100644 pythonx/compose/foundation/text/__init__.py
create mode 100644 pythonx/compose/foundation/text/input/__init__.py
create mode 100644 pythonx/compose/foundation/text/input/__init__.pyi
create mode 100644 tests/test_text_field.py
diff --git a/PROJECT.md b/PROJECT.md
index 2cf7383..40377cc 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -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 없음)
@@ -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), 나머지 다수는 *부재*(옛 토큰·삭제된
파일이 없음)를 확인하는 것이라 기능 진척으로 세지 않는다.
### 마일스톤
@@ -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. 구조
diff --git a/README.md b/README.md
index edef549..668de0e 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/docs/INTENT.md b/docs/INTENT.md
index 857b011..ad943f9 100644
--- a/docs/INTENT.md
+++ b/docs/INTENT.md
@@ -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.
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 0afbdd6..4e60e8c 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -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.
@@ -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.""]` 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
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`
@@ -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) |
diff --git a/docs/guide/getting-started.html b/docs/guide/getting-started.html
index a5c0f00..4481089 100644
--- a/docs/guide/getting-started.html
+++ b/docs/guide/getting-started.html
@@ -43,9 +43,9 @@ 1. Get the source1. 소스
2. Run the tests2. 테스트 실행
python3 -m pytest tests -q
-Expect 132 passed, 99 skipped. One skipped test reads the maintainer's UI.ipynb, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
132 passed, 99 skipped 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 UI.ipynb 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
+Expect 134 passed, 112 skipped. One skipped test reads the maintainer's UI.ipynb, which a clone does not have; mypy type-checks the stubs. The other skipped tests drive the binder's adaptation layer and need a python-multiplatform checkout beside this one, or:
134 passed, 112 skipped 가 정상입니다. 건너뛴 테스트 하나는 클론에는 없는 메인테이너의 UI.ipynb 를 읽으며, mypy 는 스텁을 타입 검사합니다. 나머지 건너뛴 테스트는 바인더의 적응 계층을 구동하며, 옆에 python-multiplatform 체크아웃이 있거나 다음이 필요합니다:
PYTHONMULTIPLATFORM_HOME=/path/to/python-multiplatform python3 -m pytest tests -q
-With a current one (31c092f0 or later) 230 pass and 1 skips (the UI.ipynb test); an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.
최신 체크아웃(31c092f0 이후)이면 230 개가 통과하고 1 개(UI.ipynb 테스트)를 건너뛰며, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.
+With a current one (31c092f0 or later) 245 pass and 1 skips (the UI.ipynb test); an older one skips 5 that call a snake_case method on a proxy the binder returned. That is expected and recorded.
최신 체크아웃(31c092f0 이후)이면 230 개가 통과하고 1 개(UI.ipynb 테스트)를 건너뛰며, 오래된 체크아웃은 바인더가 돌려준 proxy 의 snake_case 메서드를 부르는 5 개를 건너뜁니다. 예상된 상태이며 기록되어 있습니다.
diff --git a/docs/guide/guide-composables.html b/docs/guide/guide-composables.html
index b9b2399..9eea091 100644
--- a/docs/guide/guide-composables.html
+++ b/docs/guide/guide-composables.html
@@ -64,6 +64,23 @@ Declare the app and keep stateStatus: partial. The binder path, which creates the state through Compose's mutableStateOf, is tested against a fake host shaped after python-multiplatform #38; proof against real Compose is still the E2E module. Numbers and strings round-trip: state(0) and count.value += 1 work (python-multiplatform #69).
상태: 부분. Compose 의 mutableStateOf 로 상태를 만드는 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트되었으며, 실제 Compose 증거는 아직 E2E 모듈입니다. 숫자와 문자열은 왕복됩니다: state(0) 과 count.value += 1 이 동작합니다(python-multiplatform #69).
+
+A text field텍스트 필드
+from pythonx.compose.material3 import TextField, Text
+from pythonx.compose.foundation.text.input import remember_text_field_state
+
+field = remember_text_field_state("") # inside a composable
+TextField(state=field, label=lambda scope: Text("Message"))
+field.text # the committed text, read when you need it (say, on submit)
+
+# outside composition, such as a notebook cell
+from pythonx.compose.foundation.text.input import TextFieldState
+draft = TextFieldState("hi")
+draft.set_text_and_place_cursor_at_end("hello")
+draft.clear_text()
+Compose keeps the text and the input method's composing region, so nothing is sent to Python per keystroke; you read field.text on demand. Keep this state apart from state you stream output into, such as messages = state(""). The notebook's TextField(text_state=…, padding=8) has no Kotlin counterpart and is written TextField(state=…, modifier=Modifier.padding(8)). Status: partial — tested against a fake host; the input-method proof is python-multiplatform's end-to-end test (#26).
Compose 가 텍스트와 입력기의 조합 영역을 보관하므로 키 입력마다 파이썬으로 보내지는 것이 없고, 필요할 때 field.text 를 읽습니다. 이 상태는 출력을 흘려 넣는 상태(예: messages = state(""))와 분리해 두세요. 노트북의 TextField(text_state=…, padding=8) 은 Kotlin 에 대응이 없으며 TextField(state=…, modifier=Modifier.padding(8)) 로 씁니다. 상태: 부분 — 가짜 호스트로 테스트되었으며, 입력기 증거는 python-multiplatform 의 종단 간 테스트(#26)입니다.
+
+
From the notebook's spelling to the real parameters노트북 표기에서 실제 매개변수로
The notebook's signatures are examples. The surface follows Compose:
노트북의 시그니처는 예시입니다. 실제 표면은 Compose 를 따릅니다:
@@ -74,7 +91,7 @@ From the notebook's spelling to the real parametersText(font_size=30) | fontSize | font_size |
Column(horizontal_alignment=…) | horizontalAlignment | horizontal_alignment |
Button(corner_radius=50) | none — Compose takes a shape없음 — Compose 는 shape 를 받음 | shape |
-TextField(text_state=…) | value, onValueChange | value, on_value_change |
+TextField(text_state=…, padding=8) | state, modifier | state=…, modifier=Modifier.padding(8) |
fontSize is a TextUnit, which packs several fields into one value, so font_size refuses a raw number such as 30.
fontSize 는 여러 필드를 한 값에 담는 TextUnit 이므로, font_size 는 30 같은 숫자를 거절합니다.
@@ -96,7 +113,7 @@ What does not work yet아
- The colour-scheme factories cannot be called yet: they take 36 colour parameters.
DefaultIcons is Icons.Default, an icon is a value (an ImageVector to a type checker) read without parentheses — Icon(DefaultIcons.Add, content_description=None), not the notebook's DefaultIcons.Add() — and it is tested here against a fake host only; the real draw is in python-multiplatform's render test.색 스킴 팩토리는 색 매개변수를 36 개 받아 아직 호출할 수 없습니다. DefaultIcons 는 Icons.Default 이고, 아이콘은 괄호 없이 읽는 값(타입 검사기에는 ImageVector)입니다 — 노트북의 DefaultIcons.Add() 가 아니라 Icon(DefaultIcons.Add, content_description=None). 여기서는 가짜 호스트로만 테스트되며, 실제 그리기는 python-multiplatform 의 렌더 테스트에 있습니다.
- A declared app root and Pythonic state — redefining a screen replaces it, with no update call — are planned (issue #11).선언형 앱 루트와 파이썬다운 상태 — 화면을 다시 정의하면 교체되고, 갱신 호출은 없음 — 는 계획 단계입니다(이슈 #11).
-TextField input-method (IME) handling is pending (issue #10).TextField 의 입력기(IME) 처리는 대기 중입니다(이슈 #10).
+TextField(state=…) is wired and tested against a fake host only; that the input method's composing sequence survives, with no Python callback per keystroke, is proved in python-multiplatform's end-to-end test (#26).TextField(state=…) 는 가짜 호스트로만 테스트되었습니다. 입력기의 조합 입력이 키 입력마다 파이썬 콜백 없이 유지되는지는 python-multiplatform 의 종단 간 테스트(#26)에서 증명합니다.
remember_saveable and the coroutine scopes the notebook imports are planned.노트북이 import 하는 remember_saveable, 코루틴 스코프는 계획 단계입니다.
diff --git a/docs/guide/status.html b/docs/guide/status.html
index e884765..00406cf 100644
--- a/docs/guide/status.html
+++ b/docs/guide/status.html
@@ -34,9 +34,9 @@
Implemented means code in this repository and a passing test that exercises it. Tests that only assert an absence are not counted.
구현은 이 저장소의 코드와 그것을 실행하는 통과 테스트가 함께 있다는 뜻입니다. 부재만 확인하는 테스트는 세지 않습니다.
| State상태 | Item항목 |
-| implemented구현 | Mapping manifest pythonx/compose/pythonx-map.toml — inside the package, ships in the wheel매핑 매니페스트 pythonx/compose/pythonx-map.toml — 패키지 안에 있으며 wheel 에 포함 |
| implemented구현 | @Composable identity decorator@Composable 항등 데코레이터 |
| implemented구현 | pythonx as a real on-disk package re-exporting androidx.compose.* by one rule — names, keywords, signatures, Dp allowlistandroidx.compose.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonx — 이름, 키워드, 시그니처, Dp 허용 목록 |
| implemented구현 | Modifier chains with snake_case methods, overload dispatch, Dp as a numbersnake_case 메서드를 쓰는 Modifier 체인, 오버로드 디스패치, 숫자 Dp |
| implemented구현 | Column, Row, Spacer importable from material3 as well as layout (manifest [aliases])layout 뿐 아니라 material3 에서도 import 되는 Column, Row, Spacer (매니페스트 [aliases]) |
| implemented구현 | Type stubs (.pyi) generated from real Compose 1.11.1 by the runtime's naming rule, with py.typed, checked with mypy (#12); many types are still Any런타임과 같은 이름 규칙으로 실제 Compose 1.11.1 에서 생성한 타입 스텁(.pyi)과 py.typed, mypy 로 검사(#12); 아직 많은 타입이 Any 입니다 |
| implemented구현 | Snake-case names, keywords and signatures — modules and methods; a method's keywords need python-multiplatform's describe_member, and Kotlin's own spelling still works at run timesnake_case 이름·키워드·시그니처 — 모듈과 메서드; 메서드 키워드에는 python-multiplatform 의 describe_member 가 필요하고, Kotlin 이름도 실행 시에는 그대로 통합니다 |
| partial부분 | The empty Modifier — Modifier.padding(16) from the class needs an app-supplied factory against real Compose빈 Modifier — 클래스에서 시작하는 Modifier.padding(16) 은 실제 Compose 에서 앱이 제공하는 팩토리가 필요 |
| implemented구현 | Alignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), and the grouped Alignment.Horizontal.End beside Alignment.EndAlignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), 그리고 Alignment.End 와 나란히 묶음 표기 Alignment.Horizontal.End |
| partial부분 | Material 3 widgets — re-exported by rule; render proofs per widget pending (#9)Material 3 위젯 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9) |
| partial부분 | Distribution as pythonx-compose — the wheel carries the manifest, the re-export rule, the type stubs and py.typedpythonx-compose 배포 — wheel 에 매니페스트, 재노출 규칙, 타입 스텁, py.typed 포함 |
| partial부분 | A declared app root (@app) and Pythonic state (state), with no update call — binder path tested against a fake host shaped after python-multiplatform #38; real Compose is the E2E module (#11, #19)갱신 호출 없는 선언형 앱 루트(@app)와 파이썬다운 상태(state) — 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 는 E2E 모듈(#11, #19) |
| partial부분 | DefaultIcons is Icons.Default, written DefaultIcons.Add — tested against a fake host shaped after python-multiplatform #37/#38; the real draw is in python-multiplatform's render testDefaultIcons 는 Icons.Default 이며 DefaultIcons.Add 로 씁니다 — python-multiplatform #37/#38 모양의 가짜 호스트로 테스트됨; 실제 그리기는 python-multiplatform 의 렌더 테스트에 있음 |
| planned계획 | TextField input-method (IME) handling (#10)TextField 입력기(IME) 처리(#10) |
| planned계획 | Colour schemes (36 colour parameters)색 스킴 (색 매개변수 36 개) |
| planned계획 | remember_saveable, coroutine scopesremember_saveable, 코루틴 스코프 |
+| implemented구현 | Mapping manifest pythonx/compose/pythonx-map.toml — inside the package, ships in the wheel매핑 매니페스트 pythonx/compose/pythonx-map.toml — 패키지 안에 있으며 wheel 에 포함 |
| implemented구현 | @Composable identity decorator@Composable 항등 데코레이터 |
| implemented구현 | pythonx as a real on-disk package re-exporting androidx.compose.* by one rule — names, keywords, signatures, Dp allowlistandroidx.compose.* 를 규칙 하나로 재노출하는 실제 디스크 패키지 pythonx — 이름, 키워드, 시그니처, Dp 허용 목록 |
| implemented구현 | Modifier chains with snake_case methods, overload dispatch, Dp as a numbersnake_case 메서드를 쓰는 Modifier 체인, 오버로드 디스패치, 숫자 Dp |
| implemented구현 | Column, Row, Spacer importable from material3 as well as layout (manifest [aliases])layout 뿐 아니라 material3 에서도 import 되는 Column, Row, Spacer (매니페스트 [aliases]) |
| implemented구현 | Type stubs (.pyi) generated from real Compose 1.11.1 by the runtime's naming rule, with py.typed, checked with mypy (#12); many types are still Any런타임과 같은 이름 규칙으로 실제 Compose 1.11.1 에서 생성한 타입 스텁(.pyi)과 py.typed, mypy 로 검사(#12); 아직 많은 타입이 Any 입니다 |
| implemented구현 | Snake-case names, keywords and signatures — modules and methods; a method's keywords need python-multiplatform's describe_member, and Kotlin's own spelling still works at run timesnake_case 이름·키워드·시그니처 — 모듈과 메서드; 메서드 키워드에는 python-multiplatform 의 describe_member 가 필요하고, Kotlin 이름도 실행 시에는 그대로 통합니다 |
| partial부분 | The empty Modifier — Modifier.padding(16) from the class needs an app-supplied factory against real Compose빈 Modifier — 클래스에서 시작하는 Modifier.padding(16) 은 실제 Compose 에서 앱이 제공하는 팩토리가 필요 |
| implemented구현 | Alignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), and the grouped Alignment.Horizontal.End beside Alignment.EndAlignment / Arrangement — Alignment.Center, Arrangement.End, Arrangement.spaced_by(8), 그리고 Alignment.End 와 나란히 묶음 표기 Alignment.Horizontal.End |
| partial부분 | Material 3 widgets — re-exported by rule; render proofs per widget pending (#9)Material 3 위젯 — 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9) |
| partial부분 | Distribution as pythonx-compose — the wheel carries the manifest, the re-export rule, the type stubs and py.typedpythonx-compose 배포 — wheel 에 매니페스트, 재노출 규칙, 타입 스텁, py.typed 포함 |
| partial부분 | A declared app root (@app) and Pythonic state (state), with no update call — binder path tested against a fake host shaped after python-multiplatform #38; real Compose is the E2E module (#11, #19)갱신 호출 없는 선언형 앱 루트(@app)와 파이썬다운 상태(state) — 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 는 E2E 모듈(#11, #19) |
| partial부분 | DefaultIcons is Icons.Default, written DefaultIcons.Add — tested against a fake host shaped after python-multiplatform #37/#38; the real draw is in python-multiplatform's render testDefaultIcons 는 Icons.Default 이며 DefaultIcons.Add 로 씁니다 — python-multiplatform #37/#38 모양의 가짜 호스트로 테스트됨; 실제 그리기는 python-multiplatform 의 렌더 테스트에 있음 |
| partial부분 | TextField(state=…) with TextFieldState from pythonx.compose.foundation.text.input — tested against a fake host shaped after python-multiplatform #73; the input-method (IME) composing proof is python-multiplatform E2E #26 (#10)pythonx.compose.foundation.text.input 의 TextFieldState 를 쓰는 TextField(state=…) — python-multiplatform #73 모양의 가짜 호스트로 테스트됨; 입력기(IME) 조합 증거는 python-multiplatform E2E #26 (#10) |
| planned계획 | Colour schemes (36 colour parameters)색 스킴 (색 매개변수 36 개) |
| planned계획 | remember_saveable, coroutine scopesremember_saveable, 코루틴 스코프 |
-Test baseline: 132 passed, 99 skipped without a python-multiplatform checkout; 230 passed, 1 skipped with a current one. The one skip with a current checkout reads the maintainer's UI.ipynb.
테스트 기준선: python-multiplatform 체크아웃이 없으면 132 passed, 99 skipped, 최신 체크아웃이면 230 passed, 1 skipped. 최신 체크아웃에서 건너뛰는 1 개는 메인테이너의 UI.ipynb 를 읽는 테스트입니다.
+Test baseline: 134 passed, 112 skipped without a python-multiplatform checkout; 245 passed, 1 skipped with a current one. The one skip with a current checkout reads the maintainer's UI.ipynb.
테스트 기준선: python-multiplatform 체크아웃이 없으면 132 passed, 99 skipped, 최신 체크아웃이면 245 passed, 1 skipped. 최신 체크아웃에서 건너뛰는 1 개는 메인테이너의 UI.ipynb 를 읽는 테스트입니다.
FAQ
diff --git a/docs/locale/README_ko.md b/docs/locale/README_ko.md
index 8707409..d5e6f66 100644
--- a/docs/locale/README_ko.md
+++ b/docs/locale/README_ko.md
@@ -156,7 +156,7 @@ pip 패키지 **`pythonx-compose`**(파이썬 3.11 이상)로 배포되며, impo
| 실제 Compose 1.11.1 에서 생성한 타입 스텁(`.pyi`), `py.typed` | ✅ 구현, mypy 로 검사; 아직 많은 타입이 `Any` 입니다(#12) |
| 배포 (`pythonx-compose`) | 🟡 부분 — wheel 에 매니페스트, 재노출 규칙, 스텁, `py.typed` 포함 |
| 갱신 호출 없는 선언형 앱 루트(`@app`)와 파이썬다운 상태(`state`) | 🟡 부분 — 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 는 E2E 모듈(#11, #19), 숫자와 문자열은 `state` 로 왕복됨 |
-| `TextField` 입력기(IME) 처리 | ⏳ 계획(#10) |
+| `TextField(state=...)` 와 `TextFieldState` (`pythonx.compose.foundation.text.input`) | 🟡 부분 — python-multiplatform #73 모양의 가짜 호스트로 테스트됨; 입력기(IME) 조합 증거는 python-multiplatform E2E #26 (#10) |
| `DefaultIcons`(`Icons.Default`), `DefaultIcons.Add` 로 씀 | 🟡 부분 — python-multiplatform #37/#38 모양의 가짜 호스트로 테스트됨; `Icon(DefaultIcons.Add, …)` 의 실제 그리기는 python-multiplatform 의 렌더 테스트에 있음 |
| 색 스킴 | ⏳ 계획 |
| `remember_saveable`, 코루틴 스코프 | ⏳ 계획 |
diff --git a/pythonx/compose/foundation/text/__init__.py b/pythonx/compose/foundation/text/__init__.py
new file mode 100644
index 0000000..35431b0
--- /dev/null
+++ b/pythonx/compose/foundation/text/__init__.py
@@ -0,0 +1,5 @@
+"""`pythonx.compose.foundation.text` -- only the container of `pythonx.compose.foundation.text.input`.
+
+No manifest row maps `androidx.compose.foundation.text` itself; this file exists so the package
+is found by the wheel (`packages.find` does not descend into a directory without one).
+"""
diff --git a/pythonx/compose/foundation/text/input/__init__.py b/pythonx/compose/foundation/text/input/__init__.py
new file mode 100644
index 0000000..e18c8c9
--- /dev/null
+++ b/pythonx/compose/foundation/text/input/__init__.py
@@ -0,0 +1,5 @@
+"""`pythonx.compose.foundation.text.input` -- the Pythonic view of `androidx.compose.foundation.text.input` (see `pythonx/compose/_reexport.py`)."""
+
+from pythonx.compose._reexport import reexport
+
+__getattr__, __dir__ = reexport(__name__)
diff --git a/pythonx/compose/foundation/text/input/__init__.pyi b/pythonx/compose/foundation/text/input/__init__.pyi
new file mode 100644
index 0000000..33cc73c
--- /dev/null
+++ b/pythonx/compose/foundation/text/input/__init__.pyi
@@ -0,0 +1,78 @@
+# GENERATED by tools/gen_stubs.py from python-multiplatform's Kotlin-named stubs (artifact kotlin-stubs, workflow run 37103737430, commit a5028618, Compose 1.11.1). Do not edit.
+import typing as _t
+
+class _TextFieldState_clearText(_t.Protocol):
+ def __call__(self) -> None: ...
+
+class _TextFieldState_setTextAndPlaceCursorAtEnd(_t.Protocol):
+ def __call__(self, text: str) -> None: ...
+
+class _TextFieldState_setTextAndSelectAll(_t.Protocol):
+ def __call__(self, text: str) -> None: ...
+
+class _TextFieldState_toTextFieldBuffer(_t.Protocol):
+ def __call__(self) -> TextFieldBuffer: ...
+
+class InputTransformation:
+ """Kotlin: androidx.compose.foundation.text.input.InputTransformation"""
+
+class KeyboardActionHandler:
+ """Kotlin: androidx.compose.foundation.text.input.KeyboardActionHandler"""
+
+class OutputTransformation:
+ """Kotlin: androidx.compose.foundation.text.input.OutputTransformation"""
+
+class TextFieldBuffer:
+ """Kotlin: androidx.compose.foundation.text.input.TextFieldBuffer"""
+
+class TextFieldLineLimits:
+ """Kotlin: androidx.compose.foundation.text.input.TextFieldLineLimits"""
+
+class TextFieldState:
+ """Kotlin: androidx.compose.foundation.text.input.TextFieldState"""
+ def __init__(self, initialText: str = ..., initialSelection: _t.Any = ...) -> None:
+ """Kotlin constructor: androidx.compose.foundation.text.input.TextFieldState (androidx.compose.foundation.text.input.TextFieldState(initialText: kotlin.String, initialSelection: androidx.compose.ui.text.TextRange): androidx.compose.foundation.text.input.TextFieldState)"""
+ ...
+ clear_text: _t.ClassVar[_TextFieldState_clearText]
+ set_text_and_place_cursor_at_end: _t.ClassVar[_TextFieldState_setTextAndPlaceCursorAtEnd]
+ set_text_and_select_all: _t.ClassVar[_TextFieldState_setTextAndSelectAll]
+ to_text_field_buffer: _t.ClassVar[_TextFieldState_toTextFieldBuffer]
+ @property
+ def composition(self) -> _t.Any | None:
+ """Kotlin property: androidx.compose.foundation.text.input.TextFieldState.composition (androidx.compose.foundation.text.input.TextFieldState.composition: androidx.compose.ui.text.TextRange?)"""
+ ...
+ @property
+ def selection(self) -> _t.Any:
+ """Kotlin property: androidx.compose.foundation.text.input.TextFieldState.selection (androidx.compose.foundation.text.input.TextFieldState.selection: androidx.compose.ui.text.TextRange)"""
+ ...
+ @property
+ def text(self) -> str:
+ """Kotlin property: androidx.compose.foundation.text.input.TextFieldState.text (androidx.compose.foundation.text.input.TextFieldState.text: kotlin.CharSequence)"""
+ ...
+ @property
+ def undo_state(self) -> UndoState:
+ """Kotlin property: androidx.compose.foundation.text.input.TextFieldState.undoState (androidx.compose.foundation.text.input.TextFieldState.undoState: androidx.compose.foundation.text.input.UndoState)"""
+ ...
+
+class UndoState:
+ """Kotlin: androidx.compose.foundation.text.input.UndoState"""
+
+def clear_text(receiver: TextFieldState, /) -> None:
+ """Kotlin: androidx.compose.foundation.text.input.TextFieldState.clearText(): kotlin.Unit"""
+ ...
+
+def remember_text_field_state(initial_text: str = ..., initial_selection: _t.Any = ...) -> TextFieldState:
+ """Kotlin: androidx.compose.foundation.text.input.rememberTextFieldState(initialText: kotlin.String, initialSelection: androidx.compose.ui.text.TextRange): androidx.compose.foundation.text.input.TextFieldState"""
+ ...
+
+def set_text_and_place_cursor_at_end(receiver: TextFieldState, /, text: str) -> None:
+ """Kotlin: androidx.compose.foundation.text.input.TextFieldState.setTextAndPlaceCursorAtEnd(text: kotlin.String): kotlin.Unit"""
+ ...
+
+def set_text_and_select_all(receiver: TextFieldState, /, text: str) -> None:
+ """Kotlin: androidx.compose.foundation.text.input.TextFieldState.setTextAndSelectAll(text: kotlin.String): kotlin.Unit"""
+ ...
+
+def to_text_field_buffer(receiver: TextFieldState, /) -> TextFieldBuffer:
+ """Kotlin: androidx.compose.foundation.text.input.TextFieldState.toTextFieldBuffer(): androidx.compose.foundation.text.input.TextFieldBuffer"""
+ ...
diff --git a/pythonx/compose/material3/__init__.pyi b/pythonx/compose/material3/__init__.pyi
index bfab69e..59b7b05 100644
--- a/pythonx/compose/material3/__init__.pyi
+++ b/pythonx/compose/material3/__init__.pyi
@@ -1,6 +1,7 @@
# GENERATED by tools/gen_stubs.py from python-multiplatform's Kotlin-named stubs (artifact kotlin-stubs, workflow run 37103737430, commit a5028618, Compose 1.11.1). Do not edit.
import typing as _t
import pythonx.compose.foundation
+import pythonx.compose.foundation.text.input
import pythonx.compose.layout
import pythonx.compose.ui
import pythonx.compose.ui.graphics
@@ -2292,7 +2293,7 @@ def OutlinedTextField__String_Unit_Modifier_Boolean_Boolean_TextStyle_Unit_Unit_
"""Kotlin: androidx.compose.material3.OutlinedTextField(value: kotlin.String, onValueChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean, readOnly: kotlin.Boolean, textStyle: androidx.compose.ui.text.TextStyle, label: kotlin.Function0?, placeholder: kotlin.Function0?, leadingIcon: kotlin.Function0?, trailingIcon: kotlin.Function0?, prefix: kotlin.Function0?, suffix: kotlin.Function0?, supportingText: kotlin.Function0?, isError: kotlin.Boolean, visualTransformation: androidx.compose.ui.text.input.VisualTransformation, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, keyboardActions: androidx.compose.foundation.text.KeyboardActions, singleLine: kotlin.Boolean, maxLines: kotlin.Int, minLines: kotlin.Int, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource?, shape: androidx.compose.ui.graphics.Shape, colors: androidx.compose.material3.TextFieldColors): kotlin.Unit"""
...
-def OutlinedTextField__TextFieldState_Modifier_Boolean_Boolean_TextStyle_TextFieldLabelPosition_Unit_Unit_Unit_Unit_Unit_Unit_Unit_Boolean_InputTransformation_OutputTransformation_KeyboardOptions_KeyboardActionHandler_TextFieldLineLimits_Unit_ScrollState_Shape_TextFieldColors_PaddingValues_MutableInteractionSource(state: _t.Any, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: _t.Any | None = ..., output_transformation: _t.Any | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: _t.Any | None = ..., line_limits: _t.Any = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None:
+def OutlinedTextField__TextFieldState_Modifier_Boolean_Boolean_TextStyle_TextFieldLabelPosition_Unit_Unit_Unit_Unit_Unit_Unit_Unit_Boolean_InputTransformation_OutputTransformation_KeyboardOptions_KeyboardActionHandler_TextFieldLineLimits_Unit_ScrollState_Shape_TextFieldColors_PaddingValues_MutableInteractionSource(state: pythonx.compose.foundation.text.input.TextFieldState, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: pythonx.compose.foundation.text.input.InputTransformation | None = ..., output_transformation: pythonx.compose.foundation.text.input.OutputTransformation | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: pythonx.compose.foundation.text.input.KeyboardActionHandler | None = ..., line_limits: pythonx.compose.foundation.text.input.TextFieldLineLimits = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None:
"""Kotlin: androidx.compose.material3.OutlinedTextField(state: androidx.compose.foundation.text.input.TextFieldState, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean, readOnly: kotlin.Boolean, textStyle: androidx.compose.ui.text.TextStyle, labelPosition: androidx.compose.material3.TextFieldLabelPosition, label: kotlin.Function1?, placeholder: kotlin.Function0?, leadingIcon: kotlin.Function0?, trailingIcon: kotlin.Function0?, prefix: kotlin.Function0?, suffix: kotlin.Function0?, supportingText: kotlin.Function0?, isError: kotlin.Boolean, inputTransformation: androidx.compose.foundation.text.input.InputTransformation?, outputTransformation: androidx.compose.foundation.text.input.OutputTransformation?, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, lineLimits: androidx.compose.foundation.text.input.TextFieldLineLimits, onTextLayout: kotlin.Function2?, scrollState: androidx.compose.foundation.ScrollState, shape: androidx.compose.ui.graphics.Shape, colors: androidx.compose.material3.TextFieldColors, contentPadding: androidx.compose.foundation.layout.PaddingValues, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource?): kotlin.Unit"""
...
@@ -2468,7 +2469,7 @@ def TextField__String_Unit_Modifier_Boolean_Boolean_TextStyle_Unit_Unit_Unit_Uni
"""Kotlin: androidx.compose.material3.TextField(value: kotlin.String, onValueChange: kotlin.Function1, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean, readOnly: kotlin.Boolean, textStyle: androidx.compose.ui.text.TextStyle, label: kotlin.Function0?, placeholder: kotlin.Function0?, leadingIcon: kotlin.Function0?, trailingIcon: kotlin.Function0?, prefix: kotlin.Function0?, suffix: kotlin.Function0?, supportingText: kotlin.Function0?, isError: kotlin.Boolean, visualTransformation: androidx.compose.ui.text.input.VisualTransformation, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, keyboardActions: androidx.compose.foundation.text.KeyboardActions, singleLine: kotlin.Boolean, maxLines: kotlin.Int, minLines: kotlin.Int, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource?, shape: androidx.compose.ui.graphics.Shape, colors: androidx.compose.material3.TextFieldColors): kotlin.Unit"""
...
-def TextField__TextFieldState_Modifier_Boolean_Boolean_TextStyle_TextFieldLabelPosition_Unit_Unit_Unit_Unit_Unit_Unit_Unit_Boolean_InputTransformation_OutputTransformation_KeyboardOptions_KeyboardActionHandler_TextFieldLineLimits_Unit_ScrollState_Shape_TextFieldColors_PaddingValues_MutableInteractionSource(state: _t.Any, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: _t.Any | None = ..., output_transformation: _t.Any | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: _t.Any | None = ..., line_limits: _t.Any = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None:
+def TextField__TextFieldState_Modifier_Boolean_Boolean_TextStyle_TextFieldLabelPosition_Unit_Unit_Unit_Unit_Unit_Unit_Unit_Boolean_InputTransformation_OutputTransformation_KeyboardOptions_KeyboardActionHandler_TextFieldLineLimits_Unit_ScrollState_Shape_TextFieldColors_PaddingValues_MutableInteractionSource(state: pythonx.compose.foundation.text.input.TextFieldState, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: pythonx.compose.foundation.text.input.InputTransformation | None = ..., output_transformation: pythonx.compose.foundation.text.input.OutputTransformation | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: pythonx.compose.foundation.text.input.KeyboardActionHandler | None = ..., line_limits: pythonx.compose.foundation.text.input.TextFieldLineLimits = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None:
"""Kotlin: androidx.compose.material3.TextField(state: androidx.compose.foundation.text.input.TextFieldState, modifier: androidx.compose.ui.Modifier, enabled: kotlin.Boolean, readOnly: kotlin.Boolean, textStyle: androidx.compose.ui.text.TextStyle, labelPosition: androidx.compose.material3.TextFieldLabelPosition, label: kotlin.Function1?, placeholder: kotlin.Function0?, leadingIcon: kotlin.Function0?, trailingIcon: kotlin.Function0?, prefix: kotlin.Function0?, suffix: kotlin.Function0?, supportingText: kotlin.Function0?, isError: kotlin.Boolean, inputTransformation: androidx.compose.foundation.text.input.InputTransformation?, outputTransformation: androidx.compose.foundation.text.input.OutputTransformation?, keyboardOptions: androidx.compose.foundation.text.KeyboardOptions, onKeyboardAction: androidx.compose.foundation.text.input.KeyboardActionHandler?, lineLimits: androidx.compose.foundation.text.input.TextFieldLineLimits, onTextLayout: kotlin.Function2?, scrollState: androidx.compose.foundation.ScrollState, shape: androidx.compose.ui.graphics.Shape, colors: androidx.compose.material3.TextFieldColors, contentPadding: androidx.compose.foundation.layout.PaddingValues, interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource?): kotlin.Unit"""
...
@@ -2760,7 +2761,7 @@ def OutlinedCard(on_click: _t.Callable[[], None], modifier: pythonx.compose.ui.M
@_t.overload
def OutlinedTextField(value: str, on_value_change: _t.Callable[[str], None], modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label: _t.Callable[[], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., visual_transformation: _t.Any = ..., keyboard_options: _t.Any = ..., keyboard_actions: _t.Any = ..., single_line: bool = ..., max_lines: int = ..., min_lines: int = ..., interaction_source: _t.Any | None = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ...) -> None: ...
@_t.overload
-def OutlinedTextField(state: _t.Any, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: _t.Any | None = ..., output_transformation: _t.Any | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: _t.Any | None = ..., line_limits: _t.Any = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None: ... # type: ignore[overload-cannot-match]
+def OutlinedTextField(state: pythonx.compose.foundation.text.input.TextFieldState, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: pythonx.compose.foundation.text.input.InputTransformation | None = ..., output_transformation: pythonx.compose.foundation.text.input.OutputTransformation | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: pythonx.compose.foundation.text.input.KeyboardActionHandler | None = ..., line_limits: pythonx.compose.foundation.text.input.TextFieldLineLimits = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None: ... # type: ignore[overload-cannot-match]
@_t.overload
def OutlinedTextField(value: _t.Any, on_value_change: _t.Callable[[_t.Any], None], modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label: _t.Callable[[], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., visual_transformation: _t.Any = ..., keyboard_options: _t.Any = ..., keyboard_actions: _t.Any = ..., single_line: bool = ..., max_lines: int = ..., min_lines: int = ..., interaction_source: _t.Any | None = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ...) -> None: ... # type: ignore[overload-cannot-match]
@@ -2817,7 +2818,7 @@ def Text(text: str, modifier: pythonx.compose.ui.Modifier = ..., color: _t.Any =
@_t.overload
def TextField(value: str, on_value_change: _t.Callable[[str], None], modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label: _t.Callable[[], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., visual_transformation: _t.Any = ..., keyboard_options: _t.Any = ..., keyboard_actions: _t.Any = ..., single_line: bool = ..., max_lines: int = ..., min_lines: int = ..., interaction_source: _t.Any | None = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ...) -> None: ...
@_t.overload
-def TextField(state: _t.Any, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: _t.Any | None = ..., output_transformation: _t.Any | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: _t.Any | None = ..., line_limits: _t.Any = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None: ... # type: ignore[overload-cannot-match]
+def TextField(state: pythonx.compose.foundation.text.input.TextFieldState, modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label_position: TextFieldLabelPosition = ..., label: _t.Callable[[TextFieldLabelScope], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., input_transformation: pythonx.compose.foundation.text.input.InputTransformation | None = ..., output_transformation: pythonx.compose.foundation.text.input.OutputTransformation | None = ..., keyboard_options: _t.Any = ..., on_keyboard_action: pythonx.compose.foundation.text.input.KeyboardActionHandler | None = ..., line_limits: pythonx.compose.foundation.text.input.TextFieldLineLimits = ..., on_text_layout: _t.Callable[[_t.Any, _t.Callable[[], pythonx.compose.ui.text.TextLayoutResult | None]], None] | None = ..., scroll_state: pythonx.compose.foundation.ScrollState = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ..., content_padding: _t.Any = ..., interaction_source: _t.Any | None = ...) -> None: ... # type: ignore[overload-cannot-match]
@_t.overload
def TextField(value: _t.Any, on_value_change: _t.Callable[[_t.Any], None], modifier: pythonx.compose.ui.Modifier = ..., enabled: bool = ..., read_only: bool = ..., text_style: _t.Any = ..., label: _t.Callable[[], None] | None = ..., placeholder: _t.Callable[[], None] | None = ..., leading_icon: _t.Callable[[], None] | None = ..., trailing_icon: _t.Callable[[], None] | None = ..., prefix: _t.Callable[[], None] | None = ..., suffix: _t.Callable[[], None] | None = ..., supporting_text: _t.Callable[[], None] | None = ..., is_error: bool = ..., visual_transformation: _t.Any = ..., keyboard_options: _t.Any = ..., keyboard_actions: _t.Any = ..., single_line: bool = ..., max_lines: int = ..., min_lines: int = ..., interaction_source: _t.Any | None = ..., shape: pythonx.compose.ui.graphics.Shape = ..., colors: TextFieldColors = ...) -> None: ... # type: ignore[overload-cannot-match]
diff --git a/pythonx/compose/pythonx-map.toml b/pythonx/compose/pythonx-map.toml
index 163e13f..df664f9 100644
--- a/pythonx/compose/pythonx-map.toml
+++ b/pythonx/compose/pythonx-map.toml
@@ -34,6 +34,11 @@
# `ImageVector`, what an icon is (`Icons.Default.Add`): without this row its stub types it `Any`.
"pythonx.compose.ui.graphics.vector" = "androidx.compose.ui.graphics.vector"
+# `TextFieldState` and `rememberTextFieldState`, what `TextField(state=...)` takes (INTENT section
+# 5.8). The state-based overload keeps the buffer and the IME composing region inside Compose, so
+# nothing crosses into Python per keystroke; Python makes the state and reads or writes it on demand.
+"pythonx.compose.foundation.text.input" = "androidx.compose.foundation.text.input"
+
# The long spelling, kept beside the short one because both are real: the generated stubs use
# `pythonx.compose.layout`, and the render fixtures in `python-multiplatform` were written against
# the fully-qualified form. Mapping both means neither is wrong while the spelling settles.
diff --git a/tests/fake_host.py b/tests/fake_host.py
index 068f0a9..c9a9237 100644
--- a/tests/fake_host.py
+++ b/tests/fake_host.py
@@ -24,6 +24,23 @@
itself and a Kotlin object as its handle, as `_coerce` does; the stub keeps the Python object.
No walked row for these exists in this repository, so the shape is only as faithful as that reading.
+**The text-field rows are shaped after the stubs, not a walked jar** (python-multiplatform `26485a02`,
+which includes #73; the `kotlin-stubs` artefact's `androidx/compose/foundation/text/input/__init__.pyi`
+and the TextField overloads of `androidx/compose/material3/__init__.pyi`). Modelled:
+`TextFieldState__String_TextRange` (the constructor, named like a constructor-shaped function, both
+slots defaulted, returning OBJECT `TextFieldState`); `rememberTextFieldState` (a `@Composable`:
+`initialText`, `initialSelection`, then `$composer`, `$changed`, `$default` -- the slot names
+`PythonxAdapter` reads as the composable flag); `TextFieldState.text` (GETTER, receiver in `args[0]`,
+returning STRING declared `kotlin.CharSequence`, which the stub types `str`);
+`setTextAndPlaceCursorAtEnd` and `clearText` (FUNCTION, extension, `` slot 0 typed
+`TextFieldState`, as the stub's module functions take `receiver, /`); and two `TextField`
+overloads, `TextField__TextFieldState` (`state`, `modifier`, `enabled`, `readOnly`, `isError`, composable)
+and `TextField__String` (`value`, `modifier`, composable). Those two keep a subset of the real
+parameters: `label` and `onValueChange` are function-typed slots, and a function slot needs the
+binder's `NewFunction` rows, which this host does not carry. Neither draws; a call is logged in
+`composable_calls`. `TextRange` is an opaque OBJECT here; the binder's #78 caveat (a companion
+module shadowing `TextRange(2)`) is not modelled.
+
What this does **not** prove: that the Kotlin half marshals correctly, that Compose's own `padding`
runs, or that a handle is released on the Kotlin side. Those are
`WalkedArtifactComposeModifierTest` and `PythonxAdapterTest`, and they live in the other repository
@@ -57,6 +74,11 @@
ICONS_FILLED = ICONS + ".Filled"
IMAGE_VECTOR = "androidx.compose.ui.graphics.vector.ImageVector"
+TEXT_INPUT = "androidx.compose.foundation.text.input"
+TEXT_FIELD_STATE = TEXT_INPUT + ".TextFieldState"
+TEXT_RANGE = "androidx.compose.ui.text.TextRange"
+MATERIAL3 = "androidx.compose.material3"
+
EMPTY_MODIFIER = "androidx.compose.ui.emptyModifier"
"""The one entry the real walker does **not** produce; see `ComposeShapedFragment.EMPTY_MODIFIER`.
@@ -97,6 +119,15 @@ class StubIconSet:
__slots__ = ()
+class StubTextFieldState:
+ """A `TextFieldState` for this fixture: the committed text, which is all Python ever reads."""
+
+ __slots__ = ("text",)
+
+ def __init__(self, text=""):
+ self.text = text
+
+
class StubPaddingValues:
"""An ordinary object parameter, so two arity-2 overloads differ by declared type alone."""
@@ -136,6 +167,10 @@ def __init__(self):
self._handles = {}
self._next_handle = 1
self.filled = StubIconSet()
+ self.composable_calls = []
+ self.last_text_field_flags = None
+ self.last_text_field_modifier = None
+ self._composer = None
self._build()
# ------------------------------------------------------------------ the boundary (3 names)
@@ -172,8 +207,8 @@ def invoke(self, handle, args):
return result
def _unpack(self, value, tag, type_name):
- if tag != "OBJECT":
- return value
+ if tag != "OBJECT" or value is None:
+ return value # a null reference crosses as None
if type_name == KOTLIN_ANY and not self._is_handle(value):
return value # a Python object crossing an `Any?` slot as itself
return self._object(value)
@@ -219,6 +254,12 @@ def _object(self, handle):
raise AssertionError(f"no live handle {handle!r}")
return self._handles[handle]
+ def composer(self):
+ """A handle standing in for the `Composer` a composition would push (`push_composer`)."""
+ if self._composer is None:
+ self._composer = self._handle(object())
+ return self._composer
+
def live_handles(self):
return set(self._handles)
@@ -261,6 +302,7 @@ def body(receiver, args):
def _build(self):
self._scalar_boxes()
+ self._build_text_field()
self._add(
EMPTY_MODIFIER, 0, (), (), (), "OBJECT", MODIFIER, False, None, (),
lambda args: StubModifier(),
@@ -391,6 +433,85 @@ def _build(self):
lambda args: args[0].write(args[1]), kind="SETTER",
)
+ def _composable(self, name, params, body, returns=None):
+ """A `@Composable` row: `params` are (name, tag, type, has_default), then the synthetic slots.
+
+ `$composer`, `$changed` and `$default` close the row the way the Compose compiler spells
+ them; `PythonxAdapter` recognises a composable by `$composer` alone and fills the three.
+ """
+ names = tuple(p[0] for p in params) + ("$composer", "$changed", "$default")
+ tags = tuple(p[1] for p in params) + ("OBJECT", "INT", "INT")
+ types = tuple(p[2] for p in params) + ("androidx.compose.runtime.Composer", "kotlin.Int", "kotlin.Int")
+ defaults = tuple(p[3] for p in params) + (False, False, False)
+ label = name.rpartition(".")[2]
+
+ def run(args):
+ self.composable_calls.append(label)
+ return body(args)
+
+ return_tag, return_type = ("UNIT", "kotlin.Unit") if returns is None else ("OBJECT", returns)
+ self._add(name, len(names), names, tags, types, return_tag, return_type, False, None,
+ defaults, run)
+
+ def _build_text_field(self):
+ text_args = (("initialText", "STRING", "kotlin.String", True),
+ ("initialSelection", "OBJECT", TEXT_RANGE, True))
+ self._add(
+ f"{TEXT_FIELD_STATE}__String_TextRange", 2, ("initialText", "initialSelection"),
+ ("STRING", "OBJECT"), ("kotlin.String", TEXT_RANGE), "OBJECT", TEXT_FIELD_STATE,
+ False, None, (True, True),
+ lambda args: StubTextFieldState("" if args[0] is None else args[0]),
+ )
+ self._composable(
+ f"{TEXT_INPUT}.rememberTextFieldState", text_args,
+ lambda args: StubTextFieldState("" if args[0] is None else args[0]), TEXT_FIELD_STATE,
+ )
+ self._add(
+ f"{TEXT_FIELD_STATE}.text", 0, (), (), (), "STRING", "kotlin.CharSequence", False,
+ TEXT_FIELD_STATE, (), lambda args: args[0].text, kind="GETTER",
+ )
+
+ def write(label, effect):
+ def body(args):
+ self.calls.append(label)
+ effect(args)
+
+ return body
+
+ def place_at_end(args):
+ args[0].text = args[1]
+
+ def clear(args):
+ args[0].text = ""
+
+ for leaf, names, tags, types, effect in (
+ ("setTextAndPlaceCursorAtEnd", ("", "text"), ("OBJECT", "STRING"),
+ (TEXT_FIELD_STATE, "kotlin.String"), place_at_end),
+ ("clearText", ("",), ("OBJECT",), (TEXT_FIELD_STATE,), clear),
+ ):
+ self._add(
+ f"{TEXT_INPUT}.{leaf}", len(names), names, tags, types, "UNIT", "kotlin.Unit", True,
+ TEXT_FIELD_STATE, (False,) * len(names), write(leaf, effect),
+ )
+
+ def remember_flags(args):
+ self.last_text_field_flags = {"readOnly": args[3], "isError": args[4]}
+ self.last_text_field_modifier = None if args[1] is None else args[1].describe()
+
+ self._composable(
+ f"{MATERIAL3}.TextField__TextFieldState",
+ (("state", "OBJECT", TEXT_FIELD_STATE, False), ("modifier", "OBJECT", MODIFIER, True),
+ ("enabled", "BOOLEAN", "kotlin.Boolean", True),
+ ("readOnly", "BOOLEAN", "kotlin.Boolean", True),
+ ("isError", "BOOLEAN", "kotlin.Boolean", True)),
+ remember_flags,
+ )
+ self._composable(
+ f"{MATERIAL3}.TextField__String",
+ (("value", "STRING", "kotlin.String", False), ("modifier", "OBJECT", MODIFIER, True)),
+ lambda args: None,
+ )
+
def _read_icon(self, icon):
self.calls.append(f"Icons.Filled.{icon}")
return StubConstant(icon)
diff --git a/tests/test_text_field.py b/tests/test_text_field.py
new file mode 100644
index 0000000..22c2e87
--- /dev/null
+++ b/tests/test_text_field.py
@@ -0,0 +1,116 @@
+"""`TextField(state=...)`: the state-based overload, and the module that makes its state (issue #10).
+
+Compose owns the text buffer and the IME composing region, so nothing crosses into Python per
+keystroke; Python creates a `TextFieldState` and reads or writes it on demand. There is no
+per-widget wrapper: `TextField` is androidx's, re-exported by rule, and
+`pythonx.compose.foundation.text.input` is the manifest row that reaches `TextFieldState` and
+`rememberTextFieldState`.
+
+These tests go through `pythonx.compose.*` against the fake host's rows (`tests/fake_host.py`
+says what they are shaped after). What they prove is the Python half: the module resolves, a
+constructor and a composable are called with the right slots, a property is read as `str`, an
+extension is a snake_case method, and the `state=` keyword selects the state overload. They do not
+prove that Compose accepts a composing sequence with no Python callback -- that is
+python-multiplatform's render test (E2E #26), judged over up to four frames.
+"""
+
+from __future__ import annotations
+
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from test_chain import AdapterCase # noqa: E402
+
+
+class TextFieldCase(AdapterCase):
+
+ def setUp(self):
+ super().setUp()
+ self.needs_member_resolver()
+ if not hasattr(self.binding, "push_composer"):
+ self.skipTest("the binder has no @Composable binding (push_composer); python-multiplatform #73 shapes need it")
+ # Composable rows read the composer `push_composer` was given; a handle stands in for it.
+ self.binding.push_composer(self.host.composer())
+ self.addCleanup(self.binding.pop_composer)
+ from pythonx.compose.foundation.text import input as text_input
+ from pythonx.compose import material3
+
+ self.text_input, self.material3 = text_input, material3
+
+
+class TheTextFieldState(TextFieldCase):
+
+ def test_a_state_made_outside_composition_reads_its_text_as_a_str(self):
+ field = self.text_input.TextFieldState("hi")
+ self.assertEqual("hi", field.text)
+ self.assertIsInstance(field.text, str)
+
+ def test_the_initial_text_is_optional(self):
+ self.assertEqual("", self.text_input.TextFieldState().text)
+
+ def test_set_text_and_place_cursor_at_end_writes_it(self):
+ field = self.text_input.TextFieldState("hi")
+ field.set_text_and_place_cursor_at_end("hello")
+ self.assertEqual("hello", field.text)
+ self.assertIn("setTextAndPlaceCursorAtEnd", self.host.calls)
+
+ def test_clear_text_empties_it(self):
+ field = self.text_input.TextFieldState("hi")
+ field.clear_text()
+ self.assertEqual("", field.text)
+
+ def test_remember_text_field_state_is_the_composable_that_makes_one(self):
+ field = self.text_input.remember_text_field_state("draft")
+ self.assertEqual("draft", field.text)
+ self.assertEqual(["rememberTextFieldState"], self.host.composable_calls)
+
+ def test_remember_text_field_state_needs_a_composition(self):
+ self.binding.pop_composer()
+ self.addCleanup(lambda: self.binding.push_composer(self.host.composer()))
+ with self.assertRaises(RuntimeError):
+ self.text_input.remember_text_field_state("")
+
+ def test_the_kotlin_spelling_works_too(self):
+ field = self.text_input.TextFieldState(initialText="kt")
+ self.assertEqual("kt", field.text)
+
+
+class TheStateOverload(TextFieldCase):
+
+ def test_state_selects_the_state_overload(self):
+ field = self.text_input.TextFieldState("hi")
+ self.material3.TextField(state=field)
+ self.assertEqual(["TextField__TextFieldState"], self.host.composable_calls)
+
+ def test_keywords_are_snake_case_and_reach_the_row(self):
+ field = self.text_input.TextFieldState("hi")
+ self.material3.TextField(state=field, read_only=True, is_error=True)
+ self.assertEqual({"readOnly": True, "isError": True}, self.host.last_text_field_flags)
+
+ def test_the_notebook_spelling_is_a_modifier(self):
+ Modifier = self.register_empty()
+ field = self.text_input.TextFieldState("hi")
+ self.material3.TextField(state=field, modifier=Modifier.padding(8))
+ self.assertEqual("padding(8.0)", self.host.last_text_field_modifier)
+
+ def test_text_state_is_not_a_parameter(self):
+ field = self.text_input.TextFieldState("hi")
+ with self.assertRaises(TypeError):
+ self.material3.TextField(text_state=field)
+ self.assertEqual([], self.host.composable_calls)
+
+ def test_padding_is_not_a_parameter(self):
+ field = self.text_input.TextFieldState("hi")
+ with self.assertRaises(TypeError):
+ self.material3.TextField(state=field, padding=8)
+
+ def test_a_value_still_reaches_the_string_overload(self):
+ self.material3.TextField(value="x")
+ self.assertEqual(["TextField__String"], self.host.composable_calls)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 2a720f5..4849517 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -225,6 +225,36 @@ def test_an_unknown_default_icon_fails(self):
DefaultIcons.NotAnIcon
""", "NotAnIcon", "[attr-defined]")
+ def test_a_text_field_state_types_the_state_overload(self):
+ """Issue #10: `TextField(state=...)`, the state made by `pythonx.compose.foundation.text.input`."""
+ self.assertPasses("""
+ from pythonx.compose.foundation.text.input import TextFieldState, remember_text_field_state
+ from pythonx.compose.material3 import TextField
+ from pythonx.compose.ui import Modifier
+
+ f = TextFieldState("hi")
+ s: str = f.text
+ f.set_text_and_place_cursor_at_end("x")
+ f.clear_text()
+ g: TextFieldState = remember_text_field_state("")
+ TextField(state=f)
+ TextField(state=f, modifier=Modifier.padding(8))
+ """)
+
+ def test_a_text_field_without_a_kotlin_counterpart_fails(self):
+ """The notebook's `text_state=` and `padding=` are written `state=` and `modifier=`."""
+ self.assertFails("""
+ from pythonx.compose.foundation.text.input import TextFieldState
+ from pythonx.compose.material3 import TextField
+
+ TextField(text_state=TextFieldState("hi"))
+ """, "text_state", "[call-overload]")
+ self.assertFails("""
+ from pythonx.compose.foundation.text.input import TextFieldState
+
+ n: int = TextFieldState("hi").text
+ """, "[assignment]")
+
def test_a_kotlin_spelled_module_function_fails(self):
self.assertFails("""
from pythonx.compose.layout import fillMaxWidth