Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Compose(`androidx.compose.*`)를 파이썬에서 쓰기 위한 pip 패키지 **`

```
uv run --with pytest --with mypy pytest tests -q
PythonMultiplatform 체크아웃 없음 148 passed, 113 skipped
python-multiplatform develop 31c092f0+ 260 passed, 1 skipped
PythonMultiplatform 체크아웃 없음 148 passed, 114 skipped
python-multiplatform develop 31c092f0+ 261 passed, 1 skipped
describe_member 없는 체크아웃 같은 수, 그중 7 개 건너뜀 (python-multiplatform #54 이전)
describe(module, name) 없는 체크아웃 같은 수, 그중 13 개 건너뜀 (python-multiplatform #36 이전)
그보다 오래된 체크아웃 거기에 5 개 더 건너뜀 (#131 이전, snake_case 이름 없음)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ standalone desktop toolkit.
| Material 3 widgets (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 partial: re-exported by rule; render proofs per widget pending (#9) |
| 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` |
| Declared app root (`@app`), no update call; state through Kotlin's `mutable_state_of` | 🟡 partial: binder path tested against a fake host shaped after python-multiplatform #38; against real Compose, python-multiplatform's notebook E2E passes (#11, #19); numbers and strings round-trip |
| `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 |
Expand Down
21 changes: 12 additions & 9 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ Run from a worktree with `uv run --with pytest --with mypy pytest tests -q -rs`

| Environment | Result |
|---|---|
| No `PythonMultiplatform` checkout found | **148 passed, 113 skipped**, 167 subtests passed |
| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **260 passed, 1 skipped**, 198 subtests passed |
| No `PythonMultiplatform` checkout found | **148 passed, 114 skipped**, 164 subtests passed |
| python-multiplatform `develop` at `31c092f0` or later (property rows, #38; `describe_member`, #54; besides `add_member_resolver` and `describe(module, name)`) | **261 passed, 1 skipped**, 195 subtests passed |
| an older checkout, before python-multiplatform #131 (no binder-side snake_case names) | the same, with the snake_case method tests 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
112 tests that install the binder's layers through `tests/adapter.py` skip (one of them against the
113 tests that install the binder's layers through `tests/adapter.py` skip (one of them against the
installed wheel, #88), plus the notebook test.
Against a binder without `describe(module, name)`, the 13 that check grouped constants (§7, S7.1)
skip. Against a binder before python-multiplatform #131, the tests that call a snake_case method or
Expand Down Expand Up @@ -297,11 +297,14 @@ python-multiplatform #26 diagnosis). `pythonx.compose.runtime` provides:
is imported; afterwards it is an ordinary module attribute, the same object every time.
- `@app`: `app(fn)` sets `app_root.value = fn` and returns `fn` unchanged. Declaring the root again
replaces the value, so the screen follows. There is no update or refresh function (INTENT §5.1).
- `state(initial)` is **removed** (INTENT §5.11, issue #101; `planned` until that lands): Kotlin's
`mutable_state_of` does the same job. A screen keeps state with `remember_saveable` inside `App`
(S5.6) or with `mutable_state_of`. `_new_state` stays as the internal function behind `app_root`.

Until #101 lands, both states come from one internal function, `_new_state`. When the binder cannot supply
- `state(initial)` is **removed** (INTENT §5.11, issue #101): Kotlin's `mutable_state_of` does the
same job. A screen keeps state with `remember_saveable` inside `App` (S5.6) or with
`mutable_state_of`. `_new_state` stays as the internal function behind `app_root`. Tests:
`tests/test_app_root.py::TheLogic::test_state_is_not_a_name_of_the_module`,
`::TheBinderPath::test_state_is_not_served_even_with_a_binder`, and the stub check in
`tests/test_stubs.py`.

`app_root` comes from one internal function, `_new_state`. When the binder cannot supply
`mutableStateOf` it raises `RuntimeError` naming python-multiplatform #38. No pure-Python state is a
fallback: it would not make Compose recompose. Names other than `app_root` still resolve through the
re-export rule, and the module's own names win.
Expand All @@ -310,7 +313,7 @@ Evidence: python-multiplatform #38 (`31c092f0`) binds generic functions and prop
setters, and an `Any?` slot carries a Python object as itself, so `mutableStateOf(x)` is callable and
the returned `MutableState`'s `.value` reads and writes. Since python-multiplatform #69 (PR #81) the
binder boxes a Python scalar for an `Any` slot (bool, int within 64 bits, float, str) and unboxes it
on read, so `state(0)` and `counter.value += 1` work; an int beyond 64 bits is refused with the
on read, so `mutable_state_of(0)` and `counter.value += 1` work; an int beyond 64 bits is refused with the
reason. `pythonx` does none of this itself. Tests: `tests/test_app_root.py::TheLogic` (11) and `::TheStateFactory`
(4) run against a test-only fake state patched in for `_new_state`; `::TheBinderPath` (5) uses the
real `_new_state` through the binder's Python layer and `tests/fake_host.py`, whose `mutableStateOf`
Expand Down
9 changes: 4 additions & 5 deletions docs/guide/guide-composables.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,17 @@ <h2><span data-lang="en">Write a screen</span><span data-lang="ko">화면 하나

<section>
<h2><span data-lang="en">Declare the app and keep state</span><span data-lang="ko">앱 선언과 상태</span></h2>
<pre><code class="language-python">from pythonx.compose.runtime import app, state
<pre><code class="language-python">from pythonx.compose.runtime import app, mutable_state_of
from pythonx.compose.material3 import Text, Button

count = state(0)
count = mutable_state_of(0)

@app
def Main():
Button(on_click=lambda: setattr(count, &quot;value&quot;, count.value + 1),
content=lambda: Text(f&quot;Clicked {count.value}&quot;))</code></pre>
<p data-lang="en"><code>@app</code> declares the screen the host draws and returns the function unchanged. Declaring it again, for example in a notebook cell, replaces the screen; there is no update call. <code>state(initial)</code> is a Compose state, read and written through <code>.value</code>. The host is configured once with <code>PythonAppView(module = &quot;pythonx.compose.runtime&quot;, attribute = &quot;app_root&quot;)</code>.</p><p data-lang="ko"><code>@app</code> 은 호스트가 그리는 화면을 선언하고 함수를 그대로 돌려줍니다. 노트북 셀 등에서 다시 선언하면 화면이 바뀌며, 갱신 호출은 없습니다. <code>state(initial)</code> 는 Compose 상태이며 <code>.value</code> 로 읽고 씁니다. 호스트는 <code>PythonAppView(module = &quot;pythonx.compose.runtime&quot;, attribute = &quot;app_root&quot;)</code> 로 한 번만 설정합니다.</p>
<p data-lang="en"><strong>Status: partial.</strong> The binder path, which creates the state through Compose's <code>mutableStateOf</code>, 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: <code>state(0)</code> and <code>count.value += 1</code> work (python-multiplatform #69).</p><p data-lang="ko"><strong>상태: 부분.</strong> Compose 의 <code>mutableStateOf</code> 로 상태를 만드는 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트되었으며, 실제 Compose 증거는 아직 E2E 모듈입니다. 숫자와 문자열은 왕복됩니다: <code>state(0)</code> 과 <code>count.value += 1</code> 이 동작합니다(python-multiplatform #69).</p>
<p data-lang="en"><code>@app</code> declares the screen the host draws and returns the function unchanged. Declaring it again, for example in a notebook cell, replaces the screen; there is no update call. <code>mutable_state_of(initial)</code> is Compose's own <code>mutableStateOf</code>, read and written through <code>.value</code>; there is no second name for it. The host is configured once with <code>PythonAppView(module = &quot;pythonx.compose.runtime&quot;, attribute = &quot;app_root&quot;)</code>.</p><p data-lang="ko"><code>@app</code> 은 호스트가 그리는 화면을 선언하고 함수를 그대로 돌려줍니다. 노트북 셀 등에서 다시 선언하면 화면이 바뀌며, 갱신 호출은 없습니다. <code>mutable_state_of(initial)</code> 는 Compose 의 <code>mutableStateOf</code> 그대로이며 <code>.value</code> 로 읽고 씁니다. 다른 이름은 두지 않습니다. 호스트는 <code>PythonAppView(module = &quot;pythonx.compose.runtime&quot;, attribute = &quot;app_root&quot;)</code> 로 한 번만 설정합니다.</p>
<p data-lang="en"><strong>Status: partial.</strong> The binder path, which creates the state through Compose's <code>mutableStateOf</code>, is tested against a fake host shaped after python-multiplatform #38; against real Compose, python-multiplatform's notebook E2E passes. Numbers and strings round-trip: <code>mutable_state_of(0)</code> and <code>count.value += 1</code> work (python-multiplatform #69).</p><p data-lang="ko"><strong>상태: 부분.</strong> Compose 의 <code>mutableStateOf</code> 로 상태를 만드는 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트되었으며, 실제 Compose 에서는 python-multiplatform 의 노트북 E2E 가 통과합니다. 숫자와 문자열은 왕복됩니다: <code>mutable_state_of(0)</code> 과 <code>count.value += 1</code> 이 동작합니다(python-multiplatform #69).</p>
</section>

<section>
Expand Down Expand Up @@ -112,7 +112,6 @@ <h2><span data-lang="en">Spellings that changed</span><span data-lang="ko">바
<h2><span data-lang="en">What does not work yet</span><span data-lang="ko">아직 안 되는 것</span></h2>
<ul class="clean">
<li><span data-lang="en">The colour-scheme factories cannot be called yet: they take 36 colour parameters. <code>DefaultIcons</code> is <code>Icons.Default</code>, an icon is a value (an <code>ImageVector</code> to a type checker) read without parentheses: <code>Icon(DefaultIcons.Add, content_description=None)</code>, not the notebook's <code>DefaultIcons.Add()</code>. It is tested here against a fake host only; the real draw is in python-multiplatform's render test.</span><span data-lang="ko">색 스킴 팩토리는 색 매개변수를 36 개 받아 아직 호출할 수 없습니다. <code>DefaultIcons</code> 는 <code>Icons.Default</code> 이고, 아이콘은 괄호 없이 읽는 값(타입 검사기에는 <code>ImageVector</code>)입니다. 노트북의 <code>DefaultIcons.Add()</code> 가 아니라 <code>Icon(DefaultIcons.Add, content_description=None)</code>. 여기서는 가짜 호스트로만 테스트되며, 실제 그리기는 python-multiplatform 의 렌더 테스트에 있습니다.</span></li>
<li><span data-lang="en">A declared app root and Pythonic state (redefining a screen replaces it, with no update call) are planned (issue #11).</span><span data-lang="ko">선언형 앱 루트와 파이썬다운 상태(화면을 다시 정의하면 교체되고, 갱신 호출은 없음)는 계획 단계입니다(이슈 #11).</span></li>
<li><span data-lang="en"><code>TextField(state=…)</code> 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).</span><span data-lang="ko"><code>TextField(state=…)</code> 는 가짜 호스트로만 테스트되었습니다. 입력기의 조합 입력이 키 입력마다 파이썬 콜백 없이 유지되는지는 python-multiplatform 의 종단 간 테스트(#26)에서 증명합니다.</span></li>
<li><span data-lang="en"><code>remember_saveable</code> and the coroutine scopes the notebook imports are planned.</span><span data-lang="ko">노트북이 import 하는 <code>remember_saveable</code>, 코루틴 스코프는 계획 단계입니다.</span></li>
</ul>
Expand Down
2 changes: 1 addition & 1 deletion docs/locale/README_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ pip 패키지 **`pythonx-compose`**(파이썬 3.11 이상)로 배포되며, impo
| Material 3 위젯 (`Text`, `Button`, `Card`, `TextField`, …) | 🟡 부분: 규칙으로 재노출됨; 위젯별 렌더 증거 대기(#9) |
| 실제 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` 로 왕복됨 |
| 갱신 호출 없는 선언형 앱 루트(`@app`), 상태는 Kotlin 의 `mutable_state_of` | 🟡 부분: 바인더 경로는 python-multiplatform #38 모양의 가짜 호스트로 테스트됨, 실제 Compose 에서는 python-multiplatform 노트북 E2E 가 통과(#11, #19), 숫자와 문자열은 왕복됨 |
| `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 의 렌더 테스트에 있음 |
| 색 스킴 | ⏳ 계획 |
Expand Down
2 changes: 1 addition & 1 deletion docs/style/writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ visible string of the guide in `data-lang="en"` and `data-lang="ko"`).
use the plain declarative, 한다체: "한다", "두었다".
- **Terms.** Code identifiers, Kotlin names, commands and file names stay in their original spelling
inside code formatting, in both languages. A particle follows code with a space: "`UI.ipynb` 를",
"`state()` 로". Common technical words are written as Korean loanwords when that is how Korean
"`mutable_state_of()` 로". Common technical words are written as Korean loanwords when that is how Korean
developers say them ("바인더", "스텁", "컴포저블", "모듈"); otherwise the English term stays
("snake_case", "wheel", "proxy").
- **Status words** pair one to one: implemented / 구현, partial / 부분, planned / 계획.
Expand Down
22 changes: 9 additions & 13 deletions pythonx/compose/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,22 @@
slot detection; `@Composable` changes nothing about that and exists only so the notebook's own
spelling keeps working.

## The app root and `state`
## The app root

`@app` declares the screen the host draws; `state(initial)` makes a value Compose observes. The host
is configured once with
`@app` declares the screen the host draws. The host is configured once with
`PythonAppView(module = "pythonx.compose.runtime", attribute = "app_root")`: it reads the module
attribute `app_root`, a Compose `MutableState` whose `.value` is a zero-argument callable or `None`
(nothing is drawn). `app(fn)` writes `app_root.value = fn` and returns `fn` unchanged, so declaring
the root again -- in a notebook cell, say -- replaces the screen. There is deliberately no update or
refresh function (`docs/INTENT.md` section 5.1).

Both states come from `_new_state`, the one place that asks the binder for
`androidx.compose.runtime.mutableStateOf`. `app_root` is created on first read, because the binder may
be installed after `pythonx` is imported. A Python object with a `.value` would not make Compose
recompose, so when the binder cannot supply `mutableStateOf` (python-multiplatform #38) `_new_state`
raises instead of falling back to one.
`app_root` comes from `_new_state`, the internal function that asks the binder for
`androidx.compose.runtime.mutableStateOf`; it is created on first read, because the binder may be
installed after `pythonx` is imported. A screen's own state is not a name of this package: it is
Kotlin's `mutable_state_of`, re-exported by the rule, or `remember_saveable` (`docs/INTENT.md`
sections 5.9 and 5.11; the former `state()` is removed, issue #101). A Python object with a `.value`
would not make Compose recompose, so when the binder cannot supply `mutableStateOf`
(python-multiplatform #38) `_new_state` raises instead of falling back to one.

## How it is reached

Expand Down Expand Up @@ -99,11 +100,6 @@ def _new_state(initial):
return make(initial)


def state(initial):
"""A Compose state holding `initial`; read and write it through `.value`."""
return _new_state(initial)


def app(root):
"""Declare `root`, a zero-argument function, as the screen the host draws; returns it unchanged.

Expand Down
3 changes: 0 additions & 3 deletions pythonx/compose/runtime/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,5 @@ def Composable(target):

app_root: State

def state(initial):
...

def app(root):
...
Loading
Loading