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)fontSizefont_size Column(horizontal_alignment=…)horizontalAlignmenthorizontal_alignment Button(corner_radius=50)none — Compose takes a shape없음 — Compose 는 shape 를 받음shape -TextField(text_state=…)value, onValueChangevalue, on_value_change +TextField(text_state=…, padding=8)state, modifierstate=…, 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