diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..455e892 --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,30 @@ +extends: + - '@commitlint/config-conventional' + +rules: + type-enum: + - 2 + - always + - - build + - chore + - ci + - docs + - feat + - fix + - perf + - refactor + - revert + - style + - test + scope-case: + - 0 + subject-case: + - 0 + header-max-length: + - 1 + - always + - 100 + body-max-line-length: + - 0 + footer-max-line-length: + - 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e801b3..a177889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12'] steps: - name: Checkout @@ -43,6 +43,11 @@ jobs: run: | pip install . + - name: Build package + run: | + pip install build + python -m build + - name: Run tests run: | pytest -q diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..7935a34 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,79 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + e2e-android: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install PythonNative + run: pip install -e . + + - name: Install Maestro + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Build, install, and run E2E tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + arch: x86_64 + script: >- + cd examples/hello-world && + pn run android && + sleep 5 && + cd ../.. && + maestro test tests/e2e/android.yaml + + e2e-ios: + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PythonNative + run: pip install -e . + + - name: Install Maestro and idb + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + brew tap facebook/fb && brew install idb-companion + + - name: Build and launch iOS app + working-directory: examples/hello-world + run: pn run ios + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Run E2E tests + run: maestro --platform ios test tests/e2e/ios.yaml diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 0000000..f616d1a --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,46 @@ +name: PR Lint + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + pr-title: + name: PR title (Conventional Commits) + runs-on: ubuntu-latest + steps: + - name: Validate PR title + uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + build + chore + ci + docs + feat + fix + perf + refactor + revert + style + test + requireScope: false + subjectPattern: ^[a-z].+[^.]$ + subjectPatternError: | + Subject "{subject}" must start with a lowercase letter and must not + end with a period. + Example: "feat(cli): add init subcommand" + + commits: + name: Commit messages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07450cc..20ac529 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,47 @@ -name: Release to PyPI +name: Release on: push: - tags: - - 'v*.*.*' + branches: [main] + workflow_dispatch: jobs: - build-and-publish: + release: + name: Semantic Release runs-on: ubuntu-latest + concurrency: + group: release + cancel-in-progress: false permissions: - contents: read + contents: write id-token: write + steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' + + - name: Install build tools + run: python -m pip install -U pip build + + - name: Python Semantic Release + id: release + uses: python-semantic-release/python-semantic-release@v9 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Build sdist and wheel - run: | - python -m pip install -U pip build - python -m build + - name: Build package + if: steps.release.outputs.released == 'true' + run: python -m build - - name: Publish package to PyPI + - name: Publish to PyPI + if: steps.release.outputs.released == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true diff --git a/.gitignore b/.gitignore index c5de671..4f1c160 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,6 @@ cython_debug/ # Metadata *_metadata.json + +# macOS +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1f6260b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,251 @@ +# CHANGELOG + + +## v0.8.0 (2026-04-08) + +### Features + +- **hooks,reconciler**: Defer effects; add batching and use_reducer + ([`bf6bb57`](https://github.com/pythonnative/pythonnative/commit/bf6bb57b6f97c140820a902ea0eff6bf6a7ffdbc)) + +- **native_views,components**: Add flexbox-inspired layout system + ([`094d997`](https://github.com/pythonnative/pythonnative/commit/094d99786f7153a7286eb7db9775db0bb90abf1d)) + +- **navigation**: Add declarative navigation system + ([`828bbb0`](https://github.com/pythonnative/pythonnative/commit/828bbb0c83fba640a7055edf1237500f27493fd3)) + +- **navigation**: Add native tab bars and nested navigator forwarding + ([`2b80032`](https://github.com/pythonnative/pythonnative/commit/2b8003218267dd39b968c630b89bd5e212ea7254)) + +### Refactoring + +- **native_views**: Split monolithic module into platform-specific package + ([`d0068fd`](https://github.com/pythonnative/pythonnative/commit/d0068fdbcceb4745b02d8043b03eade2b54dde66)) + + +## v0.7.0 (2026-04-03) + +### Features + +- Replace class-based Page with function components, style prop, and use_navigation hook + ([`8103710`](https://github.com/pythonnative/pythonnative/commit/8103710aed5feb564583bb161cf81771669645fe)) + + +## v0.6.0 (2026-04-03) + +### Build System + +- **deps**: Drop Python 3.9 support (EOL October 2025) + ([`552cd99`](https://github.com/pythonnative/pythonnative/commit/552cd9958c463a51af9e33f0e254dab18135130f)) + +### Code Style + +- **cli**: Reformat pn.py for Black 2026 stable style + ([`298f884`](https://github.com/pythonnative/pythonnative/commit/298f884ce3e1c58a17c92484c5832ebae6f1beaa)) + +### Continuous Integration + +- **workflows**: Add package build step to verify sdist and wheel before release + ([`7fbf9c0`](https://github.com/pythonnative/pythonnative/commit/7fbf9c07988d4c543253dec8ba28da42c38cc3a9)) + +- **workflows,cli**: Fix e2e workflow script chaining and GitHub API auth + ([`01d1968`](https://github.com/pythonnative/pythonnative/commit/01d19683f41a4b00048dfbce687e510bec2e2d31)) + +### Documentation + +- Align branch prefixes with conventional commit types + ([`c6e0e08`](https://github.com/pythonnative/pythonnative/commit/c6e0e08cb0757dad6495c6fee36063699afba87a)) + +- **repo**: Align conventional commit scopes with module structure + ([`ecc39af`](https://github.com/pythonnative/pythonnative/commit/ecc39af78708bc5a83ba81501c7b65d985890de9)) + +- **repo**: Remove component table from README + ([`ab162c5`](https://github.com/pythonnative/pythonnative/commit/ab162c5b658b2367857ab998d3b3f750eca15b4a)) + +### Features + +- Add function components, hooks, layout, styling, hot reload, native APIs, and new UI components + ([`3bd87de`](https://github.com/pythonnative/pythonnative/commit/3bd87de4a8775e23eb4f081a31b9125f9b20861c)) + +- **cli,templates**: Add pythonVersion config, fix Android build, and wire pip requirements + ([`a529834`](https://github.com/pythonnative/pythonnative/commit/a529834a7bfe817a51ef2a5846c97c2f4deee321)) + +### Testing + +- Increase app startup wait for slow CI emulators + ([`4ff6b94`](https://github.com/pythonnative/pythonnative/commit/4ff6b9453a7687eeaf7777bf4a2ab542b32a7e25)) + + +## v0.5.0 (2026-03-31) + +### Continuous Integration + +- **workflows**: Add Maestro E2E tests for Android and iOS + ([`cfe247e`](https://github.com/pythonnative/pythonnative/commit/cfe247edf99da8ff870e2d4118ef74b2df5521c1)) + +### Features + +- **core**: Replace imperative widget API with declarative component model and reconciler + ([`b6b7721`](https://github.com/pythonnative/pythonnative/commit/b6b77216305202ea0c5197b29e725e14cbe99b5e)) + + +## v0.4.0 (2026-03-18) + +### Bug Fixes + +- **components,templates**: Restore hello-world on iOS and Android + ([`d7ac93b`](https://github.com/pythonnative/pythonnative/commit/d7ac93be202161a5c8328816a5c6ff8a96dde1d5)) + +### Continuous Integration + +- **workflows**: Add semantic-release pipeline and PR commit linting + ([`0711683`](https://github.com/pythonnative/pythonnative/commit/0711683f5b56751027bb1a5a63ee2d9afcd4b620)) + +- **workflows**: Append detailed changes link to release notes + ([`11d50a7`](https://github.com/pythonnative/pythonnative/commit/11d50a75dff850a3855a299f38f5885cf15cefc6)) + +- **workflows**: Fix duplicate release, and use changelog for release notes + ([`1cd5393`](https://github.com/pythonnative/pythonnative/commit/1cd5393e7bf20d5350052cfaa81fd511dc4ca3ca)) + +- **workflows**: Simplify release pipeline to use python-semantic-release defaults + ([`2766f24`](https://github.com/pythonnative/pythonnative/commit/2766f244f84d359e1ae74a4b029e0701fad4b0be)) + +### Documentation + +- **repo**: Rewrite README with banner, structured sections, and badges + ([`7c083f4`](https://github.com/pythonnative/pythonnative/commit/7c083f4e38367c6cd4163e0be8c78da1fdf8d3da)) + +- **repo**: Simplify README with badges and one-paragraph overview + ([`3ac84b1`](https://github.com/pythonnative/pythonnative/commit/3ac84b1a3f541b47121b46a687b78826f8d348f9)) + +### Features + +- **components**: Standardize fluent setters and align base signatures + ([`d236d89`](https://github.com/pythonnative/pythonnative/commit/d236d899690a4033effdcab4862a556a742fa6d1)) + +- **components,core**: Add layout/styling APIs and fluent setters + ([`6962d38`](https://github.com/pythonnative/pythonnative/commit/6962d3881bf091b3494fc2c964f7ea65a99ce606)) + +### Refactoring + +- **components**: Declare abstract static wrap in ScrollViewBase + ([`593fee4`](https://github.com/pythonnative/pythonnative/commit/593fee4fcf66678cb026de58115f959633d859b4)) + +- **core,components,examples**: Add annotations; tighten mypy + ([`86e4ffc`](https://github.com/pythonnative/pythonnative/commit/86e4ffc9e51810997006055434783416784c182f)) + + +## v0.3.0 (2025-10-22) + +### Build System + +- **repo**: Remove invalid PyPI classifier + ([`c8552e1`](https://github.com/pythonnative/pythonnative/commit/c8552e137e0176c0f5c61193e786429e2e93ac7c)) + +### Chores + +- **experiments**: Remove experiments directory + ([`caf6993`](https://github.com/pythonnative/pythonnative/commit/caf69936e085a3f487123ebcb3a6d807fefcc66c)) + +- **repo,core,mkdocs**: Bump version to 0.3.0 + ([`64d7c1c`](https://github.com/pythonnative/pythonnative/commit/64d7c1cfb448797305efc7f4014e56584f92fc1a)) + +### Documentation + +- **mkdocs**: Add Architecture page + ([`6d61ffc`](https://github.com/pythonnative/pythonnative/commit/6d61ffc64ca5db8ae688d09a748ddda2a1bc0af6)) + +### Features + +- **core,templates**: Add push/pop navigation and lifecycle wiring + ([`06ea22d`](https://github.com/pythonnative/pythonnative/commit/06ea22d215a1700685a7ca8070ca2189895ed25c)) + +- **templates,core**: Adopt Fragment-based Android navigation + ([`7a3a695`](https://github.com/pythonnative/pythonnative/commit/7a3a695477ece3cf76afd00f203523990f8789df)) + + +## v0.2.0 (2025-10-14) + +### Build System + +- **templates,cli**: Ship template dirs with package; drop zip artifacts + ([`7725b14`](https://github.com/pythonnative/pythonnative/commit/7725b1462c42d89f27fb4d3d733e73177c55d8ac)) + +### Chores + +- Clean up + ([`6c7a882`](https://github.com/pythonnative/pythonnative/commit/6c7a882895691903457a0a94d33192b6018c77fd)) + +- **core,components,cli**: Align lint, typing, and tests with CI + ([`30037d1`](https://github.com/pythonnative/pythonnative/commit/30037d17ad397952a88e3dfeb8bd003ced7319d8)) + +- **experiments**: Remove unused experiment directories + ([`db06fd1`](https://github.com/pythonnative/pythonnative/commit/db06fd101789392deee8c37263a61ee4d7106853)) + +- **repo,ci,docs**: Rename demo to examples/hello-world and update refs + ([`6d5b78e`](https://github.com/pythonnative/pythonnative/commit/6d5b78ea7dce66b5031b952928aed8d4a713fae8)) + +- **repo,core,mkdocs**: Bump version to 0.2.0 + ([`d3f8d31`](https://github.com/pythonnative/pythonnative/commit/d3f8d31942c3ca5d1657024e3a5cb332787afcd8)) + +- **templates**: Scrub DEVELOPMENT_TEAM from iOS template + ([`64ab266`](https://github.com/pythonnative/pythonnative/commit/64ab2666fe09f036934d3922ab55e8e599df3c35)) + +### Continuous Integration + +- **workflows,mkdocs**: Set CNAME to docs.pythonnative.com for docs deploy + ([`401a076`](https://github.com/pythonnative/pythonnative/commit/401a076dcb1fe0c19771f4a19141ee8da28c80e2)) + +### Documentation + +- **mkdocs**: Add roadmap and link in nav + ([`16ede97`](https://github.com/pythonnative/pythonnative/commit/16ede972d41b549853962c7056b65558c9ebd2f5)) + +- **mkdocs**: Update Getting Started, Hello World, Components, and platform guides + ([`f3a03b0`](https://github.com/pythonnative/pythonnative/commit/f3a03b01986365063535a2f336793cc6f21836db)) + +- **repo**: Add CONTRIBUTING.md + ([`f61cb85`](https://github.com/pythonnative/pythonnative/commit/f61cb85301c7bff57299b4c814319e9262f0f5ef)) + +### Features + +- Update README + ([`e839585`](https://github.com/pythonnative/pythonnative/commit/e8395855acf5d38a0e5987475900f4eeb1eee313)) + +- **cli,mkdocs,tests**: Add pn init/run/clean; use bundled templates + ([`9c61757`](https://github.com/pythonnative/pythonnative/commit/9c61757713fe60b5e98756f552681a782f397f3a)) + +- **cli,templates**: Auto-select iOS sim; guard PythonKit + ([`7b7c59c`](https://github.com/pythonnative/pythonnative/commit/7b7c59c262f2510a5fb46e455c13a2fc56086845)) + +- **cli,templates**: Bundle offline templates; add run --prepare-only + ([`d9dd821`](https://github.com/pythonnative/pythonnative/commit/d9dd821bc18289f1f1a367e737cfe7d5bfaf6ee3)) + +- **cli,templates**: Dev-first templates; stage in-repo lib for pn run + ([`b3dd731`](https://github.com/pythonnative/pythonnative/commit/b3dd731bd5efcca8e1a47f8f888fc6123854a40c)) + +- **cli,templates,core**: Bootstrap entrypoint; pn run shows Hello UI + ([`2805e1d`](https://github.com/pythonnative/pythonnative/commit/2805e1d5c6a58eb718b94ba0ce57c1078a08d578)) + +- **cli,templates,core**: Fetch iOS Python runtime and bootstrap PythonKit + ([`bcc0916`](https://github.com/pythonnative/pythonnative/commit/bcc0916a5b7427874ab7a5971a6a9941c4222c77)) + +- **components,utils**: Unify constructors; set Android context + ([`4c06b67`](https://github.com/pythonnative/pythonnative/commit/4c06b67214ea7fc4530a0d39b7105cfb62d20cf5)) + +- **repo,mkdocs,workflows**: Migrate to src layout; add pyproject and docs scaffold + ([`f273922`](https://github.com/pythonnative/pythonnative/commit/f273922e8a0494df7ba2cd59a3ad2ef54f918d3e)) + +### Refactoring + +- **cli**: Make pn.py typing py3.9-compatible and wrap long lines + ([`b38da78`](https://github.com/pythonnative/pythonnative/commit/b38da78dac52e42968efa6f4115b6b84de65b3b5)) + +- **components,core**: Align component names with docs + ([`a326ceb`](https://github.com/pythonnative/pythonnative/commit/a326ceb23c2cfaba409f11451a1c0000f0afbf5e)) + + +## v0.1.0 (2025-10-14) + + +## v0.0.1 (2025-10-14) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0298c8..37daba4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in contributing. This repository contains the PythonNat ## Quick start -Development uses Python ≥ 3.9. +Development uses Python ≥ 3.10. ```bash # create and activate a venv (recommended) @@ -94,27 +94,31 @@ Accepted types (standard): - `style` – formatting/whitespace (no code behavior) - `test` – add/adjust tests only -Recommended scopes (match the smallest accurate directory/module): - -- Library/CLI scopes: - - `cli` – `src/pythonnative/cli/` (the `pn` command) - - `core` – `src/pythonnative/pythonnative/` package internals - - `components` – UI view modules under `src/pythonnative/pythonnative/` (e.g., `button.py`, `label.py`) - - `utils` – utilities like `utils.py` - - `tests` – `tests/` - -- Templates and examples: - - `templates` – `templates/` (Android/iOS templates, zips) - - `examples` – `examples/` (e.g., `hello-world/`) - - - -- Repo‑level and ops: +Recommended scopes (choose the smallest, most accurate unit; prefer module/directory names): + +- Module/directory scopes: + - `cli` – CLI tool and `pn` command (`src/pythonnative/cli/`) + - `components` – declarative element-creating functions (`components.py`) + - `element` – Element descriptor class (`element.py`) + - `hooks` – function components and hooks (`hooks.py`) + - `hot_reload` – file watcher and module reloader (`hot_reload.py`) + - `native_modules` – native API modules for device capabilities (`native_modules/`) + - `native_views` – platform-specific native view creation and updates (`native_views/`) + - `package` – `src/pythonnative/__init__.py` exports and package boundary + - `page` – Page component, lifecycle, and reactive state (`page.py`) + - `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`) + - `style` – StyleSheet and theming (`style.py`) + - `utils` – shared utilities (`utils.py`) + +- Other scopes: - `deps` – dependency updates and version pins - - `docker` – containerization files (e.g., `Dockerfile`) - - `repo` – top‑level files (`README.md`, `CONTRIBUTING.md`, `.gitignore`, licenses) + - `examples` – example apps under `examples/` - `mkdocs` – documentation site (MkDocs/Material) configuration and content under `docs/` - - `workflows` – CI pipelines (e.g., `.github/workflows/`) + - `pyproject` – `pyproject.toml` packaging/build metadata + - `repo` – repository metadata and top‑level files (`README.md`, `CONTRIBUTING.md`, `.gitignore`, licenses) + - `templates` – Android/iOS project templates under `src/pythonnative/templates/` + - `tests` – unit/integration/E2E tests under `tests/` + - `workflows` – CI pipelines under `.github/workflows/` Note: Avoid redundant type==scope pairs (e.g., `docs(docs)`). Prefer a module scope (e.g., `docs(core)`) or `docs(repo)` for top‑level updates. @@ -124,12 +128,12 @@ Examples: build(deps): refresh pinned versions chore(repo): add contributing guidelines ci(workflows): add publish job -docs(core): clarify ListView data contract -feat(components): add MaterialSearchBar +docs(reconciler): clarify diffing algorithm +feat(components): add Slider element fix(cli): handle missing Android SDK gracefully -perf(core): reduce allocations in list diffing +perf(reconciler): reduce allocations in list diffing refactor(utils): extract path helpers -test(tests): cover ios template copy flow +test: cover iOS template copy flow ``` Examples (no scope): @@ -205,25 +209,32 @@ Co-authored-by: Name ## Pull request checklist +- PR title: Conventional Commits format (CI-enforced by `pr-lint.yml`). - Tests: added/updated; `pytest` passes. - Lint/format: `ruff check .`, `black` pass. -- Docs: update `README.md` and any Django docs pages if behavior changes. +- Docs: update `README.md` if behavior changes. - Templates: update `templates/` if generator output changes. - No generated artifacts committed. ## Versioning and releases -- The library version is tracked in `pyproject.toml` (`project.version`). Use SemVer. -- Workflow: - - Contributors: branch off `main` (or `dev` if used) and open PRs. - - Maintainer (release): bump version, tag, and publish to PyPI. - - Tag on `main`: `git tag -a vX.Y.Z -m "Release vX.Y.Z" && git push --tags`. +- The version is tracked in `pyproject.toml` (`project.version`) and mirrored in `src/pythonnative/__init__.py` as `__version__`. Both files are updated automatically by [python-semantic-release](https://python-semantic-release.readthedocs.io/). +- **Automated release pipeline** (on every merge to `main`): + 1. `python-semantic-release` scans Conventional Commit messages since the last tag. + 2. It determines the next SemVer bump: `feat` → **minor**, `fix`/`perf` → **patch**, `BREAKING CHANGE` → **major** (minor while version < 1.0). + 3. Version files are updated, `CHANGELOG.md` is generated, and a tagged release commit (`chore(release): vX.Y.Z`) is pushed. + 4. A GitHub Release is created with auto-generated release notes and the built sdist/wheel attached. + 5. When drafts are disabled, the package is also published to PyPI via Trusted Publishing. +- **Draft / published toggle**: the `DRAFT_RELEASE` variable at the top of `.github/workflows/release.yml` controls release mode. Set to `"true"` (the default) for draft GitHub Releases with PyPI publishing skipped; flip to `"false"` to publish releases and upload to PyPI immediately. +- Commit types that trigger a release: `feat` (minor), `fix` and `perf` (patch), `BREAKING CHANGE` (major). All other types (`build`, `chore`, `ci`, `docs`, `refactor`, `revert`, `style`, `test`) are recorded in the changelog but do **not** trigger a release on their own. +- Tag format: `v`-prefixed (e.g., `v0.4.0`). +- Manual version bumps are no longer needed — just merge PRs with valid Conventional Commit titles. For ad-hoc runs, use the workflow's **Run workflow** button (`workflow_dispatch`). ### Branch naming (suggested) - Use lowercase kebab‑case; concise (≤ 40 chars). -- Prefix conventions: - - `feature/-` +- Branch prefixes match Conventional Commit types: + - `feat/-` - `fix/-` - `chore/` - `docs/` @@ -232,23 +243,56 @@ Co-authored-by: Name - `test/` - `perf/` - `build/` - - `release/vX.Y.Z` - - `hotfix/` Examples: ```text -feature/cli-init +feat/cli-init fix/core-threading-deadlock-123 docs/contributing ci/publish-pypi build/lock-versions refactor/utils-paths test/templates-android -release/v0.2.0 -hotfix/cli-regression +fix/cli-regression +``` + +### E2E tests (Maestro) + +End-to-end tests use [Maestro](https://maestro.dev/) to drive the hello-world example on real emulators and simulators. + +```bash +# Install Maestro (one-time) +curl -Ls "https://get.maestro.mobile.dev" | bash + +# For iOS, also install idb-companion +brew tap facebook/fb && brew install idb-companion ``` +Build and launch the app first, then run the tests: + +```bash +cd examples/hello-world + +# Android (emulator must be running) +pn run android +maestro test ../../tests/e2e/android.yaml + +# iOS (simulator must be running; --platform ios needed when an Android emulator is also connected) +pn run ios +maestro --platform ios test ../../tests/e2e/ios.yaml +``` + +Test flows live in `tests/e2e/flows/` and cover main page rendering, counter interaction, and multi-page navigation. The `e2e.yml` workflow runs these automatically on pushes to `main` and PRs. + +### CI + +- **CI** (`ci.yml`): runs formatter, linter, type checker, and tests on every push and PR. +- **E2E** (`e2e.yml`): builds the hello-world example on Android (Linux emulator) and iOS (macOS simulator), then runs Maestro flows. Triggers on pushes to `main`, PRs, and manual dispatch. +- **PR Lint** (`pr-lint.yml`): validates the PR title against Conventional Commits format (protects squash merges) and checks individual commit messages via commitlint (protects rebase merges). Recommended: add the **PR title** job as a required status check in branch-protection settings. +- **Release** (`release.yml`): runs on merge to `main`; computes version, generates changelog, tags, creates GitHub Release, and (when `DRAFT_RELEASE` is `"false"`) publishes to PyPI. +- **Docs** (`docs.yml`): deploys documentation to GitHub Pages on push to `main`. + ## Security and provenance - Avoid bundling secrets or credentials in templates or code. diff --git a/LICENSE b/LICENSE index 18fff31..ad12cea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 PythonNative +Copyright (c) 2026 Owen Carey Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4418c1b..62f5958 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,79 @@ -# PythonNative +

+ PythonNative +

-**PythonNative** is a cross-platform toolkit that allows you to create native Android and iOS apps using Python. Inspired by frameworks like React Native and NativeScript, PythonNative provides a Pythonic interface for building native UI elements, handling lifecycle events, and accessing platform-specific APIs. +

+ Build native Android and iOS apps in Python. +

+ +

+ CI + Release + PyPI Version + Python Versions + License: MIT + Docs +

+ +

+ Documentation · + Getting Started · + Examples · + Contributing +

+ +--- + +## Overview + +PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a **declarative, React-like component model** with hooks and automatic reconciliation, powered by Chaquopy on Android and rubicon-objc on iOS. Write function components with `use_state`, `use_effect`, and friends, just like React, and let PythonNative handle creating and updating native views. ## Features -- **Native UI Components**: Create and manage native buttons, labels, lists, and more, all from Python. -- **Cross-Platform**: Write once, run on both Android and iOS. -- **Lifecycle Management**: Handle app lifecycle events with ease. -- **Native API Access**: Access device features like Camera, Geolocation, and Notifications. -- **Powered by Proven Tools**: PythonNative integrates seamlessly with [Rubicon](https://beeware.org/project/projects/bridges/rubicon/) for iOS and [Chaquopy](https://chaquo.com/chaquopy/) for Android, ensuring robust native performance. +- **Declarative UI:** Describe *what* your UI should look like with element functions (`Text`, `Button`, `Column`, `Row`, etc.). PythonNative creates and updates native views automatically. +- **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern. +- **`style` prop:** Pass all visual and layout properties through a single `style` dict, composable via `StyleSheet`. +- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation. +- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. +- **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app. +- **Navigation:** Push and pop screens with argument passing via the `use_navigation()` hook. +- **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. ## Quick Start ### Installation -First, install PythonNative via pip: - ```bash pip install pythonnative ``` -### Create Your First App +### Usage -Initialize a new PythonNative app: +```python +import pythonnative as pn -```bash -pn init my_app -``` -Your app directory will look like this: - -```text -my_app/ -├── README.md -├── app -│ ├── __init__.py -│ ├── main_page.py -│ └── resources -├── pythonnative.json -├── requirements.txt -└── tests +@pn.component +def MainPage(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Tap me", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) ``` -### Writing Views +## Documentation -In PythonNative, everything is a view. Here's a simple example of how to create a main page with a list view: +Visit [docs.pythonnative.com](https://docs.pythonnative.com/) for the full documentation, including getting started guides, platform-specific instructions for Android and iOS, API reference, and working examples. -```python -import pythonnative as pn - -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack_view = pn.StackView(self.native_instance) - list_data = ["item_{}".format(i) for i in range(100)] - list_view = pn.ListView(self.native_instance, list_data) - stack_view.add_view(list_view) - self.set_root_view(stack_view) -``` +## Contributing -### Run the app +Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, coding standards, and guidelines for submitting pull requests. -```bash -pn run android -pn run ios -``` - -## Documentation +## License -For detailed guides and API references, visit the [PythonNative documentation](https://docs.pythonnative.com/). +[MIT](LICENSE) diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md new file mode 100644 index 0000000..9def148 --- /dev/null +++ b/docs/api/component-properties.md @@ -0,0 +1,213 @@ +# Component Property Reference + +All visual and layout properties are passed via the `style` dict (or list of dicts) to element functions. Behavioural properties (callbacks, data, content) remain as keyword arguments. + +## Common layout properties (inside `style`) + +All components accept these layout properties in their `style` dict: + +- `width` — fixed width in dp (Android) / pt (iOS) +- `height` — fixed height +- `flex` — flex grow factor (shorthand for `flex_grow`) +- `flex_grow` — how much a child grows to fill available space +- `flex_shrink` — how much a child shrinks when space is limited +- `margin` — outer spacing (int, float, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) +- `min_width`, `max_width` — width constraints +- `min_height`, `max_height` — height constraints +- `align_self` — override parent alignment (`"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`) +- `key` — stable identity for reconciliation (passed as a kwarg, not inside `style`) + +## View + +```python +pn.View(*children, style={ + "flex_direction": "column", + "justify_content": "center", + "align_items": "center", + "overflow": "hidden", + "spacing": 8, + "padding": 16, + "background_color": "#F5F5F5", +}) +``` + +Universal flex container (like React Native's `View`). Defaults to `flex_direction: "column"`. + +Flex container properties (inside `style`): + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing`, `padding`, `background_color` + +## Text + +```python +pn.Text(text, style={"font_size": 18, "color": "#333", "bold": True, "text_align": "center"}) +``` + +- `text` — display string (positional) +- Style properties: `font_size`, `color`, `bold`, `text_align`, `background_color`, `max_lines` + +## Button + +```python +pn.Button(title, on_click=handler, style={"color": "#FFF", "background_color": "#007AFF", "font_size": 16}) +``` + +- `title` — button label (positional) +- `on_click` — callback `() -> None` +- `enabled` — interactive state (kwarg, default `True`) +- Style properties: `color`, `background_color`, `font_size` + +## Column / Row + +```python +pn.Column(*children, style={"spacing": 12, "padding": 16, "align_items": "center"}) +pn.Row(*children, style={"spacing": 8, "justify_content": "space_between"}) +``` + +Convenience wrappers for `View` with fixed `flex_direction`: + +- `Column` = `View` with `flex_direction: "column"` (always vertical) +- `Row` = `View` with `flex_direction: "row"` (always horizontal) + +- `*children` — child elements (positional) +- Style properties: + - `spacing` — gap between children (dp / pt) + - `padding` — inner padding (int for all sides, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) + - `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`, `"leading"`, `"trailing"` + - `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` + - `overflow` — `"visible"` (default), `"hidden"` + - `background_color` — container background + +## SafeAreaView + +```python +pn.SafeAreaView(*children, style={"background_color": "#FFF", "padding": 8}) +``` + +Container that respects safe area insets (notch, status bar). + +## ScrollView + +```python +pn.ScrollView(child, style={"background_color": "#FFF"}) +``` + +## TextInput + +```python +pn.TextInput(value="", placeholder="Enter text", on_change=handler, secure=False, + style={"font_size": 16, "color": "#000", "background_color": "#FFF"}) +``` + +- `on_change` — callback `(str) -> None` receiving new text + +## Image + +```python +pn.Image(source="https://example.com/photo.jpg", style={"width": 200, "height": 150, "scale_type": "cover"}) +``` + +- `source` — image URL (`http://...` / `https://...`) or local resource name +- Style properties: `width`, `height`, `scale_type` (`"cover"`, `"contain"`, `"stretch"`, `"center"`), `background_color` + +## Switch + +```python +pn.Switch(value=False, on_change=handler) +``` + +- `on_change` — callback `(bool) -> None` + +## Slider + +```python +pn.Slider(value=0.5, min_value=0.0, max_value=1.0, on_change=handler) +``` + +- `on_change` — callback `(float) -> None` + +## ProgressBar + +```python +pn.ProgressBar(value=0.5, style={"background_color": "#EEE"}) +``` + +- `value` — 0.0 to 1.0 + +## ActivityIndicator + +```python +pn.ActivityIndicator(animating=True) +``` + +## WebView + +```python +pn.WebView(url="https://example.com") +``` + +## Spacer + +```python +pn.Spacer(size=16, flex=1) +``` + +- `size` — fixed dimension in dp / pt +- `flex` — flex grow factor + +## Pressable + +```python +pn.Pressable(child, on_press=handler, on_long_press=handler) +``` + +Wraps any child element with tap/long-press handling. + +## Modal + +```python +pn.Modal(*children, visible=show_modal, on_dismiss=handler, title="Confirm", + style={"background_color": "#FFF"}) +``` + +Overlay dialog shown when `visible=True`. + +## TabBar + +```python +pn.Element("TabBar", { + "items": [ + {"name": "Home", "title": "Home"}, + {"name": "Settings", "title": "Settings"}, + ], + "active_tab": "Home", + "on_tab_select": handler, +}) +``` + +Native tab bar — typically created automatically by `Tab.Navigator`. + +| Platform | Native view | +|----------|--------------------------| +| Android | `BottomNavigationView` | +| iOS | `UITabBar` | + +- `items` — list of `{"name": str, "title": str}` dicts defining each tab +- `active_tab` — the `name` of the currently active tab +- `on_tab_select` — callback `(str) -> None` receiving the selected tab name + +## FlatList + +```python +pn.FlatList(data=items, render_item=render_fn, key_extractor=key_fn, + separator_height=1, style={"background_color": "#FFF"}) +``` + +- `data` — list of items +- `render_item` — `(item, index) -> Element` function +- `key_extractor` — `(item, index) -> str` for stable keys +- `separator_height` — spacing between items diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 0233735..f05178c 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -1,11 +1,92 @@ # pythonnative package -API reference will be generated here via mkdocstrings. +## Public API -Key flags and helpers: +### create_page -- `pythonnative.utils.IS_ANDROID`: platform flag with robust detection for Chaquopy/Android. -- `pythonnative.utils.get_android_context()`: returns the current Android Activity/Context when running on Android. -- `pythonnative.utils.set_android_context(ctx)`: set by `pythonnative.Page` on Android; you generally don’t call this directly. -- `pythonnative.utils.get_android_fragment_container()`: returns the current Fragment container `ViewGroup` used for page rendering. -- `pythonnative.utils.set_android_fragment_container(viewGroup)`: set by the host `PageFragment`; you generally don’t call this directly. +`pythonnative.create_page(...)` — called internally by native templates to bootstrap the root component. You don't call this directly. + +### Element functions + +- `pythonnative.Text`, `Button`, `Column`, `Row`, `ScrollView`, `TextInput`, `Image`, `Switch`, `ProgressBar`, `ActivityIndicator`, `WebView`, `Spacer` +- `pythonnative.View`, `SafeAreaView`, `Modal`, `Slider`, `Pressable`, `FlatList` + +Each returns an `Element` descriptor. Visual and layout properties are passed via `style={...}`. See the Component Property Reference for full details. + +### ErrorBoundary + +`pythonnative.ErrorBoundary(child, fallback=...)` — catches render errors in *child* and displays *fallback* instead. *fallback* may be an `Element` or a callable that receives the exception and returns an `Element`. + +### Element + +`pythonnative.Element` — the descriptor type returned by element functions. You generally don't create these directly. + +### Hooks + +Function component primitives: + +- `pythonnative.component` — decorator to create a function component +- `pythonnative.use_state(initial)` — local component state +- `pythonnative.use_reducer(reducer, initial_state)` — reducer-based state management; returns `(state, dispatch)` +- `pythonnative.use_effect(effect, deps)` — side effects, run after native commit +- `pythonnative.use_navigation()` — navigation handle (navigate/go_back/get_params) +- `pythonnative.use_route()` — convenience hook for current route params +- `pythonnative.use_focus_effect(effect, deps)` — like `use_effect` but only runs when the screen is focused +- `pythonnative.use_memo(factory, deps)` — memoised values +- `pythonnative.use_callback(fn, deps)` — stable function references +- `pythonnative.use_ref(initial)` — mutable ref object +- `pythonnative.use_context(context)` — read from context +- `pythonnative.create_context(default)` — create a new context +- `pythonnative.Provider(context, value, child)` — provide a context value + +### Navigation + +Declarative, component-based navigation system: + +- `pythonnative.NavigationContainer(child)` — root container for the navigation tree +- `pythonnative.create_stack_navigator()` — create a stack-based navigator (returns object with `.Navigator` and `.Screen`) +- `pythonnative.create_tab_navigator()` — create a tab-based navigator +- `pythonnative.create_drawer_navigator()` — create a drawer-based navigator + +### Batching + +- `pythonnative.batch_updates()` — context manager that batches multiple state updates into a single re-render + +### Styling + +- `pythonnative.StyleSheet` — utility for creating and composing style dicts +- `pythonnative.ThemeContext` — built-in theme context (defaults to light theme) + +## Native API modules + +- `pythonnative.native_modules.Camera` — photo capture and gallery picking +- `pythonnative.native_modules.Location` — GPS / location services +- `pythonnative.native_modules.FileSystem` — app-scoped file I/O +- `pythonnative.native_modules.Notifications` — local push notifications + +## Internal helpers + +- `pythonnative.utils.IS_ANDROID` — platform flag with robust detection for Chaquopy/Android. +- `pythonnative.utils.get_android_context()` — returns the current Android `Activity`/`Context` when running on Android. +- `pythonnative.utils.set_android_context(ctx)` — set internally during page bootstrapping; you generally don't call this directly. +- `pythonnative.utils.get_android_fragment_container()` — returns the current Fragment container `ViewGroup` used for page rendering. + +## Reconciler + +`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, context providers, and error boundaries. Effects are flushed after each mount/reconcile pass. Used internally by `create_page`. + +## Hot reload + +`pythonnative.hot_reload.FileWatcher` — watches a directory for file changes and triggers a callback. Used by `pn run --hot-reload`. + +`pythonnative.hot_reload.ModuleReloader` — reloads changed Python modules on the device and triggers page re-rendering. + +## Native view registry + +`pythonnative.native_views.NativeViewRegistry` — maps element type names to platform-specific handlers. Use `set_registry()` to inject a mock for testing. + +The `native_views` package is organised into submodules: + +- `pythonnative.native_views.base` — shared `ViewHandler` protocol and utilities (`parse_color_int`, `resolve_padding`, `LAYOUT_KEYS`) +- `pythonnative.native_views.android` — Android handlers (only imported at runtime on Android via Chaquopy) +- `pythonnative.native_views.ios` — iOS handlers (only imported at runtime on iOS via rubicon-objc) diff --git a/docs/assets/banner.jpg b/docs/assets/banner.jpg new file mode 100644 index 0000000..d7c4c04 Binary files /dev/null and b/docs/assets/banner.jpg differ diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index c7726dc..5505fdb 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -1,60 +1,151 @@ # Architecture -PythonNative maps Python directly to native platform APIs. Conceptually, it is closer to NativeScript's dynamic bindings than to React Native's bridge-and-module approach. +PythonNative combines **direct native bindings** with a **declarative reconciler**, giving you React-like ergonomics while calling native platform APIs synchronously from Python. ## High-level model -- Direct bindings: call native APIs synchronously from Python. - - iOS: rubicon-objc exposes Objective-C/Swift classes (e.g., UIViewController, UIButton, WKWebView) and lets you create dynamic Objective-C subclasses and selectors. - - Android: Chaquopy exposes Java classes (e.g., android.widget.Button, android.webkit.WebView) via the java bridge so you can construct and call methods directly. -- Shared Python API: components like Page, StackView, Label, Button, and WebView have a small, consistent surface. Platform-specific behavior is chosen at import time using pythonnative.utils.IS_ANDROID. -- Thin native bootstrap: the host app remains native (Android Activity or iOS UIViewController). It passes a live instance/pointer into Python, and Python drives the UI from there. +1. **Declarative element tree:** Your `@pn.component` function returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes). +2. **Function components and hooks:** All UI is built with `@pn.component` functions using `use_state`, `use_reducer`, `use_effect`, `use_navigation`, etc. — inspired by React hooks but designed for Python. +3. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations. +4. **Post-render effects:** Effects queued via `use_effect` are flushed **after** the reconciler commits native mutations, matching React semantics. This guarantees that effect callbacks interact with the committed native tree. +5. **State batching:** Multiple state updates triggered during a render pass (e.g. from effects) are automatically batched into a single re-render. Explicit batching is available via `pn.batch_updates()`. +6. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content. +7. **Error boundaries:** `pn.ErrorBoundary` catches render errors in child subtrees and displays fallback UI, preventing a single component failure from crashing the entire page. +8. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: + - **iOS:** rubicon-objc exposes Objective-C/Swift classes (`UILabel`, `UIButton`, `UIStackView`, etc.). + - **Android:** Chaquopy exposes Java classes (`android.widget.TextView`, `android.widget.Button`, etc.) via the JNI bridge. +9. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls `create_page()` internally to bootstrap your Python component, and the reconciler drives the UI from there. -## Comparison +## How it works -- Versus React Native: RN typically exposes capabilities via native modules/TurboModules and a bridge. PythonNative does not require authoring such modules for most APIs; you can access platform classes directly from Python. -- Versus NativeScript: similar philosophy—dynamic, synchronous access to Obj-C/Java from the scripting runtime. +``` +@pn.component fn → Element tree → Reconciler → Native views → Flush effects + ↑ +Hook set_state() → schedule render → diff → patch native views → Flush effects + (batched) +``` -## iOS flow (Rubicon-ObjC) +The reconciler uses **key-based diffing** (matching children by key first, then by position). When a child with the same key/type is found, its props are updated in-place on the native view. When the type changes, the old native view is destroyed and a new one is created. -- The iOS template (Swift + PythonKit) boots Python and instantiates your `MainPage` with the current `UIViewController` pointer. -- In Python, Rubicon wraps the pointer; you then interact with UIKit classes directly. +### Render lifecycle -```python -from rubicon.objc import ObjCClass, ObjCInstance +1. **Render phase:** Component functions execute. Hooks record state reads, queue effects, and register memos. No native mutations happen yet. +2. **Commit phase:** The reconciler applies the diff to native views — creating, updating, and removing views as needed. +3. **Effect phase:** Pending effects are flushed in depth-first order (children before parents). Cleanup functions from the previous render run before new effect callbacks. +4. **Drain phase:** If effects set state, a new render pass is automatically triggered and the cycle repeats (up to a safety limit to prevent infinite loops). -UIButton = ObjCClass("UIButton") -vc = ObjCInstance(native_ptr) # passed from Swift template -button = UIButton.alloc().init() -# Configure target/action via a dynamic Objective-C subclass (see Button implementation) +## Component model + +PythonNative uses a single component model: **function components** decorated with `@pn.component`. + +```python +@pn.component +def Counter(initial: int = 0): + count, set_count = pn.use_state(initial) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 18}), + pn.Button("+", on_click=lambda: set_count(count + 1)), + style={"spacing": 4}, + ) ``` +Each component is a Python function that: +- Accepts props as keyword arguments +- Uses hooks for state (`use_state`, `use_reducer`), side effects (`use_effect`), navigation (`use_navigation`), and more +- Returns an `Element` tree describing the UI +- Each call site creates an independent instance with its own hook state + +The entry point `create_page()` is called internally by native templates to bootstrap your root component. You don't call it directly. + +## Styling + +- **`style` prop:** Pass a dict (or list of dicts) to any component — `style={"font_size": 24, "color": "#333"}`. +- **StyleSheet:** Create reusable named style dictionaries with `pn.StyleSheet.create(...)`. +- **Theming:** Use `pn.ThemeContext` with `pn.Provider` and `pn.use_context` to propagate theme values through the tree. + +## Layout + +PythonNative uses a **flexbox-inspired layout model** built on platform-native layout managers. + +`View` is the **universal flex container** (like React Native's `View`). It defaults to `flex_direction: "column"`. `Column` and `Row` are convenience wrappers that fix the direction. + +### Flex container properties (inside `style`) + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing + +### Child layout properties + +- `flex` — flex grow factor (shorthand) +- `flex_grow`, `flex_shrink` — individual flex properties +- `align_self` — override the parent's `align_items` for this child +- `width`, `height` — fixed dimensions +- `min_width`, `min_height` — minimum size constraints +- `margin` — outer spacing + +Under the hood: +- **Android:** `LinearLayout` with gravity, weights, and divider-based spacing +- **iOS:** `UIStackView` with axis, alignment, distribution, and layout margins + +## Native view handlers + +Platform-specific rendering logic lives in the `native_views` package, organised into dedicated submodules: + +- `native_views.base` — shared `ViewHandler` protocol and common utilities (colour parsing, padding resolution, layout keys, flex constants) +- `native_views.android` — Android handlers using Chaquopy's Java bridge (`jclass`, `dynamic_proxy`) +- `native_views.ios` — iOS handlers using rubicon-objc (`ObjCClass`, `objc_method`) + +Column, Row, and View share a single `FlexContainerHandler` class on each platform. The handler reads `flex_direction` from the element's props to configure the native layout container. + +Each handler class maps an element type name (e.g. `"Text"`, `"Button"`) to platform-native widget creation, property updates, and child management. The `NativeViewRegistry` lazily imports only the relevant platform module at runtime, so the package can be imported on any platform for testing. + +## Comparison + +- **Versus React Native:** RN uses JSX + a JavaScript bridge + Yoga layout. PythonNative uses Python + direct native calls + platform layout managers. No JS bridge, no serialisation overhead. +- **Versus NativeScript:** Similar philosophy (direct, synchronous native access), but PythonNative adds a declarative reconciler layer and React-like hooks that NativeScript does not have by default. + +## iOS flow (Rubicon-ObjC) + +- The iOS template (Swift + PythonKit) boots Python and calls `create_page()` internally with the current `UIViewController` pointer. +- The reconciler creates UIKit views and attaches them to the controller's view. +- State changes trigger re-renders; the reconciler patches UIKit views in-place. + ## Android flow (Chaquopy) -- The Android template (Kotlin + Chaquopy) initializes Python in MainActivity and provides the current Activity/Context to Python. -- Components acquire the Context implicitly and construct real Android views. +- The Android template (Kotlin + Chaquopy) initializes Python in `MainActivity` and passes the `Activity` to Python. +- `PageFragment` calls `create_page()` internally, which renders the root component and attaches views to the fragment container. +- State changes trigger re-render; the reconciler patches Android views in-place. -```python -from java import jclass -from pythonnative.utils import get_android_context +## Hot reload -WebViewClass = jclass("android.webkit.WebView") -context = get_android_context() -webview = WebViewClass(context) -webview.loadUrl("https://example.com") -``` +During development, `pn run --hot-reload` watches `app/` for file changes and pushes updated Python files to the running app, enabling near-instant UI updates without full rebuilds. + +## Native API modules -## Key implications +PythonNative provides cross-platform modules for common device APIs: -- Synchronous native calls: no JS bridge; Python calls are direct. -- Lifecycle rules remain native: Activities/ViewControllers are created by the OS. Python receives and controls them; it does not instantiate Android Activities directly. -- Small, growing surface: the shared Python API favors clarity and consistency, expanding progressively. +- `pythonnative.native_modules.Camera` — photo capture and gallery +- `pythonnative.native_modules.Location` — GPS / location services +- `pythonnative.native_modules.FileSystem` — app-scoped file I/O +- `pythonnative.native_modules.Notifications` — local push notifications ## Navigation model overview -- See the Navigation guide for full details and comparisons with other frameworks. - - iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. - - Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph. +PythonNative provides two navigation approaches: + +- **Declarative navigators** (recommended): `NavigationContainer` with `create_stack_navigator()`, `create_tab_navigator()`, and `create_drawer_navigator()`. Navigation state is managed in Python as component state, and navigators are composable — you can nest tabs inside stacks, etc. +- **Page-level navigation**: `use_navigation()` returns a `NavigationHandle` with `.navigate()`, `.go_back()`, and `.get_params()`, delegating to native platform navigation when running on device. + +Both approaches are supported. The declarative system uses the existing reconciler pipeline — navigators are function components that render the active screen via `use_state`, and navigation context is provided via `Provider`. + +See the [Navigation guide](../guides/navigation.md) for full details. + +- iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. +- Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph. ## Related docs diff --git a/docs/concepts/components.md b/docs/concepts/components.md index e9d12ca..cbeb421 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -1,51 +1,220 @@ # Components -High-level overview of PythonNative components and how they map to native UI. +PythonNative uses a **declarative component model** inspired by React. You describe *what* the UI should look like, and the framework handles creating and updating native views. -## Constructor pattern +## Element functions -- All core components share a consistent, contextless constructor on both platforms. -- On Android, a `Context` is acquired implicitly from the current `Activity` set by `pn.Page`. -- On iOS, UIKit classes are allocated/initialized directly. - -Examples: +UI is built with element-creating functions. Each returns a lightweight `Element` descriptor — no native objects are created until the reconciler mounts the tree. ```python import pythonnative as pn -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) +pn.Text("Hello", style={"font_size": 18, "color": "#333333"}) +pn.Button("Tap me", on_click=lambda: print("tapped")) +pn.Column( + pn.Text("First"), + pn.Text("Second"), + style={"spacing": 8, "padding": 16}, +) +``` + +### Available components + +**Layout:** + +- `View(*children, style=...)` — universal flex container (default `flex_direction: "column"`) +- `Column(*children, style=...)` — vertical flex container (fixed `flex_direction: "column"`) +- `Row(*children, style=...)` — horizontal flex container (fixed `flex_direction: "row"`) +- `ScrollView(child, style=...)` — scrollable container +- `SafeAreaView(*children, style=...)` — safe-area-aware container +- `Spacer(size, flex)` — empty space + +**Display:** + +- `Text(text, style=...)` — text display +- `Image(source, style=...)` — image display (supports URLs and resource names) +- `WebView(url)` — embedded web content + +**Input:** + +- `Button(title, on_click, style=...)` — tappable button +- `TextInput(value, placeholder, on_change, secure, style=...)` — text entry +- `Switch(value, on_change)` — toggle switch +- `Slider(value, min_value, max_value, on_change)` — continuous slider +- `Pressable(child, on_press, on_long_press)` — tap handler wrapper + +**Feedback:** + +- `ProgressBar(value)` — determinate progress (0.0–1.0) +- `ActivityIndicator(animating)` — indeterminate spinner + +**Overlay:** + +- `Modal(*children, visible, on_dismiss, title)` — modal dialog + +**Error handling:** + +- `ErrorBoundary(child, fallback)` — catches render errors in child and displays fallback + +**Lists:** + +- `FlatList(data, render_item, key_extractor, separator_height)` — scrollable data list + +### Flex layout model + +PythonNative uses a **flexbox-inspired layout model**. `View` is the universal flex container — `Column` and `Row` are convenience wrappers. + +#### Flex container properties (inside `style`) + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing + +#### Child layout properties + +All components accept these in their `style` dict: + +- `width`, `height` — fixed dimensions (dp / pt) +- `flex` — flex grow factor (shorthand) +- `flex_grow`, `flex_shrink` — individual flex properties +- `margin` — outer margin (int, float, or dict like padding) +- `min_width`, `min_height` — minimum size constraints +- `max_width`, `max_height` — maximum size constraints +- `align_self` — override parent alignment for this child + +#### Example: centering content + +```python +pn.View( + pn.Text("Centered"), + style={"flex": 1, "justify_content": "center", "align_items": "center"}, +) +``` + +#### Example: horizontal row with spacing + +```python +pn.Row( + pn.Button("Cancel"), + pn.Spacer(flex=1), + pn.Button("OK"), + style={"padding": 16, "align_items": "center"}, +) +``` + +## Function components — the building block + +All UI in PythonNative is built with `@pn.component` function components. Each screen is a function component that returns an element tree: + +```python +@pn.component +def MainPage(): + name, set_name = pn.use_state("World") + return pn.Text(f"Hello, {name}!", style={"font_size": 24}) +``` + +The entry point `create_page()` is called internally by native templates to bootstrap your root component. You don't call it directly — just export your component and configure the entry point in `pythonnative.json`. + +## State and re-rendering + +Use `pn.use_state(initial)` to create local component state. Call the setter to update — the framework automatically re-renders the component and applies only the differences to the native views: + +```python +@pn.component +def CounterPage(): + count, set_count = pn.use_state(0) + + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button("Increment", on_click=lambda: set_count(count + 1)), + style={"spacing": 12}, + ) +``` + +## Composing components + +Build complex UIs by composing smaller `@pn.component` functions. Each instance has **independent state**: + +```python +@pn.component +def Counter(label: str = "Count", initial: int = 0): + count, set_count = pn.use_state(initial) + + return pn.Column( + pn.Text(f"{label}: {count}", style={"font_size": 18}), + pn.Row( + pn.Button("-", on_click=lambda: set_count(count - 1)), + pn.Button("+", on_click=lambda: set_count(count + 1)), + style={"spacing": 8}, + ), + style={"spacing": 4}, + ) + + +@pn.component +def MainPage(): + return pn.Column( + Counter(label="Apples", initial=0), + Counter(label="Oranges", initial=5), + style={"spacing": 16, "padding": 16}, + ) +``` + +Changing one `Counter` doesn't affect the other — each has its own hook state. + +### Available hooks + +- `use_state(initial)` — local component state; returns `(value, setter)` +- `use_reducer(reducer, initial_state)` — reducer-based state; returns `(state, dispatch)` +- `use_effect(effect, deps)` — side effects, run after native commit (timers, API calls, subscriptions) +- `use_memo(factory, deps)` — memoised computed values +- `use_callback(fn, deps)` — stable function references +- `use_ref(initial)` — mutable ref that persists across renders +- `use_context(context)` — read from a context provider +- `use_navigation()` — navigation handle for navigate/go_back/get_params +- `use_route()` — convenience hook for current route params +- `use_focus_effect(effect, deps)` — like `use_effect` but only runs when the screen is focused + +### Custom hooks - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello")) - stack.add_view(pn.Button("Tap me")) - stack.add_view(pn.TextField("initial")) - self.set_root_view(stack) +Extract reusable stateful logic into plain functions: + +```python +def use_toggle(initial: bool = False): + value, set_value = pn.use_state(initial) + def toggle(): + set_value(not value) + return value, toggle ``` -Notes: -- `pn.Page` stores the Android `Activity` so components like `pn.Button()` and `pn.Label()` can construct their native counterparts. -- If you construct views before the `Page` is created on Android, a runtime error will be raised because no `Context` is available. +### Context and Provider + +Share values across the tree without prop drilling: + +```python +theme = pn.create_context({"primary": "#007AFF"}) -## Core components +@pn.component +def App(): + return pn.Provider(theme, {"primary": "#FF0000"}, + MyComponent() + ) -Stabilized with contextless constructors on both platforms: +@pn.component +def MyComponent(): + t = pn.use_context(theme) + return pn.Button("Click", style={"color": t["primary"]}) +``` -- `Page` -- `StackView` -- `Label`, `Button` -- `ImageView` -- `TextField`, `TextView` -- `Switch` -- `ProgressView`, `ActivityIndicatorView` -- `WebView` +## Platform detection -APIs are intentionally small and grow progressively in later releases. Properties and setters are kept consistent where supported by both platforms. +Use `pythonnative.utils.IS_ANDROID` when you need platform-specific logic: -## Platform detection and Android context +```python +from pythonnative.utils import IS_ANDROID -- Use `pythonnative.utils.IS_ANDROID` for platform checks when needed. -- On Android, `Page` records the current `Activity` so child views can acquire a `Context` implicitly. Constructing views before `Page` initialization will raise. +title = "Android App" if IS_ANDROID else "iOS App" +``` diff --git a/docs/concepts/hooks.md b/docs/concepts/hooks.md new file mode 100644 index 0000000..49b75aa --- /dev/null +++ b/docs/concepts/hooks.md @@ -0,0 +1,303 @@ +# Function Components and Hooks + +PythonNative uses React-like function components with hooks for managing state, effects, navigation, memoisation, and context. Function components decorated with `@pn.component` are the only way to build UI in PythonNative. + +## Creating a function component + +Decorate a Python function with `@pn.component`: + +```python +import pythonnative as pn + +@pn.component +def Greeting(name: str = "World"): + return pn.Text(f"Hello, {name}!", style={"font_size": 20}) +``` + +Use it like any other component: + +```python +@pn.component +def MyPage(): + return pn.Column( + Greeting(name="Alice"), + Greeting(name="Bob"), + style={"spacing": 12}, + ) +``` + +## Hooks + +Hooks let function components manage state and side effects. They must be called at the top level of a `@pn.component` function (not inside loops or conditions). + +### use_state + +Local component state. Returns `(value, setter)`. + +```python +@pn.component +def Counter(initial: int = 0): + count, set_count = pn.use_state(initial) + + return pn.Column( + pn.Text(f"Count: {count}"), + pn.Button("+", on_click=lambda: set_count(count + 1)), + ) +``` + +The setter accepts a value or a function that receives the current value: + +```python +set_count(10) # set directly +set_count(lambda prev: prev + 1) # functional update +``` + +If the initial value is expensive to compute, pass a callable: + +```python +count, set_count = pn.use_state(lambda: compute_default()) +``` + +### use_reducer + +For complex state logic, `use_reducer` lets you manage state transitions through a reducer function — similar to React's `useReducer`: + +```python +def reducer(state, action): + if action == "increment": + return state + 1 + if action == "decrement": + return state - 1 + if action == "reset": + return 0 + return state + +@pn.component +def Counter(): + count, dispatch = pn.use_reducer(reducer, 0) + + return pn.Column( + pn.Text(f"Count: {count}"), + pn.Row( + pn.Button("-", on_click=lambda: dispatch("decrement")), + pn.Button("+", on_click=lambda: dispatch("increment")), + pn.Button("Reset", on_click=lambda: dispatch("reset")), + style={"spacing": 8}, + ), + ) +``` + +The reducer receives the current state and an action, and returns the new state. Actions can be any value (strings, dicts, etc.). The component only re-renders when the reducer returns a different state. + +### use_effect + +Run side effects **after** the native view tree is committed. The effect function may return a cleanup callable. + +```python +@pn.component +def Timer(): + seconds, set_seconds = pn.use_state(0) + + def tick(): + import threading + t = threading.Timer(1.0, lambda: set_seconds(seconds + 1)) + t.start() + return t.cancel # cleanup: cancel the timer + + pn.use_effect(tick, [seconds]) + + return pn.Text(f"Elapsed: {seconds}s") +``` + +Effects are **deferred** — they are queued during the render phase and executed after the reconciler finishes committing native view mutations. This means effect callbacks can safely measure layout or interact with the committed native tree. + +Dependency control: + +- `pn.use_effect(fn, None)` — run on every render +- `pn.use_effect(fn, [])` — run on mount only +- `pn.use_effect(fn, [a, b])` — run when `a` or `b` change + +### use_navigation + +Access navigation from any component. Returns a `NavigationHandle` with `.navigate()`, `.go_back()`, and `.get_params()`. + +```python +@pn.component +def HomeScreen(): + nav = pn.use_navigation() + + return pn.Column( + pn.Text("Home", style={"font_size": 24}), + pn.Button( + "Go to Details", + on_click=lambda: nav.navigate("Detail", params={"id": 42}), + ), + style={"spacing": 12, "padding": 16}, + ) + +@pn.component +def DetailScreen(): + nav = pn.use_navigation() + item_id = nav.get_params().get("id", 0) + + return pn.Column( + pn.Text(f"Detail #{item_id}", style={"font_size": 20}), + pn.Button("Back", on_click=nav.go_back), + style={"spacing": 12, "padding": 16}, + ) +``` + +See the [Navigation guide](../guides/navigation.md) for full details. + +### use_route + +Convenience hook to read the current route's parameters: + +```python +@pn.component +def DetailScreen(): + params = pn.use_route() + item_id = params.get("id", 0) + return pn.Text(f"Detail #{item_id}") +``` + +### use_focus_effect + +Like `use_effect` but only runs when the screen is focused. Useful for refreshing data when navigating back to a screen: + +```python +@pn.component +def FeedScreen(): + items, set_items = pn.use_state([]) + pn.use_focus_effect(lambda: load_items(set_items), []) + return pn.FlatList(data=items, render_item=lambda item, i: pn.Text(item)) +``` + +### use_memo + +Memoise an expensive computation: + +```python +sorted_items = pn.use_memo(lambda: sorted(items, key=lambda x: x.name), [items]) +``` + +### use_callback + +Return a stable function reference (avoids unnecessary re-renders of children): + +```python +handle_click = pn.use_callback(lambda: set_count(count + 1), [count]) +``` + +### use_ref + +A mutable container that persists across renders without triggering re-renders: + +```python +render_count = pn.use_ref(0) +render_count["current"] += 1 +``` + +### use_context + +Read a value from the nearest `Provider` ancestor: + +```python +theme = pn.use_context(pn.ThemeContext) +color = theme["primary_color"] +``` + +## Context and Provider + +Share values through the component tree without passing props manually: + +```python +user_context = pn.create_context({"name": "Guest"}) + +@pn.component +def App(): + return pn.Provider(user_context, {"name": "Alice"}, + UserProfile() + ) + +@pn.component +def UserProfile(): + user = pn.use_context(user_context) + return pn.Text(f"Welcome, {user['name']}") +``` + +## Batching state updates + +By default, each state setter call triggers a re-render. When you need to update multiple pieces of state at once, use `pn.batch_updates()` to coalesce them into a single render pass: + +```python +@pn.component +def Form(): + name, set_name = pn.use_state("") + email, set_email = pn.use_state("") + + def on_submit(): + with pn.batch_updates(): + set_name("Alice") + set_email("alice@example.com") + # single re-render here + + return pn.Column( + pn.Text(f"{name} <{email}>"), + pn.Button("Fill", on_click=on_submit), + ) +``` + +State updates triggered by effects during a render pass are automatically batched — the framework drains any pending re-renders after effect flushing completes, so you don't need `batch_updates()` inside effects. + +## Error boundaries + +Wrap risky components in `pn.ErrorBoundary` to catch render errors and display a fallback UI: + +```python +@pn.component +def App(): + return pn.ErrorBoundary( + MyRiskyComponent(), + fallback=lambda err: pn.Text(f"Something went wrong: {err}"), + ) +``` + +Without an error boundary, an exception during rendering crashes the entire page. Error boundaries catch errors during both initial mount and subsequent reconciliation. + +## Custom hooks + +Extract reusable stateful logic into plain functions: + +```python +def use_toggle(initial: bool = False): + value, set_value = pn.use_state(initial) + toggle = pn.use_callback(lambda: set_value(not value), [value]) + return value, toggle + +def use_text_input(initial: str = ""): + text, set_text = pn.use_state(initial) + return text, set_text +``` + +Use them in any component: + +```python +@pn.component +def Settings(): + dark_mode, toggle_dark = use_toggle(False) + + return pn.Column( + pn.Text("Settings", style={"font_size": 24, "bold": True}), + pn.Row( + pn.Text("Dark mode"), + pn.Switch(value=dark_mode, on_change=lambda v: toggle_dark()), + ), + ) +``` + +## Rules of hooks + +1. Only call hooks inside `@pn.component` functions +2. Call hooks at the top level — not inside loops, conditions, or nested functions +3. Hooks must be called in the same order on every render diff --git a/docs/examples.md b/docs/examples.md index 821ee4a..20112d5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,3 +1,52 @@ # Examples -A collection of simple examples showing PythonNative components and patterns. +A collection of examples showing PythonNative's declarative component model and patterns. + +## Quick counter + +```python +import pythonnative as pn + + +@pn.component +def Counter(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Increment", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) +``` + +## Reusable components + +```python +import pythonnative as pn + + +@pn.component +def LabeledInput(label: str = "", placeholder: str = ""): + return pn.Column( + pn.Text(label, style={"font_size": 14, "bold": True}), + pn.TextInput(placeholder=placeholder), + style={"spacing": 4}, + ) + + +@pn.component +def FormPage(): + return pn.ScrollView( + pn.Column( + pn.Text("Sign Up", style={"font_size": 24, "bold": True}), + LabeledInput(label="Name", placeholder="Enter your name"), + LabeledInput(label="Email", placeholder="you@example.com"), + pn.Button("Submit", on_click=lambda: print("submitted")), + style={"spacing": 12, "padding": 16}, + ) + ) +``` + +See `examples/hello-world/` for a full multi-page demo with navigation. diff --git a/docs/examples/hello-world.md b/docs/examples/hello-world.md index b939317..73fe9cc 100644 --- a/docs/examples/hello-world.md +++ b/docs/examples/hello-world.md @@ -1,24 +1,22 @@ # Hello World -Create a simple page with a label and a button. +Create a simple component with a counter that increments on tap. ```python import pythonnative as pn -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - label = pn.Label("Hello, world!") - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Hello tapped")) - stack.add_view(label) - stack.add_view(button) - self.set_root_view(stack) +@pn.component +def App(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Tap me", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) ``` Run it: diff --git a/docs/getting-started.md b/docs/getting-started.md index f35ff23..f36d5c5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,26 +18,32 @@ This scaffolds: - `requirements.txt` - `.gitignore` -A minimal `app/main_page.py` looks like (no bootstrap needed): +A minimal `app/main_page.py` looks like: ```python import pythonnative as pn -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button clicked")) - stack.add_view(button) - self.set_root_view(stack) +@pn.component +def App(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Tap me", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) ``` +Key ideas: + +- **`@pn.component`** marks a function as a PythonNative component. The function returns an element tree describing the UI. PythonNative creates and updates native views automatically. +- **`pn.use_state(initial)`** creates local component state. Call the setter to update it — the UI re-renders automatically. +- **`style={...}`** passes visual and layout properties as a dict (or list of dicts) to any component. +- Element functions like `pn.Text(...)`, `pn.Button(...)`, `pn.Column(...)` create lightweight descriptions, not native objects. + ## Run on a platform ```bash diff --git a/docs/guides/android.md b/docs/guides/android.md index 5e939dd..23b3a6e 100644 --- a/docs/guides/android.md +++ b/docs/guides/android.md @@ -8,6 +8,10 @@ Basic steps to build and run an Android project generated by `pn`. No network is required for the template itself; the template zip is bundled with the package. +## Component model + +Your `app/` directory contains `@pn.component` function components. The native Android template uses `create_page()` internally to bootstrap your root component inside a `PageFragment`. You don't call `create_page()` directly — just export your component and configure the entry point in `pythonnative.json`. + ## Run ```bash diff --git a/docs/guides/ios.md b/docs/guides/ios.md index a0a400f..7409bc4 100644 --- a/docs/guides/ios.md +++ b/docs/guides/ios.md @@ -8,6 +8,10 @@ Basic steps to build and run an iOS project generated by `pn`. The default `ViewController.swift` initializes PythonKit, prints the Python version, and attempts to import `rubicon.objc` if present. +## Component model + +Your `app/` directory contains `@pn.component` function components. The native iOS template uses `create_page()` internally to bootstrap your root component inside a `ViewController`. You don't call `create_page()` directly — just export your component and configure the entry point in `pythonnative.json`. + ## Run / Prepare ```bash diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 7ae0f4c..5d33ec7 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -1,43 +1,191 @@ # Navigation -This guide shows how to navigate between pages and handle lifecycle events. +PythonNative offers two approaches to navigation: -## Push / Pop +1. **Declarative navigators** (recommended) — component-based, inspired by React Navigation +2. **Page-level push/pop** — imperative navigation via `use_navigation()` (for native page transitions) -Use `push` and `pop` on your `Page` to change screens. You can pass a dotted path string or a class reference. +## Declarative Navigation + +Declarative navigators manage screen state as components. Define your screens once, and the navigator handles rendering, transitions, and state. + +### Stack Navigator + +A stack navigator manages a stack of screens — push to go forward, pop to go back. ```python import pythonnative as pn +from pythonnative.navigation import NavigationContainer, create_stack_navigator + +Stack = create_stack_navigator() + +@pn.component +def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Home", + ) + ) + +@pn.component +def HomeScreen(): + nav = pn.use_navigation() + return pn.Column( + pn.Text("Home", style={"font_size": 24}), + pn.Button( + "Go to Detail", + on_click=lambda: nav.navigate("Detail", params={"id": 42}), + ), + style={"spacing": 12, "padding": 16}, + ) + +@pn.component +def DetailScreen(): + nav = pn.use_navigation() + params = nav.get_params() + return pn.Column( + pn.Text(f"Detail #{params.get('id')}", style={"font_size": 20}), + pn.Button("Back", on_click=nav.go_back), + style={"spacing": 12, "padding": 16}, + ) +``` + +### Tab Navigator + +A tab navigator renders a **native tab bar** and switches between screens. +On Android the tab bar is a `BottomNavigationView` from Material Components; +on iOS it is a `UITabBar`. + +```python +from pythonnative.navigation import create_tab_navigator + +Tab = create_tab_navigator() + +@pn.component +def App(): + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + ) +``` + +The tab bar emits a `TabBar` element that maps to platform-native views: -class MainPage(pn.Page): - def on_create(self): - stack = pn.StackView() - btn = pn.Button("Go next") - btn.set_on_click(lambda: self.push("app.second_page.SecondPage", args={"message": "Hello"})) - stack.add_view(btn) - self.set_root_view(stack) +| Platform | Native view | +|----------|------------------------------| +| Android | `BottomNavigationView` | +| iOS | `UITabBar` | + +### Drawer Navigator + +A drawer navigator provides a side menu for switching screens. + +```python +from pythonnative.navigation import create_drawer_navigator + +Drawer = create_drawer_navigator() + +@pn.component +def App(): + return NavigationContainer( + Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Drawer.Screen("Profile", component=ProfileScreen, options={"title": "Profile"}), + ) + ) + +@pn.component +def HomeScreen(): + nav = pn.use_navigation() + return pn.Column( + pn.Button("Open Menu", on_click=nav.open_drawer), + pn.Text("Home Screen"), + ) ``` -On the target page: +### Nesting Navigators + +Navigators can be nested — for example, tabs containing stacks. +When a child navigator receives a `navigate()` call for an unknown route, +it automatically **forwards** the request to its parent navigator. +Similarly, `go_back()` at the root of a child stack forwards to the parent. ```python -class SecondPage(pn.Page): - def on_create(self): - args = self.get_args() - message = args.get("message", "Second") - stack = pn.StackView() - stack.add_view(pn.Label(message)) - back = pn.Button("Back") - back.set_on_click(lambda: self.pop()) - stack.add_view(back) - self.set_root_view(stack) +Stack = create_stack_navigator() +Tab = create_tab_navigator() + +@pn.component +def HomeStack(): + return Stack.Navigator( + Stack.Screen("Feed", component=FeedScreen), + Stack.Screen("Post", component=PostScreen), + ) + +@pn.component +def App(): + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeStack, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + ) +``` + +Inside `FeedScreen`, calling `nav.navigate("Settings")` will forward to the +parent tab navigator and switch to the Settings tab. + +## NavigationHandle API + +Inside any screen rendered by a navigator, `pn.use_navigation()` returns a handle with: + +- **`.navigate(route_name, params=...)`** — navigate to a named route with optional params +- **`.go_back()`** — pop the current screen +- **`.get_params()`** — get the current route's params dict +- **`.reset(route_name, params=...)`** — reset the stack to a single route + +### Drawer-specific methods + +When inside a drawer navigator, the handle also provides: + +- **`.open_drawer()`** — open the drawer +- **`.close_drawer()`** — close the drawer +- **`.toggle_drawer()`** — toggle the drawer open/closed + +## Focus-aware Effects + +Use `pn.use_focus_effect()` to run effects only when a screen is focused: + +```python +@pn.component +def DataScreen(): + data, set_data = pn.use_state(None) + + pn.use_focus_effect(lambda: fetch_data(set_data), []) + + return pn.Text(f"Data: {data}") +``` + +## Route Parameters + +Use `pn.use_route()` for convenient access to route params: + +```python +@pn.component +def DetailScreen(): + params = pn.use_route() + item_id = params.get("id", 0) + return pn.Text(f"Item #{item_id}") ``` ## Lifecycle PythonNative forwards lifecycle events from the host: -- `on_create` +- `on_create` — triggers the initial render - `on_start` - `on_resume` - `on_pause` @@ -47,49 +195,20 @@ PythonNative forwards lifecycle events from the host: - `on_save_instance_state` - `on_restore_instance_state` -Android uses a single `MainActivity` hosting a `NavHostFragment` and a generic `PageFragment` per page. iOS forwards `viewWillAppear`/`viewWillDisappear` via an internal registry. - -## Notes - -- On Android, `push` navigates via `NavController` to a `PageFragment` and passes `page_path` and optional JSON `args`. -- On iOS, `push` uses the root `UINavigationController` to push a new `ViewController` and passes page info via KVC. - ## Platform specifics ### iOS (UIViewController per page) -- Each PythonNative page is hosted by a Swift `ViewController` instance. -- Pages are pushed and popped on a root `UINavigationController`. -- Lifecycle is forwarded from Swift to the registered Python page instance. -- Root view wiring: `Page.set_root_view` sizes and inserts the Python-native view into the controller’s view. - -Why this matches iOS conventions -- iOS apps commonly model screens as `UIViewController`s and use `UINavigationController` for hierarchical navigation. -- The approach integrates cleanly with add-to-app and system behaviors (e.g., state restoration). +- Each PythonNative screen is hosted by a Swift `ViewController` instance. +- Screens are pushed and popped on a root `UINavigationController`. +- Lifecycle is forwarded from Swift to the registered Python component. ### Android (single Activity, Fragment stack) - Single host `MainActivity` sets a `NavHostFragment` containing a navigation graph. -- Each PythonNative page is represented by a generic `PageFragment` which instantiates the Python page and attaches its root view. +- Each PythonNative screen is represented by a generic `PageFragment` which instantiates the Python component and attaches its root view. - `push`/`pop` delegate to `NavController` (via a small `Navigator` helper). -- Arguments (`page_path`, `args_json`) live in Fragment arguments and restore across configuration changes and process death. - -Why this matches Android conventions -- Modern Android apps favor one Activity with many Fragments, using Jetpack Navigation for back stack, transitions, and deep links. -- It simplifies lifecycle, back handling, and state compared to one-Activity-per-screen. +- Arguments live in Fragment arguments and restore across configuration changes. ## Comparison to other frameworks -- React Native - - Android: single `Activity`, screens managed via `Fragment`s (e.g., `react-native-screens`). - - iOS: screens map to `UIViewController`s pushed on `UINavigationController`. -- .NET MAUI / Xamarin.Forms - - Android: single `Activity`, pages via Fragments/Navigation. - - iOS: pages map to `UIViewController`s on a `UINavigationController`. -- NativeScript - - Android: single `Activity`, pages as `Fragment`s. - - iOS: pages as `UIViewController`s on `UINavigationController`. -- Flutter (special case) - - Android: single `Activity` (`FlutterActivity`/`FlutterFragmentActivity`). - - iOS: `FlutterViewController` hosts Flutter’s internal navigator; add-to-app can push multiple `FlutterViewController`s. - -Bottom line -- iOS: one host VC class, many instances on a `UINavigationController`. -- Android: one host `Activity`, many `Fragment`s with Jetpack Navigation. +- **React Native:** Android: single `Activity`, screens managed via `Fragment`s. iOS: screens map to `UIViewController`s pushed on `UINavigationController`. +- **NativeScript:** Android: single `Activity`, pages as `Fragment`s. iOS: pages as `UIViewController`s on `UINavigationController`. +- **Flutter:** Android: single `Activity`. iOS: `FlutterViewController` hosts Flutter's navigator. diff --git a/docs/guides/styling.md b/docs/guides/styling.md new file mode 100644 index 0000000..9556fe6 --- /dev/null +++ b/docs/guides/styling.md @@ -0,0 +1,243 @@ +# Styling + +Style properties are passed via the `style` prop as a dict (or list of dicts) to any element function. PythonNative also provides a `StyleSheet` utility for creating reusable styles and a theming system via context. + +## Inline styles + +Pass a `style` dict to components: + +```python +pn.Text("Hello", style={"color": "#FF3366", "font_size": 24, "bold": True}) +pn.Button("Tap", style={"background_color": "#FF1E88E5", "color": "#FFFFFF"}) +pn.Column(pn.Text("Content"), style={"background_color": "#FFF5F5F5"}) +``` + +## StyleSheet + +Create reusable named styles with `StyleSheet.create()`: + +```python +import pythonnative as pn + +styles = pn.StyleSheet.create( + title={"font_size": 28, "bold": True, "color": "#333"}, + subtitle={"font_size": 14, "color": "#666"}, + container={"padding": 16, "spacing": 12, "align_items": "stretch"}, +) + +pn.Text("Welcome", style=styles["title"]) +pn.Column( + pn.Text("Subtitle", style=styles["subtitle"]), + style=styles["container"], +) +``` + +### Composing styles + +Merge multiple style dicts with `StyleSheet.compose()`: + +```python +base = {"font_size": 16, "color": "#000"} +highlight = {"color": "#FF0000", "bold": True} +merged = pn.StyleSheet.compose(base, highlight) +# Result: {"font_size": 16, "color": "#FF0000", "bold": True} +``` + +### Combining styles with a list + +You can also pass a list of dicts to `style`. They are merged left-to-right: + +```python +pn.Text("Highlighted", style=[base, highlight]) +``` + +### Flattening styles + +Flatten a style or list of styles into a single dict: + +```python +pn.StyleSheet.flatten([base, highlight]) +pn.StyleSheet.flatten(None) # returns {} +``` + +## Colors + +Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color properties inside `style`: + +```python +pn.Text("Hello", style={"color": "#FF3366"}) +pn.Button("Tap", style={"background_color": "#FF1E88E5", "color": "#FFFFFF"}) +``` + +## Text styling + +`Text` and `Button` accept `font_size`, `color`, `bold`, and `text_align` inside `style`: + +```python +pn.Text("Title", style={"font_size": 24, "bold": True, "text_align": "center"}) +pn.Text("Subtitle", style={"font_size": 14, "color": "#666666"}) +``` + +## Flex layout + +PythonNative uses a flexbox-inspired layout model. `View` is the universal flex container, and `Column`/`Row` are convenience wrappers. + +### Flex container properties + +These go in the `style` dict of `View`, `Column`, or `Row`: + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` (only for `View`; `Column`/`Row` have fixed directions) +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing (int for all sides, or dict) + +### Child layout properties + +All components accept these in `style`: + +- `width`, `height` — fixed dimensions in dp (Android) / pt (iOS) +- `flex` — flex grow factor (shorthand for `flex_grow`) +- `flex_grow` — how much a child should grow to fill available space +- `flex_shrink` — how much a child should shrink when space is tight +- `margin` — outer spacing (int for all sides, or dict) +- `min_width`, `min_height` — minimum size constraints +- `max_width`, `max_height` — maximum size constraints +- `align_self` — override parent alignment: `"flex_start"`, `"center"`, `"flex_end"`, `"stretch"` + +### Layout examples + +**Centering content:** + +```python +pn.View( + pn.Text("Centered!"), + style={"flex": 1, "justify_content": "center", "align_items": "center"}, +) +``` + +**Horizontal row with spacer:** + +```python +pn.Row( + pn.Text("Left"), + pn.Spacer(flex=1), + pn.Text("Right"), + style={"padding": 16, "align_items": "center"}, +) +``` + +**Child with flex grow:** + +```python +pn.Column( + pn.Text("Header", style={"font_size": 20, "bold": True}), + pn.View(pn.Text("Content area"), style={"flex": 1}), + pn.Text("Footer"), + style={"flex": 1, "spacing": 8}, +) +``` + +**Horizontal button bar:** + +```python +pn.Row( + pn.Button("Cancel", style={"flex": 1}), + pn.Button("OK", style={"flex": 1, "background_color": "#007AFF", "color": "#FFF"}), + style={"spacing": 8, "padding": 16}, +) +``` + +## Layout with Column and Row + +`Column` (vertical) and `Row` (horizontal) are convenience wrappers for `View`: + +```python +pn.Column( + pn.Text("Username"), + pn.TextInput(placeholder="Enter username"), + pn.Text("Password"), + pn.TextInput(placeholder="Enter password", secure=True), + pn.Button("Login", on_click=handle_login), + style={"spacing": 8, "padding": 16, "align_items": "stretch"}, +) +``` + +### Alignment properties + +Column and Row support `align_items` and `justify_content` inside `style`: + +- **`align_items`** — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`, `"leading"`, `"trailing"` +- **`justify_content`** — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` + +```python +pn.Row( + pn.Text("Left"), + pn.Spacer(flex=1), + pn.Text("Right"), + style={"align_items": "center", "justify_content": "space_between", "padding": 16}, +) +``` + +### Spacing + +- `spacing` sets the gap between children in dp (Android) / points (iOS). + +### Padding + +- `padding: 16` — all sides +- `padding: {"horizontal": 12, "vertical": 8}` — per axis +- `padding: {"left": 8, "top": 16, "right": 8, "bottom": 16}` — per side + +## Theming + +PythonNative includes a built-in theme context with light and dark themes: + +```python +import pythonnative as pn +from pythonnative.style import DEFAULT_DARK_THEME + + +@pn.component +def ThemedText(text: str = ""): + theme = pn.use_context(pn.ThemeContext) + return pn.Text(text, style={"color": theme["text_color"], "font_size": theme["font_size"]}) + + +@pn.component +def DarkPage(): + return pn.Provider(pn.ThemeContext, DEFAULT_DARK_THEME, + pn.Column( + ThemedText(text="Dark mode!"), + style={"spacing": 8}, + ) + ) +``` + +### Theme properties + +Both light and dark themes include: + +- `primary_color`, `secondary_color` — accent colors +- `background_color`, `surface_color` — background colors +- `text_color`, `text_secondary_color` — text colors +- `error_color`, `success_color`, `warning_color` — semantic colors +- `font_size`, `font_size_small`, `font_size_large`, `font_size_title` — typography +- `spacing`, `spacing_large` — layout spacing +- `border_radius` — corner rounding + +## ScrollView + +Wrap content in a `ScrollView`: + +```python +pn.ScrollView( + pn.Column( + pn.Text("Item 1"), + pn.Text("Item 2"), + # ... many items + style={"spacing": 8}, + ) +) +``` diff --git a/docs/index.md b/docs/index.md index 97b6b2b..fa81dfb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,19 @@ # PythonNative -Build native Android and iOS apps with Python. PythonNative provides a Pythonic API for native UI components and a simple CLI to scaffold and run projects. +Build native Android and iOS apps with Python using a declarative, React-like component model. + +PythonNative provides a Pythonic API for native UI components, a virtual view tree with automatic reconciliation, and a simple CLI to scaffold and run projects. + +```python +import pythonnative as pn + + +@pn.component +def App(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + style={"spacing": 12, "padding": 16}, + ) +``` diff --git a/docs/meta/roadmap.md b/docs/meta/roadmap.md deleted file mode 100644 index 5cb3873..0000000 --- a/docs/meta/roadmap.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -title: Roadmap ---- - -# PythonNative Roadmap (v0.2.0 → v0.10.0) - -This roadmap focuses on transforming PythonNative into a workable, React Native / Expo-like framework from a developer-experience and simplicity standpoint. Releases are incremental and designed to be shippable, with DX-first improvements balanced with platform capability. - -Assumptions -- Scope: Android (Chaquopy/Java bridge) and iOS (Rubicon-ObjC), Python 3.9–3.12 -- Goals: Zero-config templates, one CLI, fast iteration loop, portable component API, and a curated subset of native capabilities with room to expand. - -Guiding Principles -- Single CLI for init/run/build/clean. -- Convention over configuration: opinionated project layout (`app/`, `pythonnative.json`, `requirements.txt`). -- Hot reload (where feasible) and rapid feedback. -- Stable component API; platform shims kept internal. -- Progressive enhancement: start with a minimal but complete loop, add breadth and depth over time. - -Milestones - -0.2.0 — Foundations: DX Baseline and Templates -- CLI - - pn init: generate project with `app/`, `pythonnative.json`, `requirements.txt`, `.gitignore`. - - pn run android|ios: scaffold template apps (from bundled zips), copy `app/`, install requirements, build+install/run. - - pn clean: remove `build/` safely. -- Templates - - Bundle `templates/android_template.zip` and `templates/ios_template.zip` into package to avoid network. - - Ensure Android template uses Kotlin+Chaquopy; iOS template uses Swift+PythonKit+Rubicon. -- Core APIs - - Stabilize `Page`, `StackView`, `Label`, `Button`, `ImageView`, `TextField`, `TextView`, `Switch`, `ProgressView`, `ActivityIndicatorView`, `WebView` with consistent ctor patterns. - - Add `utils.IS_ANDROID` fallback detection improvements. -- Docs - - Getting Started (one page), Hello World, Concepts: Components, Guides: Android/iOS quickstart. - - Roadmap (this page). Contributing. - -Success Criteria -- New user can: pn init → pn run android → sees Hello World UI; same for iOS. - -0.3.0 — Navigation and Lifecycle -- API - - Page navigation abstraction with push/pop (Android: Activity/Fragment shim, iOS: UINavigationController). - - Lifecycle events stabilized and wired from host to Python (on_create/start/resume/pause/stop/destroy). -- Templates - - Two-screen sample demonstrating navigation and parameter passing. -- Docs - - Navigation guide with examples. - -Success Criteria -- Sample app navigates between two pages on both platforms using the same Python API. - -0.4.0 — Layout and Styling Pass -- API - - Improve `StackView` configuration: axis, spacing, alignment; add `ScrollView` wrapping helpers. - - Add lightweight style API (padding/margin where supported, background color, text color/size for text components). -- DX - - Component property setters return self for fluent configuration where ergonomic. -- Docs - - Styling guide and component property reference. - -Success Criteria -- Build complex vertical forms and simple horizontal layouts with predictable results on both platforms. - -0.5.0 — Developer Experience: Live Reload Loop -- DX - - pn dev android|ios: dev server watching `app/` with file-sync into running app. - - Implement soft-reload: trigger Python module reload and page re-render without full app restart where possible. - - Fallback to fast reinstall when soft-reload not possible. -- Templates - - Integrate dev menu gesture (e.g., triple-tap or shake) to trigger reload. -- Docs - - Dev workflow: live reload expectations and caveats. - -Success Criteria -- Edit Python in `app/`, trigger near-instant UI update on device/emulator. - -0.6.0 — Forms and Lists -- API - - `ListView` cross-platform wrapper with simple adapter API (Python callback to render rows, handle click). - - Input controls: `DatePicker`, `TimePicker`, basic validation utilities. - - Add `PickerView` parity or mark as experimental if iOS-first. -- Performance - - Ensure cell reuse on Android/iOS to handle 1k-row lists smoothly. -- Docs - - Lists guide, forms guide with validation patterns. - -Success Criteria -- Build a basic todo app with a scrollable list and an add-item form. - -0.7.0 — Networking, Storage, and Permissions Primitives -- API - - Simple `fetch`-like helper (thin wrapper over requests/URLSession with threading off main UI thread). - - Key-value storage abstraction (Android SharedPreferences / iOS UserDefaults). - - Permission prompts helper (camera, location, notifications) with consistent API returning futures/promises. -- DX - - Background threading utilities for long-running tasks with callback to main thread. -- Docs - - Data fetching, local storage, permissions cookbook. - -Success Criteria -- Build a data-driven screen that fetches remote JSON, caches a token, and requests permission. - -0.8.0 — Theming and Material Components (Android parity), iOS polish -- API - - Theme object for colors/typography; propagate defaults to components. - - Material variants: MaterialButton, MaterialProgress, MaterialSearchBar, MaterialSwitch stabilized. - - iOS polishing: ensure UIKit equivalents’ look-and-feel is sensible by default. -- DX - - Dark/light theme toggling hook. -- Docs - - Theming guide with examples. - -Success Criteria -- Switch between light/dark themes and see consistent component styling across screens. - -0.9.0 — Packaging, Testing, and CI -- CLI - - pn build android|ios: produce signed (debug) APK/IPA or x archive guidance; integrate keystore setup helper for Android. - - pn test: run Python unit tests; document UI test strategy (manual/host-level instrumentation later). -- Tooling - - Add ruff/black/mypy default config and `pn fmt`, `pn lint` wrappers. -- Docs - - Release checklist; testing guide. - -Success Criteria -- Produce installable builds via pn build; run unit tests with a single command. - -0.10.0 — Plugin System (Early) and Project Orchestration -- Plugins - - Define `pythonnative.plugins` entry point allowing add-ons (e.g., Camera, Filesystem) to register platform shims. - - pn plugin add : scaffold plugin structure and install dependency. -- Orchestration - - Config-driven `pythonnative.json`: targets, app id/name, icons/splash, permissions, minSDK/iOS version. - - Asset pipeline: copy assets to correct platform locations. -- Docs - - Plugin authoring guide; configuration reference. - -Success Criteria -- Install a community plugin and use it from Python without touching native code. - -Backlog and Stretch (post-0.10) -- Cross-platform navigation stack parity (Fragments vs Activities, or single-activity multi-fragment on Android). -- Advanced layout (ConstraintLayout/AutoLayout helpers) with declarative constraints. -- Gesture/touch handling unification, animations/transitions. -- Expo-like over-the-air updates pipeline. -- Desktop/web exploration via PyObjC/Qt bridges (research). - -Breaking Changes Policy -- Pre-1.0: Minor versions may include breaking changes; provide migration notes and deprecation warnings one release ahead when possible. - -Tracking and Releases -- Each milestone will have a GitHub project board and labeled issues. -- Changelogs maintained per release; upgrade guides in docs. diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 032ef3b..23abd46 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,71 +1,86 @@ -import pythonnative as pn - -try: - # Optional: used for styling below; safe if rubicon isn't available - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None +import emoji +import pythonnative as pn +from pythonnative.navigation import NavigationContainer, create_tab_navigator -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) +MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] - def on_create(self): - super().on_create() - stack = pn.StackView() - # Ensure vertical stacking - try: - stack.native_instance.setAxis_(1) # 1 = vertical - except Exception: - pass - stack.add_view(pn.Label("Hello from PythonNative Demo!")) - button = pn.Button("Go to Second Page") +Tab = create_tab_navigator() - def on_next(): - # Visual confirmation that tap worked (iOS only) - try: - if UIColor is not None: - button.native_instance.setBackgroundColor_(UIColor.systemGreenColor()) - button.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - # Demonstrate passing args - self.push("app.second_page.SecondPage", args={"message": "Greetings from MainPage"}) +styles = pn.StyleSheet.create( + title={"font_size": 24, "bold": True}, + subtitle={"font_size": 16, "color": "#666666"}, + medal={"font_size": 32}, + card={ + "spacing": 12, + "padding": 16, + "background_color": "#F8F9FA", + "align_items": "center", + }, + section={"spacing": 16, "padding": 24, "align_items": "stretch"}, + button_row={"spacing": 8, "align_items": "center"}, +) - button.set_on_click(on_next) - # Make the button visually obvious - try: - if UIColor is not None: - button.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - button.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - stack.add_view(button) - self.set_root_view(stack) - def on_start(self): - super().on_start() +@pn.component +def counter_badge(initial: int = 0) -> pn.Element: + """Reusable counter component with its own hook-based state.""" + count, set_count = pn.use_state(initial) + medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") - def on_resume(self): - super().on_resume() + return pn.View( + pn.Text(f"Tapped {count} times", style=styles["subtitle"]), + pn.Text(medal, style=styles["medal"]), + pn.Row( + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + pn.Button("Reset", on_click=lambda: set_count(0)), + style=styles["button_row"], + ), + style=styles["card"], + ) - def on_pause(self): - super().on_pause() - def on_stop(self): - super().on_stop() +@pn.component +def HomeTab() -> pn.Element: + """Home tab — counter demo and push-navigation to other pages.""" + nav = pn.use_navigation() + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative Demo!", style=styles["title"]), + counter_badge(), + pn.Button( + "Go to Second Page", + on_click=lambda: nav.navigate( + "app.second_page.SecondPage", + params={"message": "Greetings from MainPage"}, + ), + ), + style=styles["section"], + ) + ) - def on_destroy(self): - super().on_destroy() - def on_restart(self): - super().on_restart() +@pn.component +def SettingsTab() -> pn.Element: + """Settings tab — simple placeholder content.""" + return pn.ScrollView( + pn.Column( + pn.Text("Settings", style=styles["title"]), + pn.Text("App version: 0.7.0", style=styles["subtitle"]), + pn.Text( + "This tab uses a native UITabBar on iOS " "and BottomNavigationView on Android.", + style=styles["subtitle"], + ), + style=styles["section"], + ) + ) - def on_save_instance_state(self): - super().on_save_instance_state() - def on_restore_instance_state(self): - super().on_restore_instance_state() +@pn.component +def MainPage() -> pn.Element: + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeTab, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsTab, options={"title": "Settings"}), + ) + ) diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 99af521..40725a2 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -1,72 +1,18 @@ import pythonnative as pn -try: - # Optional: iOS styling support (safe if rubicon isn't available) - from rubicon.objc import ObjCClass - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - - -class SecondPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack_view = pn.StackView() - # Read args passed from MainPage - args = self.get_args() - message = args.get("message", "Second page!") - stack_view.add_view(pn.Label(message)) - # Navigate to Third Page - to_third_btn = pn.Button("Go to Third Page") - # Style button on iOS similar to MainPage - try: - if UIColor is not None: - to_third_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - - def on_next(): - # Visual confirmation that tap worked (iOS only) - try: - if UIColor is not None: - to_third_btn.native_instance.setBackgroundColor_(UIColor.systemGreenColor()) - to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - self.push("app.third_page.ThirdPage", args={"from": "Second"}) - - to_third_btn.set_on_click(on_next) - stack_view.add_view(to_third_btn) - back_btn = pn.Button("Back") - back_btn.set_on_click(lambda: self.pop()) - stack_view.add_view(back_btn) - self.set_root_view(stack_view) - - def on_start(self): - super().on_start() - - def on_resume(self): - super().on_resume() - - def on_pause(self): - super().on_pause() - - def on_stop(self): - super().on_stop() - - def on_destroy(self): - super().on_destroy() - - def on_restart(self): - super().on_restart() - - def on_save_instance_state(self): - super().on_save_instance_state() - - def on_restore_instance_state(self): - super().on_restore_instance_state() +@pn.component +def SecondPage() -> pn.Element: + nav = pn.use_navigation() + message = nav.get_params().get("message", "Second Page") + return pn.ScrollView( + pn.Column( + pn.Text(message, style={"font_size": 24, "bold": True}), + pn.Button( + "Go to Third Page", + on_click=lambda: nav.navigate("app.third_page.ThirdPage"), + ), + pn.Button("Back", on_click=nav.go_back), + style={"spacing": 16, "padding": 24, "align_items": "stretch"}, + ) + ) diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index 6c06594..62a3388 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -1,30 +1,14 @@ import pythonnative as pn -try: - # Optional: iOS styling support (safe if rubicon isn't available) - from rubicon.objc import ObjCClass - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - - -class ThirdPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("This is the Third Page")) - back_btn = pn.Button("Back") - # Style button on iOS similar to MainPage - try: - if UIColor is not None: - back_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - back_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - back_btn.set_on_click(lambda: self.pop()) - stack.add_view(back_btn) - self.set_root_view(stack) +@pn.component +def ThirdPage() -> pn.Element: + nav = pn.use_navigation() + return pn.ScrollView( + pn.Column( + pn.Text("Third Page", style={"font_size": 24, "bold": True}), + pn.Text("You navigated two levels deep."), + pn.Button("Back to Second", on_click=nav.go_back), + style={"spacing": 16, "padding": 24, "align_items": "stretch"}, + ) + ) diff --git a/examples/hello-world/pythonnative.json b/examples/hello-world/pythonnative.json index 04dc187..db72356 100644 --- a/examples/hello-world/pythonnative.json +++ b/examples/hello-world/pythonnative.json @@ -2,6 +2,7 @@ "name": "PythonNative Demo", "appId": "com.pythonnative.demo", "entryPoint": "app/main_page.py", + "pythonVersion": "3.11", "ios": {}, "android": {} } diff --git a/examples/hello-world/requirements.txt b/examples/hello-world/requirements.txt index 7e4d11b..3a917a9 100644 --- a/examples/hello-world/requirements.txt +++ b/examples/hello-world/requirements.txt @@ -1 +1 @@ -pythonnative +emoji diff --git a/mkdocs.yml b/mkdocs.yml index 9a29ca4..2ea4223 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ nav: - Concepts: - Architecture: concepts/architecture.md - Components: concepts/components.md + - Hooks: concepts/hooks.md - Examples: - Overview: examples.md - Hello World: examples/hello-world.md @@ -24,10 +25,11 @@ nav: - Android: guides/android.md - iOS: guides/ios.md - Navigation: guides/navigation.md + - Styling: guides/styling.md - API Reference: - Package: api/pythonnative.md + - Component Properties: api/component-properties.md - Meta: - - Roadmap: meta/roadmap.md - Contributing: meta/contributing.md plugins: - search diff --git a/mypy.ini b/mypy.ini index ec0b8ad..370f2f6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.10 ignore_missing_imports = True warn_redundant_casts = True warn_unused_ignores = True @@ -8,10 +8,15 @@ strict_optional = False pretty = True files = src, tests, examples exclude = (^build/|^examples/.*/build/) +disallow_untyped_defs = True +disallow_incomplete_defs = True [mypy-pythonnative.*] implicit_reexport = True disable_error_code = attr-defined,no-redef -[mypy-pythonnative.button] +[mypy-pythonnative.native_views] +disable_error_code = misc + +[mypy-pythonnative.native_views.*] disable_error_code = misc diff --git a/pyproject.toml b/pyproject.toml index 0cdbd85..b5ae010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.3.0" +version = "0.8.0" description = "Cross-platform native UI toolkit for Android and iOS" authors = [ { name = "Owen Carey" } ] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { file = "LICENSE" } classifiers = [ "Development Status :: 2 - Pre-Alpha", @@ -18,7 +18,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -88,3 +87,30 @@ ignore = [] [tool.black] line-length = 120 target-version = ['py39'] + +# ── Semantic Release ──────────────────────────────────────────────── + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +version_variables = ["src/pythonnative/__init__.py:__version__"] +commit_message = "chore(release): v{version}" +tag_format = "v{version}" +major_on_zero = false + +[tool.semantic_release.branches.main] +match = "main" +prerelease = false + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" +exclude_commit_patterns = [ + "^chore\\(release\\):", +] + +[tool.semantic_release.commit_parser_options] +allowed_tags = [ + "build", "chore", "ci", "docs", "feat", "fix", + "perf", "refactor", "revert", "style", "test", +] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 1e2530d..11c4e16 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -1,74 +1,113 @@ -from importlib import import_module -from typing import Any, Dict +"""PythonNative — declarative native UI for Android and iOS. -__version__ = "0.3.0" +Public API:: + + import pythonnative as pn + + @pn.component + def App(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button("+", on_click=lambda: set_count(count + 1)), + style={"spacing": 12}, + ) +""" + +__version__ = "0.8.0" + +from .components import ( + ActivityIndicator, + Button, + Column, + ErrorBoundary, + FlatList, + Image, + Modal, + Pressable, + ProgressBar, + Row, + SafeAreaView, + ScrollView, + Slider, + Spacer, + Switch, + Text, + TextInput, + View, + WebView, +) +from .element import Element +from .hooks import ( + Provider, + batch_updates, + component, + create_context, + use_callback, + use_context, + use_effect, + use_memo, + use_navigation, + use_reducer, + use_ref, + use_state, +) +from .navigation import ( + NavigationContainer, + create_drawer_navigator, + create_stack_navigator, + create_tab_navigator, + use_focus_effect, + use_route, +) +from .page import create_page +from .style import StyleSheet, ThemeContext __all__ = [ - "ActivityIndicatorView", + # Components + "ActivityIndicator", "Button", - "DatePicker", - "ImageView", - "Label", - "ListView", - "MaterialActivityIndicatorView", - "MaterialButton", - "MaterialDatePicker", - "MaterialProgressView", - "MaterialSearchBar", - "MaterialSwitch", - "MaterialTimePicker", - "MaterialBottomNavigationView", - "MaterialToolbar", - "Page", - "PickerView", - "ProgressView", + "Column", + "ErrorBoundary", + "FlatList", + "Image", + "Modal", + "Pressable", + "ProgressBar", + "Row", + "SafeAreaView", "ScrollView", - "SearchBar", - "StackView", + "Slider", + "Spacer", "Switch", - "TextField", - "TextView", - "TimePicker", + "Text", + "TextInput", + "View", "WebView", + # Core + "Element", + "create_page", + # Hooks + "batch_updates", + "component", + "create_context", + "use_callback", + "use_context", + "use_effect", + "use_focus_effect", + "use_memo", + "use_navigation", + "use_reducer", + "use_ref", + "use_route", + "use_state", + "Provider", + # Navigation + "NavigationContainer", + "create_drawer_navigator", + "create_stack_navigator", + "create_tab_navigator", + # Styling + "StyleSheet", + "ThemeContext", ] - -_NAME_TO_MODULE: Dict[str, str] = { - "ActivityIndicatorView": ".activity_indicator_view", - "Button": ".button", - "DatePicker": ".date_picker", - "ImageView": ".image_view", - "Label": ".label", - "ListView": ".list_view", - "MaterialActivityIndicatorView": ".material_activity_indicator_view", - "MaterialButton": ".material_button", - "MaterialDatePicker": ".material_date_picker", - "MaterialProgressView": ".material_progress_view", - "MaterialSearchBar": ".material_search_bar", - "MaterialSwitch": ".material_switch", - "MaterialTimePicker": ".material_time_picker", - "MaterialBottomNavigationView": ".material_bottom_navigation_view", - "MaterialToolbar": ".material_toolbar", - "Page": ".page", - "PickerView": ".picker_view", - "ProgressView": ".progress_view", - "ScrollView": ".scroll_view", - "SearchBar": ".search_bar", - "StackView": ".stack_view", - "Switch": ".switch", - "TextField": ".text_field", - "TextView": ".text_view", - "TimePicker": ".time_picker", - "WebView": ".web_view", -} - - -def __getattr__(name: str) -> Any: - module_path = _NAME_TO_MODULE.get(name) - if not module_path: - raise AttributeError(f"module 'pythonnative' has no attribute {name!r}") - module = import_module(module_path, package=__name__) - return getattr(module, name) - - -def __dir__() -> Any: - return sorted(list(globals().keys()) + __all__) diff --git a/src/pythonnative/activity_indicator_view.py b/src/pythonnative/activity_indicator_view.py deleted file mode 100644 index 722924e..0000000 --- a/src/pythonnative/activity_indicator_view.py +++ /dev/null @@ -1,71 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ActivityIndicatorViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def start_animating(self) -> None: - pass - - @abstractmethod - def stop_animating(self) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ProgressBar - # ======================================== - - from java import jclass - - class ActivityIndicatorView(ActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ProgressBar") - # self.native_instance = self.native_class(context, None, android.R.attr.progressBarStyleLarge) - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(True) - - def start_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.VISIBLE) - return - - def stop_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.GONE) - return - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiactivityindicatorview - # ======================================== - - from rubicon.objc import ObjCClass - - class ActivityIndicatorView(ActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIActivityIndicatorView") - self.native_instance = self.native_class.alloc().initWithActivityIndicatorStyle_( - 0 - ) # 0: UIActivityIndicatorViewStyleLarge - self.native_instance.hidesWhenStopped = True - - def start_animating(self) -> None: - self.native_instance.startAnimating() - - def stop_animating(self) -> None: - self.native_instance.stopAnimating() diff --git a/src/pythonnative/button.py b/src/pythonnative/button.py deleted file mode 100644 index 13e38b1..0000000 --- a/src/pythonnative/button.py +++ /dev/null @@ -1,109 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Callable, Optional - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ButtonBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_title(self, title: str) -> None: - pass - - @abstractmethod - def get_title(self) -> str: - pass - - @abstractmethod - def set_on_click(self, callback: Callable[[], None]) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Button - # ======================================== - - from java import dynamic_proxy, jclass - - class Button(ButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.Button") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_title(title) - - def set_title(self, title: str) -> None: - self.native_instance.setText(title) - - def get_title(self) -> str: - return self.native_instance.getText().toString() - - def set_on_click(self, callback: Callable[[], None]) -> None: - class OnClickListener(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback): - super().__init__() - self.callback = callback - - def onClick(self, view): - self.callback() - - listener = OnClickListener(callback) - self.native_instance.setOnClickListener(listener) - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uibutton - # ======================================== - - from rubicon.objc import SEL, ObjCClass, objc_method - - NSObject = ObjCClass("NSObject") - - # Mypy cannot understand Rubicon's dynamic subclassing; ignore the base type here. - class _PNButtonHandler(NSObject): # type: ignore[valid-type] - # Set by the Button when wiring up the target/action callback. - _callback: Optional[Callable[[], None]] = None - - @objc_method - def onTap_(self, sender) -> None: - try: - callback = self._callback - if callback is not None: - callback() - except Exception: - # Swallow exceptions to avoid crashing the app; logging is handled at higher levels - pass - - class Button(ButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIButton") - self.native_instance = self.native_class.alloc().init() - self.set_title(title) - - def set_title(self, title: str) -> None: - self.native_instance.setTitle_forState_(title, 0) - - def get_title(self) -> str: - return self.native_instance.titleForState_(0) - - def set_on_click(self, callback: Callable[[], None]) -> None: - # Create a handler object with an Objective-C method `onTap:` and attach the Python callback - handler = _PNButtonHandler.new() - # Keep strong references to the handler and callback - self._click_handler = handler - handler._callback = callback - # UIControlEventTouchUpInside = 1 << 6 - self.native_instance.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index e053f8d..2bade03 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -2,6 +2,7 @@ import hashlib import json import os +import re import shutil import subprocess import sys @@ -41,44 +42,41 @@ def init_project(args: argparse.Namespace) -> None: os.makedirs(app_dir, exist_ok=True) - # Minimal hello world app scaffold (no bootstrap function; host instantiates Page directly) main_page_py = os.path.join(app_dir, "main_page.py") if not os.path.exists(main_page_py) or args.force: with open(main_page_py, "w", encoding="utf-8") as f: - f.write( - """import pythonnative as pn - - -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button clicked")) - stack.add_view(button) - self.set_root_view(stack) -""" - ) + f.write("""import pythonnative as pn + + +@pn.component +def MainPage(): + count, set_count = pn.use_state(0) + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative!", style={"font_size": 24, "bold": True}), + pn.Text(f"Tapped {count} times"), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + style={"spacing": 12, "padding": 16, "align_items": "stretch"}, + ) + ) +""") # Create config config = { "name": project_name, "appId": "com.example." + project_name.replace(" ", "").lower(), "entryPoint": "app/main_page.py", + "pythonVersion": "3.11", "ios": {}, "android": {}, } with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) - # Requirements + # Requirements (third-party packages only; pythonnative itself is bundled by the CLI) if not os.path.exists(requirements_path) or args.force: with open(requirements_path, "w", encoding="utf-8") as f: - f.write("pythonnative\n") + f.write("") # .gitignore default_gitignore = "# PythonNative\n" "__pycache__/\n" "*.pyc\n" ".venv/\n" "build/\n" ".DS_Store\n" @@ -152,7 +150,11 @@ def _copy_bundled_template_dir(template_dir: str, destination: str) -> None: def _github_json(url: str) -> Any: - req = urllib.request.Request(url, headers={"User-Agent": "pythonnative-cli"}) + headers: dict[str, str] = {"User-Agent": "pythonnative-cli"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req) as r: return json.loads(r.read().decode("utf-8")) @@ -207,6 +209,43 @@ def create_ios_project(project_name: str, destination: str) -> None: _copy_bundled_template_dir("ios_template", destination) +def _read_project_config() -> dict: + """Read pythonnative.json from the current working directory.""" + config_path = os.path.join(os.getcwd(), "pythonnative.json") + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as f: + return json.load(f) + return {} + + +def _read_requirements(requirements_path: str) -> list[str]: + """Read a requirements file and return non-empty, non-comment lines. + + Exits with an error if pythonnative is listed — the CLI bundles it + directly, so it must not be installed separately via pip/Chaquopy. + """ + if not os.path.exists(requirements_path): + return [] + with open(requirements_path, encoding="utf-8") as f: + lines = f.readlines() + result: list[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("-"): + continue + pkg_name = re.split(r"[\[><=!;]", stripped)[0].strip() + if pkg_name.lower().replace("-", "_") == "pythonnative": + print( + "Error: 'pythonnative' must not be in requirements.txt.\n" + "The pn CLI automatically bundles the installed pythonnative into your app.\n" + "requirements.txt is for third-party packages only (e.g. humanize, requests).\n" + "Remove the pythonnative line from requirements.txt and try again." + ) + sys.exit(1) + result.append(stripped) + return result + + def run_project(args: argparse.Namespace) -> None: """ Run the specified project. @@ -214,9 +253,15 @@ def run_project(args: argparse.Namespace) -> None: # Determine the platform platform: str = args.platform prepare_only: bool = getattr(args, "prepare_only", False) + hot_reload: bool = getattr(args, "hot_reload", False) + + # Read project configuration and save project root before any chdir + project_dir: str = os.getcwd() + config = _read_project_config() + python_version: str = config.get("pythonVersion", "3.11") # Define the build directory - build_dir: str = os.path.join(os.getcwd(), "build", platform) + build_dir: str = os.path.join(project_dir, "build", platform) # Create the build directory if it doesn't exist os.makedirs(build_dir, exist_ok=True) @@ -260,10 +305,30 @@ def run_project(args: argparse.Namespace) -> None: # Non-fatal; fallback to the packaged PyPI dependency if present pass - # Install any necessary Python packages into the project environment + # Validate and read the user's requirements.txt + requirements_path = os.path.join(project_dir, "requirements.txt") + pip_reqs = _read_requirements(requirements_path) + + if platform == "android": + # Patch the Android build.gradle with the configured Python version + app_build_gradle = os.path.join(build_dir, "android_template", "app", "build.gradle") + if os.path.exists(app_build_gradle): + with open(app_build_gradle, encoding="utf-8") as f: + content = f.read() + content = content.replace('version "3.11"', f'version "{python_version}"') + with open(app_build_gradle, "w", encoding="utf-8") as f: + f.write(content) + # Copy requirements.txt into the Android project for Chaquopy + android_reqs_path = os.path.join(build_dir, "android_template", "app", "requirements.txt") + if os.path.exists(requirements_path): + shutil.copy2(requirements_path, android_reqs_path) + else: + with open(android_reqs_path, "w", encoding="utf-8") as f: + f.write("") + + # Install any necessary Python packages into the host environment # Skip installation during prepare-only to avoid network access and speed up scaffolding if not prepare_only: - requirements_path = os.path.join(os.getcwd(), "requirements.txt") if os.path.exists(requirements_path): subprocess.run([sys.executable, "-m", "pip", "install", "-r", requirements_path], check=False) @@ -515,6 +580,29 @@ def run_project(args: argparse.Namespace) -> None: except Exception: # Non-fatal; if metadata isn't present, rubicon import may fail and fallback UI will appear pass + # Install user's pip requirements (pure-Python packages) into the app bundle + if pip_reqs: + try: + reqs_tmp = os.path.join(build_dir, "ios_requirements.txt") + with open(reqs_tmp, "w", encoding="utf-8") as f: + f.write("\n".join(pip_reqs) + "\n") + tmp_reqs_dir = os.path.join(build_dir, "ios_user_packages") + if os.path.isdir(tmp_reqs_dir): + shutil.rmtree(tmp_reqs_dir) + os.makedirs(tmp_reqs_dir, exist_ok=True) + subprocess.run( + [sys.executable, "-m", "pip", "install", "-t", tmp_reqs_dir, "-r", reqs_tmp], + check=False, + ) + for entry in os.listdir(tmp_reqs_dir): + src_entry = os.path.join(tmp_reqs_dir, entry) + dst_entry = os.path.join(platform_site_dir, entry) + if os.path.isdir(src_entry): + shutil.copytree(src_entry, dst_entry, dirs_exist_ok=True) + else: + shutil.copy2(src_entry, dst_entry) + except Exception: + pass # Note: Python.xcframework provides a static library for Simulator; it must be linked at build time. # We copy the XCFramework into the project directory above so Xcode can link it. except Exception: @@ -561,6 +649,39 @@ def run_project(args: argparse.Namespace) -> None: except Exception: print("Failed to auto-run on Simulator; open the project in Xcode to run.") + # Hot-reload file watcher + if hot_reload and not prepare_only: + _run_hot_reload(platform, project_dir, build_dir) + + +def _run_hot_reload(platform: str, project_dir: str, build_dir: str) -> None: + """Watch ``app/`` for changes and push updated files to the device.""" + from .hot_reload import FileWatcher + + app_dir = os.path.join(project_dir, "app") + + def on_change(changed_files: List[str]) -> None: + for fpath in changed_files: + rel = os.path.relpath(fpath, project_dir) + print(f"[hot-reload] Changed: {rel}") + if platform == "android": + dest = f"/data/data/com.pythonnative.android_template/files/{rel}" + subprocess.run(["adb", "push", fpath, dest], check=False, capture_output=True) + elif platform == "ios": + pass # simctl file push would go here + + print("[hot-reload] Watching app/ for changes. Press Ctrl+C to stop.") + watcher = FileWatcher(app_dir, on_change, interval=1.0) + watcher.start() + try: + import time + + while True: + time.sleep(1) + except KeyboardInterrupt: + watcher.stop() + print("\n[hot-reload] Stopped.") + def clean_project(args: argparse.Namespace) -> None: """ @@ -595,6 +716,11 @@ def main() -> None: action="store_true", help="Extract templates and stage app without building", ) + parser_run.add_argument( + "--hot-reload", + action="store_true", + help="Watch app/ for changes and push updates to the running app", + ) parser_run.set_defaults(func=run_project) # Create a new command 'clean' that calls clean_project diff --git a/src/pythonnative/collection_view.py b/src/pythonnative/collection_view.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py new file mode 100644 index 0000000..eb748a2 --- /dev/null +++ b/src/pythonnative/components.py @@ -0,0 +1,408 @@ +"""Built-in element-creating functions for declarative UI composition. + +Each function returns an :class:`Element` describing a native UI widget. +These are pure data — no native views are created until the reconciler +mounts the element tree. + +All visual and layout properties are passed via the ``style`` parameter, +which accepts a dict or a list of dicts (later entries override earlier). + +Layout properties supported by all components:: + + width, height, flex, flex_grow, flex_shrink, margin, + min_width, max_width, min_height, max_height, align_self + +Flex container properties (View / Column / Row):: + + flex_direction, justify_content, align_items, overflow, + spacing, padding + +``View`` is the universal flex container (like React Native's ``View``). +It defaults to ``flex_direction: "column"``. ``Column`` and ``Row`` +are convenience wrappers that fix the direction to ``"column"`` and +``"row"`` respectively. +""" + +from typing import Any, Callable, Dict, List, Optional + +from .element import Element +from .style import StyleValue, resolve_style + +# ====================================================================== +# Leaf components +# ====================================================================== + + +def Text( + text: str = "", + *, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Display text. + + Style properties: ``font_size``, ``color``, ``bold``, ``text_align``, + ``background_color``, ``max_lines``, plus common layout props. + """ + props: Dict[str, Any] = {"text": text} + props.update(resolve_style(style)) + return Element("Text", props, [], key=key) + + +def Button( + title: str = "", + *, + on_click: Optional[Callable[[], None]] = None, + enabled: bool = True, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Create a tappable button. + + Style properties: ``color``, ``background_color``, ``font_size``, + plus common layout props. + """ + props: Dict[str, Any] = {"title": title} + if on_click is not None: + props["on_click"] = on_click + if not enabled: + props["enabled"] = False + props.update(resolve_style(style)) + return Element("Button", props, [], key=key) + + +def TextInput( + *, + value: str = "", + placeholder: str = "", + on_change: Optional[Callable[[str], None]] = None, + secure: bool = False, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Create a single-line text entry field. + + Style properties: ``font_size``, ``color``, ``background_color``, + plus common layout props. + """ + props: Dict[str, Any] = {"value": value} + if placeholder: + props["placeholder"] = placeholder + if on_change is not None: + props["on_change"] = on_change + if secure: + props["secure"] = True + props.update(resolve_style(style)) + return Element("TextInput", props, [], key=key) + + +def Image( + source: str = "", + *, + scale_type: Optional[str] = None, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Display an image from a resource path or URL. + + Style properties: ``background_color``, plus common layout props. + """ + props: Dict[str, Any] = {} + if source: + props["source"] = source + if scale_type is not None: + props["scale_type"] = scale_type + props.update(resolve_style(style)) + return Element("Image", props, [], key=key) + + +def Switch( + *, + value: bool = False, + on_change: Optional[Callable[[bool], None]] = None, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Create a toggle switch.""" + props: Dict[str, Any] = {"value": value} + if on_change is not None: + props["on_change"] = on_change + props.update(resolve_style(style)) + return Element("Switch", props, [], key=key) + + +def ProgressBar( + *, + value: float = 0.0, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Show determinate progress (0.0 – 1.0).""" + props: Dict[str, Any] = {"value": value} + props.update(resolve_style(style)) + return Element("ProgressBar", props, [], key=key) + + +def ActivityIndicator( + *, + animating: bool = True, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Show an indeterminate loading spinner.""" + props: Dict[str, Any] = {"animating": animating} + props.update(resolve_style(style)) + return Element("ActivityIndicator", props, [], key=key) + + +def WebView( + *, + url: str = "", + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Embed web content.""" + props: Dict[str, Any] = {} + if url: + props["url"] = url + props.update(resolve_style(style)) + return Element("WebView", props, [], key=key) + + +def Spacer( + *, + size: Optional[float] = None, + flex: Optional[float] = None, + key: Optional[str] = None, +) -> Element: + """Insert empty space with an optional fixed size or flex weight.""" + props: Dict[str, Any] = {} + if size is not None: + props["size"] = size + if flex is not None: + props["flex"] = flex + return Element("Spacer", props, [], key=key) + + +def Slider( + *, + value: float = 0.0, + min_value: float = 0.0, + max_value: float = 1.0, + on_change: Optional[Callable[[float], None]] = None, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Continuous value slider.""" + props: Dict[str, Any] = { + "value": value, + "min_value": min_value, + "max_value": max_value, + } + if on_change is not None: + props["on_change"] = on_change + props.update(resolve_style(style)) + return Element("Slider", props, [], key=key) + + +# ====================================================================== +# Container components +# ====================================================================== + + +def View( + *children: Element, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Universal flex container (like React Native's ``View``). + + Defaults to ``flex_direction: "column"``. Override via ``style``:: + + pn.View(child_a, child_b, style={"flex_direction": "row"}) + + Flex container properties (inside ``style``): + + - ``flex_direction`` — ``"column"`` (default), ``"row"``, + ``"column_reverse"``, ``"row_reverse"`` + - ``justify_content`` — main-axis distribution: + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"`` + - ``align_items`` — cross-axis alignment: + ``"stretch"`` (default), ``"flex_start"``, ``"center"``, + ``"flex_end"`` + - ``overflow`` — ``"visible"`` (default) or ``"hidden"`` + - ``spacing``, ``padding``, ``background_color`` + """ + props: Dict[str, Any] = {"flex_direction": "column"} + props.update(resolve_style(style)) + return Element("View", props, list(children), key=key) + + +def Column( + *children: Element, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Arrange children vertically (``flex_direction: "column"``). + + Convenience wrapper around :func:`View`. The direction is fixed; + use :func:`View` directly if you need ``flex_direction: "row"``. + + Style properties: ``spacing``, ``padding``, ``align_items``, + ``justify_content``, ``background_color``, ``overflow``, + plus common layout props. + + ``align_items`` controls cross-axis (horizontal) alignment: + ``"stretch"`` (default), ``"flex_start"``/``"leading"``, + ``"center"``, ``"flex_end"``/``"trailing"``. + + ``justify_content`` controls main-axis (vertical) distribution: + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"``. + """ + props: Dict[str, Any] = {"flex_direction": "column"} + props.update(resolve_style(style)) + props["flex_direction"] = "column" + return Element("Column", props, list(children), key=key) + + +def Row( + *children: Element, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Arrange children horizontally (``flex_direction: "row"``). + + Convenience wrapper around :func:`View`. The direction is fixed; + use :func:`View` directly if you need ``flex_direction: "column"``. + + Style properties: ``spacing``, ``padding``, ``align_items``, + ``justify_content``, ``background_color``, ``overflow``, + plus common layout props. + + ``align_items`` controls cross-axis (vertical) alignment: + ``"stretch"`` (default), ``"flex_start"``/``"top"``, + ``"center"``, ``"flex_end"``/``"bottom"``. + + ``justify_content`` controls main-axis (horizontal) distribution: + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"``. + """ + props: Dict[str, Any] = {"flex_direction": "row"} + props.update(resolve_style(style)) + props["flex_direction"] = "row" + return Element("Row", props, list(children), key=key) + + +def ScrollView( + child: Optional[Element] = None, + *, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Wrap a single child in a scrollable container.""" + children = [child] if child is not None else [] + props: Dict[str, Any] = {} + props.update(resolve_style(style)) + return Element("ScrollView", props, children, key=key) + + +def SafeAreaView( + *children: Element, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Container that respects safe area insets (notch, status bar).""" + props: Dict[str, Any] = {} + props.update(resolve_style(style)) + return Element("SafeAreaView", props, list(children), key=key) + + +def Modal( + *children: Element, + visible: bool = False, + on_dismiss: Optional[Callable[[], None]] = None, + title: Optional[str] = None, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Overlay modal dialog. + + The modal is shown when ``visible=True`` and hidden when ``False``. + """ + props: Dict[str, Any] = {"visible": visible} + if on_dismiss is not None: + props["on_dismiss"] = on_dismiss + if title is not None: + props["title"] = title + props.update(resolve_style(style)) + return Element("Modal", props, list(children), key=key) + + +def Pressable( + child: Optional[Element] = None, + *, + on_press: Optional[Callable[[], None]] = None, + on_long_press: Optional[Callable[[], None]] = None, + key: Optional[str] = None, +) -> Element: + """Wrapper that adds press handling to any child element.""" + props: Dict[str, Any] = {} + if on_press is not None: + props["on_press"] = on_press + if on_long_press is not None: + props["on_long_press"] = on_long_press + children = [child] if child is not None else [] + return Element("Pressable", props, children, key=key) + + +def ErrorBoundary( + child: Optional[Element] = None, + *, + fallback: Optional[Any] = None, + key: Optional[str] = None, +) -> Element: + """Catch render errors in *child* and display *fallback* instead. + + *fallback* may be an ``Element`` or a callable that receives the + exception and returns an ``Element``:: + + pn.ErrorBoundary( + MyRiskyComponent(), + fallback=lambda err: pn.Text(f"Error: {err}"), + ) + """ + props: Dict[str, Any] = {} + if fallback is not None: + props["__fallback__"] = fallback + children = [child] if child is not None else [] + return Element("__ErrorBoundary__", props, children, key=key) + + +def FlatList( + *, + data: Optional[List[Any]] = None, + render_item: Optional[Callable[[Any, int], Element]] = None, + key_extractor: Optional[Callable[[Any, int], str]] = None, + separator_height: float = 0, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Scrollable list that renders items from *data* using *render_item*. + + Each item is rendered by calling ``render_item(item, index)``. If + ``key_extractor`` is provided, it is called as ``key_extractor(item, index)`` + to produce a stable key for each child element. + """ + items: List[Element] = [] + for i, item in enumerate(data or []): + el = render_item(item, i) if render_item else Text(str(item)) + if key_extractor is not None: + el = Element(el.type, el.props, el.children, key=key_extractor(item, i)) + items.append(el) + + inner = Column(*items, style={"spacing": separator_height} if separator_height else None) + sv_props: Dict[str, Any] = {} + sv_props.update(resolve_style(style)) + return Element("ScrollView", sv_props, [inner], key=key) diff --git a/src/pythonnative/date_picker.py b/src/pythonnative/date_picker.py deleted file mode 100644 index cb86006..0000000 --- a/src/pythonnative/date_picker.py +++ /dev/null @@ -1,72 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class DatePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_date(self, year: int, month: int, day: int) -> None: - pass - - @abstractmethod - def get_date(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/DatePicker - # ======================================== - - from java import jclass - - class DatePicker(DatePickerBase, ViewBase): - def __init__(self, context, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.DatePicker") - self.native_instance = self.native_class(context) - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> None: - self.native_instance.updateDate(year, month, day) - - def get_date(self) -> tuple: - year = self.native_instance.getYear() - month = self.native_instance.getMonth() - day = self.native_instance.getDayOfMonth() - return year, month, day - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import datetime - - from rubicon.objc import ObjCClass - - class DatePicker(DatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> None: - date = datetime(year, month, day) - self.native_instance.setDate_(date) - - def get_date(self) -> tuple: - date = self.native_instance.date() - return date.year, date.month, date.day diff --git a/src/pythonnative/element.py b/src/pythonnative/element.py new file mode 100644 index 0000000..63684d3 --- /dev/null +++ b/src/pythonnative/element.py @@ -0,0 +1,53 @@ +"""Lightweight element descriptors for the virtual view tree. + +An Element is an immutable description of a UI node — analogous to a React +element. It captures a type (name string **or** component function), a props +dictionary, and an ordered list of children without creating any native +platform objects. The reconciler consumes these trees to determine what +native views must be created, updated, or removed. +""" + +from typing import Any, Dict, List, Optional, Union + + +class Element: + """Immutable description of a single UI node. + + ``type_name`` may be a *string* (e.g. ``"Text"``) for built-in native + elements or a *callable* for function components decorated with + :func:`~pythonnative.hooks.component`. + """ + + __slots__ = ("type", "props", "children", "key") + + def __init__( + self, + type_name: Union[str, Any], + props: Dict[str, Any], + children: List["Element"], + key: Optional[str] = None, + ) -> None: + self.type = type_name + self.props = props + self.children = children + self.key = key + + def __repr__(self) -> str: + t = self.type if isinstance(self.type, str) else getattr(self.type, "__name__", repr(self.type)) + return f"Element({t!r}, props={set(self.props)}, children={len(self.children)})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Element): + return NotImplemented + return ( + self.type == other.type + and self.props == other.props + and self.children == other.children + and self.key == other.key + ) + + def __ne__(self, other: object) -> bool: + result = self.__eq__(other) + if result is NotImplemented: + return result + return not result diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py new file mode 100644 index 0000000..b6ff196 --- /dev/null +++ b/src/pythonnative/hooks.py @@ -0,0 +1,440 @@ +"""Hook primitives for function components. + +Provides React-like hooks for managing state, effects, memoisation, +context, and navigation within function components decorated with +:func:`component`. + +Usage:: + + import pythonnative as pn + + @pn.component + def counter(initial=0): + count, set_count = pn.use_state(initial) + return pn.Column( + pn.Text(f"Count: {count}"), + pn.Button("+", on_click=lambda: set_count(count + 1)), + ) +""" + +import inspect +import threading +from contextlib import contextmanager +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, TypeVar + +from .element import Element + +T = TypeVar("T") + +_SENTINEL = object() + +_hook_context: threading.local = threading.local() + +_batch_context: threading.local = threading.local() + +# ====================================================================== +# Hook state container +# ====================================================================== + + +class HookState: + """Stores all hook data for a single function component instance. + + Effects are **queued** during the render phase and **flushed** after + the reconciler commits native-view mutations. This guarantees that + effect callbacks can safely interact with the committed native tree. + """ + + __slots__ = ( + "states", + "effects", + "memos", + "refs", + "hook_index", + "_trigger_render", + "_pending_effects", + ) + + def __init__(self) -> None: + self.states: List[Any] = [] + self.effects: List[Tuple[Any, Any]] = [] + self.memos: List[Tuple[Any, Any]] = [] + self.refs: List[dict] = [] + self.hook_index: int = 0 + self._trigger_render: Optional[Callable[[], None]] = None + self._pending_effects: List[Tuple[int, Callable, Any]] = [] + + def reset_index(self) -> None: + self.hook_index = 0 + + def flush_pending_effects(self) -> None: + """Execute effects queued during the render pass (called after commit).""" + pending = self._pending_effects + self._pending_effects = [] + for idx, effect_fn, deps in pending: + _, prev_cleanup = self.effects[idx] + if callable(prev_cleanup): + try: + prev_cleanup() + except Exception: + pass + cleanup = effect_fn() + self.effects[idx] = (list(deps) if deps is not None else None, cleanup) + + def cleanup_all_effects(self) -> None: + """Run all outstanding cleanup functions (called on unmount).""" + for i, (deps, cleanup) in enumerate(self.effects): + if callable(cleanup): + try: + cleanup() + except Exception: + pass + self.effects[i] = (_SENTINEL, None) + self._pending_effects = [] + + +# ====================================================================== +# Thread-local context helpers +# ====================================================================== + + +def _get_hook_state() -> Optional[HookState]: + return getattr(_hook_context, "current", None) + + +def _set_hook_state(state: Optional[HookState]) -> None: + _hook_context.current = state + + +def _deps_changed(prev: Any, current: Any) -> bool: + if prev is _SENTINEL: + return True + if prev is None or current is None: + return True + if len(prev) != len(current): + return True + return any(p is not c and p != c for p, c in zip(prev, current)) + + +# ====================================================================== +# Batching helpers +# ====================================================================== + + +def _schedule_trigger(trigger: Callable[[], None]) -> None: + """Call *trigger* now, or defer it if inside :func:`batch_updates`.""" + if getattr(_batch_context, "depth", 0) > 0: + _batch_context.pending_trigger = trigger + else: + trigger() + + +@contextmanager +def batch_updates() -> Generator[None, None, None]: + """Batch multiple state updates so only one re-render occurs. + + Usage:: + + with pn.batch_updates(): + set_count(1) + set_name("hello") + # single re-render happens here + """ + depth = getattr(_batch_context, "depth", 0) + _batch_context.depth = depth + 1 + if depth == 0: + _batch_context.pending_trigger = None + try: + yield + finally: + _batch_context.depth -= 1 + if _batch_context.depth == 0: + trigger = _batch_context.pending_trigger + _batch_context.pending_trigger = None + if trigger is not None: + trigger() + + +# ====================================================================== +# Public hooks +# ====================================================================== + + +def use_state(initial: Any = None) -> Tuple[Any, Callable]: + """Return ``(value, setter)`` for component-local state. + + If *initial* is callable it is invoked once (lazy initialisation). + The setter accepts a value **or** a ``current -> new`` callable. + """ + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_state must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.states): + val = initial() if callable(initial) else initial + ctx.states.append(val) + + current = ctx.states[idx] + + def setter(new_value: Any) -> None: + if callable(new_value): + new_value = new_value(ctx.states[idx]) + if ctx.states[idx] is not new_value and ctx.states[idx] != new_value: + ctx.states[idx] = new_value + if ctx._trigger_render: + _schedule_trigger(ctx._trigger_render) + + return current, setter + + +def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple[Any, Callable]: + """Return ``(state, dispatch)`` for reducer-based state management. + + The *reducer* is called as ``reducer(current_state, action)`` and + must return the new state. If *initial_state* is callable it is + invoked once (lazy initialisation). + + Usage:: + + def reducer(state, action): + if action == "increment": + return state + 1 + if action == "reset": + return 0 + return state + + count, dispatch = pn.use_reducer(reducer, 0) + # dispatch("increment") -> re-render with count = 1 + """ + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_reducer must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.states): + val = initial_state() if callable(initial_state) else initial_state + ctx.states.append(val) + + current = ctx.states[idx] + + def dispatch(action: Any) -> None: + new_state = reducer(ctx.states[idx], action) + if ctx.states[idx] is not new_state and ctx.states[idx] != new_state: + ctx.states[idx] = new_state + if ctx._trigger_render: + _schedule_trigger(ctx._trigger_render) + + return current, dispatch + + +def use_effect(effect: Callable, deps: Optional[list] = None) -> None: + """Schedule *effect* to run **after** the native tree is committed. + + Effects are queued during the render pass and flushed once the + reconciler has finished applying all native-view mutations. This + means effects can safely measure layout or interact with committed + native views. + + *deps* controls when the effect re-runs: + + - ``None`` -> every render + - ``[]`` -> mount only + - ``[a, b]``-> when *a* or *b* change + + *effect* may return a cleanup callable. + """ + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_effect must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.effects): + ctx.effects.append((_SENTINEL, None)) + ctx._pending_effects.append((idx, effect, deps)) + return + + prev_deps, _prev_cleanup = ctx.effects[idx] + if _deps_changed(prev_deps, deps): + ctx._pending_effects.append((idx, effect, deps)) + + +def use_memo(factory: Callable[[], T], deps: list) -> T: + """Return a memoised value, recomputed only when *deps* change.""" + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_memo must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.memos): + value = factory() + ctx.memos.append((list(deps), value)) + return value + + prev_deps, prev_value = ctx.memos[idx] + if not _deps_changed(prev_deps, deps): + return prev_value + + value = factory() + ctx.memos[idx] = (list(deps), value) + return value + + +def use_callback(callback: Callable, deps: list) -> Callable: + """Return a stable reference to *callback*, updated only when *deps* change.""" + return use_memo(lambda: callback, deps) + + +def use_ref(initial: Any = None) -> dict: + """Return a mutable ref dict ``{"current": initial}`` that persists across renders.""" + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_ref must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.refs): + ref: dict = {"current": initial} + ctx.refs.append(ref) + return ref + + return ctx.refs[idx] + + +# ====================================================================== +# Context +# ====================================================================== + + +class Context: + """A context object created by :func:`create_context`.""" + + def __init__(self, default: Any = None) -> None: + self.default = default + self._stack: List[Any] = [] + + def _current(self) -> Any: + return self._stack[-1] if self._stack else self.default + + +def create_context(default: Any = None) -> Context: + """Create a new context with an optional default value.""" + return Context(default) + + +def use_context(context: Context) -> Any: + """Read the current value of *context* from the nearest ``Provider`` ancestor.""" + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_context must be called inside a @component function") + return context._current() + + +# ====================================================================== +# Provider element helper +# ====================================================================== + + +def Provider(context: Context, value: Any, child: Element) -> Element: + """Create a context provider element. + + All descendants of *child* will read *value* via ``use_context(context)``. + """ + return Element("__Provider__", {"__context__": context, "__value__": value}, [child]) + + +# ====================================================================== +# Navigation +# ====================================================================== + +_NavigationContext: Context = create_context(None) + + +class NavigationHandle: + """Object returned by :func:`use_navigation` providing navigation methods. + + :: + + nav = pn.use_navigation() + nav.navigate(DetailScreen, params={"id": 42}) + nav.go_back() + """ + + def __init__(self, host: Any) -> None: + self._host = host + + def navigate(self, page: Any, params: Optional[Dict[str, Any]] = None) -> None: + """Navigate forward to *page* with optional *params*.""" + self._host._push(page, params) + + def go_back(self) -> None: + """Navigate back to the previous screen.""" + self._host._pop() + + def get_params(self) -> Dict[str, Any]: + """Return parameters passed from the previous screen.""" + return self._host._get_nav_args() + + +def use_navigation() -> NavigationHandle: + """Return a :class:`NavigationHandle` for the current screen. + + Must be called inside a ``@component`` function rendered by PythonNative. + """ + handle = use_context(_NavigationContext) + if handle is None: + raise RuntimeError( + "use_navigation() called outside a PythonNative page. " + "Ensure your component is rendered via create_page()." + ) + return handle + + +# ====================================================================== +# @component decorator +# ====================================================================== + + +def component(func: Callable) -> Callable[..., Element]: + """Decorator that turns a Python function into a PythonNative component. + + The decorated function can use hooks (``use_state``, ``use_effect``, etc.) + and returns an ``Element`` tree. Each call site creates an independent + component instance with its own hook state. + """ + sig = inspect.signature(func) + positional_params = [ + name + for name, p in sig.parameters.items() + if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()) + + def wrapper(*args: Any, **kwargs: Any) -> Element: + props: dict = dict(kwargs) + + if args: + if has_var_positional: + props["children"] = list(args) + else: + for i, arg in enumerate(args): + if i < len(positional_params): + props[positional_params[i]] = arg + + key = props.pop("key", None) + return Element(func, props, [], key=key) + + wrapper.__wrapped__ = func # noqa: B010 + wrapper.__name__ = func.__name__ + wrapper.__qualname__ = func.__qualname__ + wrapper._pn_component = True # noqa: B010 + return wrapper diff --git a/src/pythonnative/hot_reload.py b/src/pythonnative/hot_reload.py new file mode 100644 index 0000000..7a10bd9 --- /dev/null +++ b/src/pythonnative/hot_reload.py @@ -0,0 +1,143 @@ +"""Hot-reload support for PythonNative development. + +Host-side +~~~~~~~~~ +:class:`FileWatcher` monitors the ``app/`` directory for changes and +triggers a push-and-reload cycle via ``adb push`` (Android) or +``simctl`` file copy (iOS). + +Device-side +~~~~~~~~~~~ +:class:`ModuleReloader` reloads changed Python modules using +``importlib.reload`` and triggers a page re-render. + +Usage (host-side, integrated into ``pn run --hot-reload``):: + + watcher = FileWatcher("app/", on_change=push_files) + watcher.start() +""" + +import importlib +import os +import sys +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +# ====================================================================== +# Host-side file watcher +# ====================================================================== + + +class FileWatcher: + """Watch a directory tree for ``.py`` file changes. + + Parameters + ---------- + watch_dir: + Directory to watch. + on_change: + Called with a list of changed file paths when modifications are detected. + interval: + Polling interval in seconds. + """ + + def __init__(self, watch_dir: str, on_change: Callable[[List[str]], None], interval: float = 1.0) -> None: + self.watch_dir = watch_dir + self.on_change = on_change + self.interval = interval + self._running = False + self._thread: Optional[threading.Thread] = None + self._mtimes: Dict[str, float] = {} + + def start(self) -> None: + """Begin watching in a background daemon thread.""" + self._running = True + self._scan() + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the watcher.""" + self._running = False + if self._thread is not None: + self._thread.join(timeout=self.interval * 2) + self._thread = None + + def _scan(self) -> List[str]: + changed: List[str] = [] + current_files: set = set() + + for root, _dirs, files in os.walk(self.watch_dir): + for fname in files: + if not fname.endswith(".py"): + continue + fpath = os.path.join(root, fname) + current_files.add(fpath) + try: + mtime = os.path.getmtime(fpath) + except OSError: + continue + if fpath in self._mtimes: + if mtime > self._mtimes[fpath]: + changed.append(fpath) + self._mtimes[fpath] = mtime + + for old in list(self._mtimes): + if old not in current_files: + del self._mtimes[old] + + return changed + + def _loop(self) -> None: + while self._running: + time.sleep(self.interval) + changed = self._scan() + if changed: + try: + self.on_change(changed) + except Exception: + pass + + +# ====================================================================== +# Device-side module reloader +# ====================================================================== + + +class ModuleReloader: + """Reload changed Python modules on device and trigger re-render.""" + + @staticmethod + def reload_module(module_name: str) -> bool: + """Reload a single module by its dotted name. + + Returns ``True`` if the module was found and reloaded successfully. + """ + mod = sys.modules.get(module_name) + if mod is None: + return False + try: + importlib.reload(mod) + return True + except Exception: + return False + + @staticmethod + def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]: + """Convert a file path to a dotted module name relative to *base_dir*.""" + rel = os.path.relpath(file_path, base_dir) if base_dir else file_path + if rel.endswith(".py"): + rel = rel[:-3] + parts = rel.replace(os.sep, ".").split(".") + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) if parts else None + + @staticmethod + def reload_page(page_instance: Any) -> None: + """Force a page re-render after module reload.""" + from .page import _request_render + + if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None: + _request_render(page_instance) diff --git a/src/pythonnative/image_view.py b/src/pythonnative/image_view.py deleted file mode 100644 index 78cb1ff..0000000 --- a/src/pythonnative/image_view.py +++ /dev/null @@ -1,76 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ImageViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_image(self, image: str) -> None: - pass - - @abstractmethod - def get_image(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ImageView - # ======================================== - - from android.graphics import BitmapFactory - from java import jclass - - class ImageView(ImageViewBase, ViewBase): - def __init__(self, image: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.ImageView") - context = get_android_context() - self.native_instance = self.native_class(context) - if image: - self.set_image(image) - - def set_image(self, image: str) -> None: - bitmap = BitmapFactory.decodeFile(image) - self.native_instance.setImageBitmap(bitmap) - - def get_image(self) -> str: - # Please note that this is a simplistic representation, getting image from ImageView - # in Android would require converting Drawable to Bitmap and then to File - return "Image file path in Android" - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiimageview - # ======================================== - - from rubicon.objc import ObjCClass - from rubicon.objc.api import NSString, UIImage - - class ImageView(ImageViewBase, ViewBase): - def __init__(self, image: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIImageView") - self.native_instance = self.native_class.alloc().init() - if image: - self.set_image(image) - - def set_image(self, image: str) -> None: - ns_str = NSString.alloc().initWithUTF8String_(image) - ui_image = UIImage.imageNamed_(ns_str) - self.native_instance.setImage_(ui_image) - - def get_image(self) -> str: - # Similar to Android, getting the image from UIImageView isn't straightforward. - return "Image file name in iOS" diff --git a/src/pythonnative/label.py b/src/pythonnative/label.py deleted file mode 100644 index c34eec2..0000000 --- a/src/pythonnative/label.py +++ /dev/null @@ -1,66 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class LabelBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> None: - pass - - @abstractmethod - def get_text(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/TextView - # ======================================== - - from java import jclass - - class Label(LabelBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.TextView") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_text(text) - - def set_text(self, text: str) -> None: - self.native_instance.setText(text) - - def get_text(self) -> str: - return self.native_instance.getText().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uilabel - # ======================================== - - from rubicon.objc import ObjCClass - - class Label(LabelBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UILabel") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> None: - self.native_instance.setText_(text) - - def get_text(self) -> str: - return self.native_instance.text() diff --git a/src/pythonnative/list_view.py b/src/pythonnative/list_view.py deleted file mode 100644 index d2378d2..0000000 --- a/src/pythonnative/list_view.py +++ /dev/null @@ -1,73 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ListViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_data(self, data: list) -> None: - pass - - @abstractmethod - def get_data(self) -> list: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ListView - # ======================================== - - from java import jclass - - class ListView(ListViewBase, ViewBase): - def __init__(self, context, data: list = []) -> None: - super().__init__() - self.context = context - self.native_class = jclass("android.widget.ListView") - self.native_instance = self.native_class(context) - self.set_data(data) - - def set_data(self, data: list) -> None: - adapter = jclass("android.widget.ArrayAdapter")( - self.context, jclass("android.R$layout").simple_list_item_1, data - ) - self.native_instance.setAdapter(adapter) - - def get_data(self) -> list: - adapter = self.native_instance.getAdapter() - return [adapter.getItem(i) for i in range(adapter.getCount())] - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitableview - # ======================================== - - from rubicon.objc import ObjCClass - - class ListView(ListViewBase, ViewBase): - def __init__(self, data: list = []) -> None: - super().__init__() - self.native_class = ObjCClass("UITableView") - self.native_instance = self.native_class.alloc().init() - self.set_data(data) - - def set_data(self, data: list) -> None: - # Note: This is a simplified representation. Normally, you would need to create a UITableViewDataSource. - self.native_instance.reloadData() - - def get_data(self) -> list: - # Note: This is a simplified representation. - # Normally, you would need to get data from the UITableViewDataSource. - return [] diff --git a/src/pythonnative/material_activity_indicator_view.py b/src/pythonnative/material_activity_indicator_view.py deleted file mode 100644 index a568ced..0000000 --- a/src/pythonnative/material_activity_indicator_view.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialActivityIndicatorViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def start_animating(self) -> None: - pass - - @abstractmethod - def stop_animating(self) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/progressindicator/CircularProgressIndicator - # ======================================== - - from java import jclass - - class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self, context) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.progressindicator.CircularProgressIndicator") - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(True) - - def start_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.VISIBLE) - return - - def stop_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.GONE) - return - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiactivityindicatorview - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIActivityIndicatorView") - self.native_instance = self.native_class.alloc().initWithActivityIndicatorStyle_( - 0 - ) # 0: UIActivityIndicatorViewStyleLarge - self.native_instance.hidesWhenStopped = True - - def start_animating(self) -> None: - self.native_instance.startAnimating() - - def stop_animating(self) -> None: - self.native_instance.stopAnimating() diff --git a/src/pythonnative/material_bottom_navigation_view.py b/src/pythonnative/material_bottom_navigation_view.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pythonnative/material_button.py b/src/pythonnative/material_button.py deleted file mode 100644 index 1db600a..0000000 --- a/src/pythonnative/material_button.py +++ /dev/null @@ -1,65 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialButtonBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_title(self, title: str) -> None: - pass - - @abstractmethod - def get_title(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/button/MaterialButton - # ======================================== - - from java import jclass - - class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, context, title: str = "") -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.button.MaterialButton") - self.native_instance = self.native_class(context) - self.set_title(title) - - def set_title(self, title: str) -> None: - self.native_instance.setText(title) - - def get_title(self) -> str: - return self.native_instance.getText().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uibutton - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIButton") # Apple does not have a direct equivalent for MaterialButton - self.native_instance = self.native_class.alloc().init() - self.set_title(title) - - def set_title(self, title: str) -> None: - self.native_instance.setTitle_forState_(title, 0) - - def get_title(self) -> str: - return self.native_instance.titleForState_(0) diff --git a/src/pythonnative/material_date_picker.py b/src/pythonnative/material_date_picker.py deleted file mode 100644 index 0eadeec..0000000 --- a/src/pythonnative/material_date_picker.py +++ /dev/null @@ -1,85 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialDatePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_date(self, year: int, month: int, day: int) -> None: - pass - - @abstractmethod - def get_date(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/datepicker/MaterialDatePicker - # ======================================== - - from java import jclass - - class MaterialDatePicker(MaterialDatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.datepicker.MaterialDatePicker") - self.builder = self.native_class.Builder.datePicker() - self.set_date(year, month, day) - self.native_instance = self.builder.build() - - def set_date(self, year: int, month: int, day: int) -> None: - # MaterialDatePicker uses milliseconds since epoch to set date - from java.util import Calendar - - cal = Calendar.getInstance() - cal.set(year, month, day) - milliseconds = cal.getTimeInMillis() - self.builder.setSelection(milliseconds) - - def get_date(self) -> tuple: - # Convert selection (milliseconds since epoch) back to a date - from java.util import Calendar - - cal = Calendar.getInstance() - cal.setTimeInMillis(self.native_instance.getSelection()) - return ( - cal.get(Calendar.YEAR), - cal.get(Calendar.MONTH), - cal.get(Calendar.DAY_OF_MONTH), - ) - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import datetime - - from rubicon.objc import ObjCClass - - class MaterialDatePicker(MaterialDatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> None: - date = datetime(year, month, day) - self.native_instance.setDate_(date) - - def get_date(self) -> tuple: - date = self.native_instance.date() - return date.year, date.month, date.day diff --git a/src/pythonnative/material_progress_view.py b/src/pythonnative/material_progress_view.py deleted file mode 100644 index 23ca565..0000000 --- a/src/pythonnative/material_progress_view.py +++ /dev/null @@ -1,66 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialProgressViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_progress(self, progress: float) -> None: - pass - - @abstractmethod - def get_progress(self) -> float: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/progressindicator/LinearProgressIndicator - # ======================================== - - from java import jclass - - class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self, context) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.progressindicator.LinearProgressIndicator") - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(False) - - def set_progress(self, progress: float) -> None: - self.native_instance.setProgress(int(progress * 100)) - - def get_progress(self) -> float: - return self.native_instance.getProgress() / 100.0 - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiprogressview - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIProgressView") - self.native_instance = self.native_class.alloc().initWithProgressViewStyle_( - 0 - ) # 0: UIProgressViewStyleDefault - - def set_progress(self, progress: float) -> None: - self.native_instance.setProgress_animated_(progress, False) - - def get_progress(self) -> float: - return self.native_instance.progress() diff --git a/src/pythonnative/material_search_bar.py b/src/pythonnative/material_search_bar.py deleted file mode 100644 index 0693323..0000000 --- a/src/pythonnative/material_search_bar.py +++ /dev/null @@ -1,65 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialSearchBarBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_query(self, query: str) -> None: - pass - - @abstractmethod - def get_query(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/search/SearchBar - # ======================================== - - from java import jclass - - class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, context, query: str = "") -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.search.SearchBar") - self.native_instance = self.native_class(context) - self.set_query(query) - - def set_query(self, query: str) -> None: - self.native_instance.setQuery(query, False) - - def get_query(self) -> str: - return self.native_instance.getQuery().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uisearchbar - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, query: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UISearchBar") - self.native_instance = self.native_class.alloc().init() - self.set_query(query) - - def set_query(self, query: str) -> None: - self.native_instance.set_searchText_(query) - - def get_query(self) -> str: - return self.native_instance.searchText() diff --git a/src/pythonnative/material_switch.py b/src/pythonnative/material_switch.py deleted file mode 100644 index 21003b5..0000000 --- a/src/pythonnative/material_switch.py +++ /dev/null @@ -1,65 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialSwitchBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_on(self, value: bool) -> None: - pass - - @abstractmethod - def is_on(self) -> bool: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/materialswitch/MaterialSwitch - # ======================================== - - from java import jclass - - class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, context, value: bool = False) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.switch.MaterialSwitch") - self.native_instance = self.native_class(context) - self.set_on(value) - - def set_on(self, value: bool) -> None: - self.native_instance.setChecked(value) - - def is_on(self) -> bool: - return self.native_instance.isChecked() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiswitch - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = ObjCClass("UISwitch") - self.native_instance = self.native_class.alloc().init() - self.set_on(value) - - def set_on(self, value: bool) -> None: - self.native_instance.setOn_animated_(value, False) - - def is_on(self) -> bool: - return self.native_instance.isOn() diff --git a/src/pythonnative/material_time_picker.py b/src/pythonnative/material_time_picker.py deleted file mode 100644 index 03d7303..0000000 --- a/src/pythonnative/material_time_picker.py +++ /dev/null @@ -1,72 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialTimePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_time(self, hour: int, minute: int) -> None: - pass - - @abstractmethod - def get_time(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/timepicker/MaterialTimePicker - # ======================================== - - from java import jclass - - class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, context, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.timepicker.MaterialTimePicker") - self.native_instance = self.native_class(context) - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> None: - self.native_instance.setTime(hour, minute) - - def get_time(self) -> tuple: - hour = self.native_instance.getHour() - minute = self.native_instance.getMinute() - return hour, minute - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import time - - from rubicon.objc import ObjCClass - - class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.native_instance.setDatePickerMode_(1) # Setting mode to Time - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> None: - t = time(hour, minute) - self.native_instance.setTime_(t) - - def get_time(self) -> tuple: - t = self.native_instance.time() - return t.hour, t.minute diff --git a/src/pythonnative/material_toolbar.py b/src/pythonnative/material_toolbar.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pythonnative/native_modules/__init__.py b/src/pythonnative/native_modules/__init__.py new file mode 100644 index 0000000..fbefc86 --- /dev/null +++ b/src/pythonnative/native_modules/__init__.py @@ -0,0 +1,19 @@ +"""Native API modules for device capabilities. + +Provides cross-platform Python interfaces to common device APIs: + +- :mod:`~.camera` — photo capture and gallery picking +- :mod:`~.location` — GPS / location services +- :mod:`~.file_system` — app-scoped file I/O +- :mod:`~.notifications` — local push notifications + +Each module auto-detects the platform and calls the appropriate native +APIs via Chaquopy (Android) or rubicon-objc (iOS). +""" + +from .camera import Camera +from .file_system import FileSystem +from .location import Location +from .notifications import Notifications + +__all__ = ["Camera", "FileSystem", "Location", "Notifications"] diff --git a/src/pythonnative/native_modules/camera.py b/src/pythonnative/native_modules/camera.py new file mode 100644 index 0000000..d0ae705 --- /dev/null +++ b/src/pythonnative/native_modules/camera.py @@ -0,0 +1,105 @@ +"""Cross-platform camera access. + +Provides methods for capturing photos and picking images from the gallery. +Uses Android's ``Intent``/``MediaStore`` or iOS's ``UIImagePickerController``. +""" + +from typing import Any, Callable, Optional + +from ..utils import IS_ANDROID + + +class Camera: + """Camera and image picker interface. + + All methods accept an ``on_result`` callback that receives the image + file path (or ``None`` on cancellation). + """ + + @staticmethod + def take_photo(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None: + """Launch the device camera to capture a photo. + + Parameters + ---------- + on_result: + ``(path_or_none) -> None`` called with the saved image path, + or ``None`` if the user cancelled. + """ + if IS_ANDROID: + Camera._android_take_photo(on_result, **options) + else: + Camera._ios_take_photo(on_result, **options) + + @staticmethod + def pick_from_gallery(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None: + """Open the system gallery picker. + + Parameters + ---------- + on_result: + ``(path_or_none) -> None`` called with the selected image path, + or ``None`` if the user cancelled. + """ + if IS_ANDROID: + Camera._android_pick_gallery(on_result, **options) + else: + Camera._ios_pick_gallery(on_result, **options) + + # -- Android implementations ----------------------------------------- + + @staticmethod + def _android_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from java import jclass + + Intent = jclass("android.content.Intent") + MediaStore = jclass("android.provider.MediaStore") + intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + from ..utils import get_android_context + + ctx = get_android_context() + ctx.startActivity(intent) + except Exception: + if on_result: + on_result(None) + + @staticmethod + def _android_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from java import jclass + + Intent = jclass("android.content.Intent") + intent = Intent(Intent.ACTION_PICK) + intent.setType("image/*") + from ..utils import get_android_context + + ctx = get_android_context() + ctx.startActivity(intent) + except Exception: + if on_result: + on_result(None) + + # -- iOS implementations --------------------------------------------- + + @staticmethod + def _ios_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + picker = ObjCClass("UIImagePickerController").alloc().init() + picker.setSourceType_(1) # UIImagePickerControllerSourceTypeCamera + except Exception: + if on_result: + on_result(None) + + @staticmethod + def _ios_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + picker = ObjCClass("UIImagePickerController").alloc().init() + picker.setSourceType_(0) # PhotoLibrary + except Exception: + if on_result: + on_result(None) diff --git a/src/pythonnative/native_modules/file_system.py b/src/pythonnative/native_modules/file_system.py new file mode 100644 index 0000000..8585d47 --- /dev/null +++ b/src/pythonnative/native_modules/file_system.py @@ -0,0 +1,131 @@ +"""Cross-platform file system access. + +Provides helpers for reading and writing files within the app's +sandboxed storage area. +""" + +import os +from typing import Any, Optional + +from ..utils import IS_ANDROID + + +class FileSystem: + """App-scoped file I/O.""" + + @staticmethod + def app_dir() -> str: + """Return the app's writable data directory.""" + if IS_ANDROID: + try: + from ..utils import get_android_context + + return str(get_android_context().getFilesDir().getAbsolutePath()) + except Exception: + pass + else: + try: + from rubicon.objc import ObjCClass + + NSSearchPathForDirectoriesInDomains = ObjCClass( + "NSFileManager" + ).defaultManager.URLsForDirectory_inDomains_ + docs = NSSearchPathForDirectoriesInDomains(9, 1) # NSDocumentDirectory, NSUserDomainMask + if docs and docs.count > 0: + return str(docs.objectAtIndex_(0).path) + except Exception: + pass + return os.path.join(os.path.expanduser("~"), ".pythonnative_data") + + @staticmethod + def read_text(path: str, encoding: str = "utf-8") -> Optional[str]: + """Read a text file relative to :meth:`app_dir` (or an absolute path).""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + with open(full, encoding=encoding) as f: + return f.read() + except OSError: + return None + + @staticmethod + def write_text(path: str, content: str, encoding: str = "utf-8") -> bool: + """Write a text file. Returns ``True`` on success.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w", encoding=encoding) as f: + f.write(content) + return True + except OSError: + return False + + @staticmethod + def exists(path: str) -> bool: + """Check if a file or directory exists.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + return os.path.exists(full) + + @staticmethod + def delete(path: str) -> bool: + """Delete a file. Returns ``True`` on success.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.remove(full) + return True + except OSError: + return False + + @staticmethod + def list_dir(path: str = "") -> list: + """List entries in a directory.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + return os.listdir(full) + except OSError: + return [] + + @staticmethod + def read_bytes(path: str) -> Optional[bytes]: + """Read a binary file.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + with open(full, "rb") as f: + return f.read() + except OSError: + return None + + @staticmethod + def write_bytes(path: str, data: bytes) -> bool: + """Write a binary file. Returns ``True`` on success.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "wb") as f: + f.write(data) + return True + except OSError: + return False + + @staticmethod + def get_size(path: str) -> Optional[int]: + """Return file size in bytes, or ``None`` if not found.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + return os.path.getsize(full) + except OSError: + return None + + @staticmethod + def ensure_dir(path: str) -> bool: + """Create a directory (and parents) if it doesn't exist.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.makedirs(full, exist_ok=True) + return True + except OSError: + return False + + @staticmethod + def join(*parts: Any) -> str: + """Join path components.""" + return os.path.join(*[str(p) for p in parts]) diff --git a/src/pythonnative/native_modules/location.py b/src/pythonnative/native_modules/location.py new file mode 100644 index 0000000..8f5a673 --- /dev/null +++ b/src/pythonnative/native_modules/location.py @@ -0,0 +1,61 @@ +"""Cross-platform location / GPS access. + +Provides methods for requesting the current device location. +Uses Android's ``LocationManager`` or iOS's ``CLLocationManager``. +""" + +from typing import Any, Callable, Optional, Tuple + +from ..utils import IS_ANDROID + + +class Location: + """GPS / Location services interface.""" + + @staticmethod + def get_current( + on_result: Optional[Callable[[Optional[Tuple[float, float]]], None]] = None, + **options: Any, + ) -> None: + """Request the current location. + + Parameters + ---------- + on_result: + ``((lat, lon) | None) -> None`` called with coordinates or + ``None`` if location is unavailable. + """ + if IS_ANDROID: + Location._android_get(on_result, **options) + else: + Location._ios_get(on_result, **options) + + @staticmethod + def _android_get(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from java import jclass + + from ..utils import get_android_context + + ctx = get_android_context() + lm = ctx.getSystemService(jclass("android.content.Context").LOCATION_SERVICE) + loc = lm.getLastKnownLocation("gps") + if loc and on_result: + on_result((loc.getLatitude(), loc.getLongitude())) + elif on_result: + on_result(None) + except Exception: + if on_result: + on_result(None) + + @staticmethod + def _ios_get(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + lm = ObjCClass("CLLocationManager").alloc().init() + lm.requestWhenInUseAuthorization() + lm.startUpdatingLocation() + except Exception: + if on_result: + on_result(None) diff --git a/src/pythonnative/native_modules/notifications.py b/src/pythonnative/native_modules/notifications.py new file mode 100644 index 0000000..40711ca --- /dev/null +++ b/src/pythonnative/native_modules/notifications.py @@ -0,0 +1,151 @@ +"""Cross-platform local notifications. + +Provides methods for scheduling and cancelling local push notifications. +Uses Android's ``NotificationManager`` or iOS's ``UNUserNotificationCenter``. +""" + +from typing import Any, Callable, Optional + +from ..utils import IS_ANDROID + + +class Notifications: + """Local notification interface.""" + + @staticmethod + def request_permission(on_result: Optional[Callable[[bool], None]] = None) -> None: + """Request notification permission from the user. + + Parameters + ---------- + on_result: + ``(granted: bool) -> None`` called with the permission result. + """ + if IS_ANDROID: + if on_result: + on_result(True) + else: + Notifications._ios_request_permission(on_result) + + @staticmethod + def schedule( + title: str, + body: str = "", + delay_seconds: float = 0, + identifier: str = "default", + **options: Any, + ) -> None: + """Schedule a local notification. + + Parameters + ---------- + title: + Notification title. + body: + Notification body text. + delay_seconds: + Seconds from now until delivery (0 = immediate). + identifier: + Unique ID for this notification (for cancellation). + """ + if IS_ANDROID: + Notifications._android_schedule(title, body, delay_seconds, identifier, **options) + else: + Notifications._ios_schedule(title, body, delay_seconds, identifier, **options) + + @staticmethod + def cancel(identifier: str = "default") -> None: + """Cancel a pending notification by its identifier.""" + if IS_ANDROID: + Notifications._android_cancel(identifier) + else: + Notifications._ios_cancel(identifier) + + # -- Android --------------------------------------------------------- + + @staticmethod + def _android_schedule(title: str, body: str, delay_seconds: float, identifier: str, **options: Any) -> None: + try: + from java import jclass + + from ..utils import get_android_context + + ctx = get_android_context() + nm = ctx.getSystemService(jclass("android.content.Context").NOTIFICATION_SERVICE) + channel_id = "pn_default" + NotificationChannel = jclass("android.app.NotificationChannel") + channel = NotificationChannel(channel_id, "PythonNative", 3) # IMPORTANCE_DEFAULT + nm.createNotificationChannel(channel) + + Builder = jclass("android.app.Notification$Builder") + builder = Builder(ctx, channel_id) + builder.setContentTitle(title) + builder.setContentText(body) + builder.setSmallIcon(jclass("android.R$drawable").ic_dialog_info) + nm.notify(abs(hash(identifier)) % (2**31), builder.build()) + except Exception: + pass + + @staticmethod + def _android_cancel(identifier: str) -> None: + try: + from java import jclass + + from ..utils import get_android_context + + ctx = get_android_context() + nm = ctx.getSystemService(jclass("android.content.Context").NOTIFICATION_SERVICE) + nm.cancel(abs(hash(identifier)) % (2**31)) + except Exception: + pass + + # -- iOS ------------------------------------------------------------- + + @staticmethod + def _ios_request_permission(on_result: Optional[Callable[[bool], None]] = None) -> None: + try: + from rubicon.objc import ObjCClass + + center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter() + center.requestAuthorizationWithOptions_completionHandler_(0x07, None) + if on_result: + on_result(True) + except Exception: + if on_result: + on_result(False) + + @staticmethod + def _ios_schedule(title: str, body: str, delay_seconds: float, identifier: str, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + content = ObjCClass("UNMutableNotificationContent").alloc().init() + content.setTitle_(title) + content.setBody_(body) + + if delay_seconds > 0: + trigger = ObjCClass("UNTimeIntervalNotificationTrigger").triggerWithTimeInterval_repeats_( + delay_seconds, False + ) + else: + trigger = ObjCClass("UNTimeIntervalNotificationTrigger").triggerWithTimeInterval_repeats_(1, False) + + request = ObjCClass("UNNotificationRequest").requestWithIdentifier_content_trigger_( + identifier, content, trigger + ) + center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter() + center.addNotificationRequest_withCompletionHandler_(request, None) + except Exception: + pass + + @staticmethod + def _ios_cancel(identifier: str) -> None: + try: + from rubicon.objc import ObjCClass + + center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter() + NSArray = ObjCClass("NSArray") + arr = NSArray.arrayWithObject_(identifier) + center.removePendingNotificationRequestsWithIdentifiers_(arr) + except Exception: + pass diff --git a/src/pythonnative/native_views/__init__.py b/src/pythonnative/native_views/__init__.py new file mode 100644 index 0000000..2630f36 --- /dev/null +++ b/src/pythonnative/native_views/__init__.py @@ -0,0 +1,87 @@ +"""Platform-specific native view creation and update logic. + +This package provides the :class:`NativeViewRegistry` that maps element type +names to platform-specific :class:`~.base.ViewHandler` implementations. + +Platform handlers live in dedicated submodules: + +- :mod:`~.base` — shared :class:`~.base.ViewHandler` protocol and utilities +- :mod:`~.android` — Android handlers (Chaquopy / Java bridge) +- :mod:`~.ios` — iOS handlers (rubicon-objc) + +All platform-branching is handled at registration time via lazy imports, +so the package can be imported on any platform for testing. +""" + +from typing import Any, Dict, Optional + +from .base import ViewHandler + + +class NativeViewRegistry: + """Maps element type names to platform-specific :class:`ViewHandler` instances.""" + + def __init__(self) -> None: + self._handlers: Dict[str, ViewHandler] = {} + + def register(self, type_name: str, handler: ViewHandler) -> None: + self._handlers[type_name] = handler + + def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: + handler = self._handlers.get(type_name) + if handler is None: + raise ValueError(f"Unknown element type: {type_name!r}") + return handler.create(props) + + def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: + handler = self._handlers.get(type_name) + if handler is not None: + handler.update(native_view, changed_props) + + def add_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.add_child(parent, child) + + def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.remove_child(parent, child) + + def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.insert_child(parent, child, index) + + +# ====================================================================== +# Singleton registry +# ====================================================================== + +_registry: Optional[NativeViewRegistry] = None + + +def get_registry() -> NativeViewRegistry: + """Return the singleton registry, lazily creating platform handlers.""" + global _registry + if _registry is not None: + return _registry + _registry = NativeViewRegistry() + + from ..utils import IS_ANDROID + + if IS_ANDROID: + from .android import register_handlers + + register_handlers(_registry) + else: + from .ios import register_handlers + + register_handlers(_registry) + return _registry + + +def set_registry(registry: NativeViewRegistry) -> None: + """Inject a custom or mock registry (primarily for testing).""" + global _registry + _registry = registry diff --git a/src/pythonnative/native_views/android.py b/src/pythonnative/native_views/android.py new file mode 100644 index 0000000..d36ae81 --- /dev/null +++ b/src/pythonnative/native_views/android.py @@ -0,0 +1,832 @@ +"""Android native view handlers (Chaquopy / Java bridge). + +Each handler class maps a PythonNative element type to an Android widget, +implementing view creation, property updates, and child management. + +This module is only imported on Android at runtime; desktop tests inject +a mock registry via :func:`~.set_registry` and never trigger this import. +""" + +from typing import Any, Callable, Dict + +from java import dynamic_proxy, jclass + +from ..utils import get_android_context +from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def _ctx() -> Any: + return get_android_context() + + +def _density() -> float: + return float(_ctx().getResources().getDisplayMetrics().density) + + +def _dp(value: float) -> int: + return int(value * _density()) + + +def _apply_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout properties (child-level flex props) to an Android view.""" + lp = view.getLayoutParams() + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + Gravity = jclass("android.view.Gravity") + needs_set = False + + if lp is None: + lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT) + needs_set = True + + if "width" in props and props["width"] is not None: + lp.width = _dp(float(props["width"])) + needs_set = True + if "height" in props and props["height"] is not None: + lp.height = _dp(float(props["height"])) + needs_set = True + + flex = props.get("flex") + flex_grow = props.get("flex_grow") + weight = None + if flex is not None: + weight = float(flex) + elif flex_grow is not None: + weight = float(flex_grow) + if weight is not None: + try: + lp.weight = weight + needs_set = True + except Exception: + pass + + if "margin" in props and props["margin"] is not None: + left, top, right, bottom = resolve_padding(props["margin"]) + try: + lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom)) + needs_set = True + except Exception: + pass + + if "align_self" in props and props["align_self"] is not None: + align_map = { + "flex_start": Gravity.START | Gravity.TOP, + "leading": Gravity.START | Gravity.TOP, + "center": Gravity.CENTER, + "flex_end": Gravity.END | Gravity.BOTTOM, + "trailing": Gravity.END | Gravity.BOTTOM, + "stretch": Gravity.FILL, + } + g = align_map.get(props["align_self"]) + if g is not None: + lp.gravity = g + needs_set = True + + if needs_set: + view.setLayoutParams(lp) + + if "min_width" in props and props["min_width"] is not None: + view.setMinimumWidth(_dp(float(props["min_width"]))) + if "min_height" in props and props["min_height"] is not None: + view.setMinimumHeight(_dp(float(props["min_height"]))) + + +def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None: + """Apply visual properties shared across many handlers.""" + if "background_color" in props and props["background_color"] is not None: + view.setBackgroundColor(parse_color_int(props["background_color"])) + if "overflow" in props: + clip = props["overflow"] == "hidden" + try: + view.setClipChildren(clip) + view.setClipToPadding(clip) + except Exception: + pass + + +def _apply_flex_container(container: Any, props: Dict[str, Any]) -> None: + """Apply flex container properties to a LinearLayout. + + Handles spacing, padding, alignment, justification, background, and overflow. + """ + LinearLayout = jclass("android.widget.LinearLayout") + Gravity = jclass("android.view.Gravity") + + if "flex_direction" in props: + vertical = is_vertical(props["flex_direction"]) + container.setOrientation(LinearLayout.VERTICAL if vertical else LinearLayout.HORIZONTAL) + + direction = props.get("flex_direction", "column") + vertical = is_vertical(direction) + + if "spacing" in props and props["spacing"]: + px = _dp(float(props["spacing"])) + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + d = GradientDrawable() + d.setColor(0x00000000) + d.setSize(1 if vertical else px, px if vertical else 1) + container.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE) + container.setDividerDrawable(d) + + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + container.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + + gravity = 0 + ai = props.get("align_items") or props.get("alignment") + if ai: + if vertical: + cross_map = { + "stretch": Gravity.FILL_HORIZONTAL, + "fill": Gravity.FILL_HORIZONTAL, + "flex_start": Gravity.START, + "leading": Gravity.START, + "start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, + "trailing": Gravity.END, + "end": Gravity.END, + } + else: + cross_map = { + "stretch": Gravity.FILL_VERTICAL, + "fill": Gravity.FILL_VERTICAL, + "flex_start": Gravity.TOP, + "top": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, + "bottom": Gravity.BOTTOM, + } + gravity |= cross_map.get(ai, 0) + + jc = props.get("justify_content") + if jc: + if vertical: + main_map = { + "flex_start": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, + } + else: + main_map = { + "flex_start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, + } + gravity |= main_map.get(jc, 0) + + if gravity: + container.setGravity(gravity) + + _apply_common_visual(container, props) + + +# ====================================================================== +# Flex container handler (shared by Column, Row, View) +# ====================================================================== + + +class FlexContainerHandler(ViewHandler): + """Unified handler for flex layout containers (Column, Row, View). + + All three element types use ``LinearLayout`` with orientation + determined by the ``flex_direction`` prop. + """ + + def create(self, props: Dict[str, Any]) -> Any: + ll = jclass("android.widget.LinearLayout")(_ctx()) + direction = props.get("flex_direction", "column") + LinearLayout = jclass("android.widget.LinearLayout") + ll.setOrientation(LinearLayout.VERTICAL if is_vertical(direction) else LinearLayout.HORIZONTAL) + _apply_flex_container(ll, props) + _apply_layout(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if changed.keys() & CONTAINER_KEYS: + _apply_flex_container(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + +# ====================================================================== +# Leaf handlers +# ====================================================================== + + +class TextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tv = jclass("android.widget.TextView")(_ctx()) + self._apply(tv, props) + _apply_layout(tv, props) + return tv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, tv: Any, props: Dict[str, Any]) -> None: + if "text" in props: + tv.setText(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + tv.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + tv.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tv.setBackgroundColor(parse_color_int(props["background_color"])) + if "bold" in props and props["bold"]: + tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 + if "max_lines" in props and props["max_lines"] is not None: + tv.setMaxLines(int(props["max_lines"])) + if "text_align" in props: + Gravity = jclass("android.view.Gravity") + mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} + tv.setGravity(mapping.get(props["text_align"], Gravity.START)) + + +class ButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = jclass("android.widget.Button")(_ctx()) + self._apply(btn, props) + _apply_layout(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setText(str(props["title"])) + if "font_size" in props and props["font_size"] is not None: + btn.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + btn.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor(parse_color_int(props["background_color"])) + if "enabled" in props: + btn.setEnabled(bool(props["enabled"])) + if "on_click" in props: + cb = props["on_click"] + if cb is not None: + + class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(ClickProxy(cb)) + else: + btn.setOnClickListener(None) + + +class ScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = jclass("android.widget.ScrollView")(_ctx()) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor(parse_color_int(props["background_color"])) + _apply_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +class TextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + et = jclass("android.widget.EditText")(_ctx()) + self._apply(et, props) + _apply_layout(et, props) + return et + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, et: Any, props: Dict[str, Any]) -> None: + if "value" in props: + et.setText(str(props["value"])) + if "placeholder" in props: + et.setHint(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + et.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + et.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + et.setBackgroundColor(parse_color_int(props["background_color"])) + if "secure" in props and props["secure"]: + InputType = jclass("android.text.InputType") + et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + if "on_change" in props: + cb = props["on_change"] + if cb is not None: + TextWatcher = jclass("android.text.TextWatcher") + + class ChangeProxy(dynamic_proxy(TextWatcher)): + def __init__(self, callback: Callable[[str], None]) -> None: + super().__init__() + self.callback = callback + + def afterTextChanged(self, s: Any) -> None: + self.callback(str(s)) + + def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None: + pass + + def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None: + pass + + et.addTextChangedListener(ChangeProxy(cb)) + + +class ImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = jclass("android.widget.ImageView")(_ctx()) + self._apply(iv, props) + _apply_layout(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor(parse_color_int(props["background_color"])) + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + ScaleType = jclass("android.widget.ImageView$ScaleType") + mapping = { + "cover": ScaleType.CENTER_CROP, + "contain": ScaleType.FIT_CENTER, + "stretch": ScaleType.FIT_XY, + "center": ScaleType.CENTER, + } + st = mapping.get(props["scale_type"]) + if st: + iv.setScaleType(st) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + Thread = jclass("java.lang.Thread") + Runnable = jclass("java.lang.Runnable") + URL = jclass("java.net.URL") + BitmapFactory = jclass("android.graphics.BitmapFactory") + Handler = jclass("android.os.Handler") + Looper = jclass("android.os.Looper") + handler = Handler(Looper.getMainLooper()) + + class LoadTask(dynamic_proxy(Runnable)): + def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None: + super().__init__() + self.image_view = image_view + self.url_str = url_str + self.main_handler = main_handler + + def run(self) -> None: + try: + url = URL(self.url_str) + stream = url.openStream() + bitmap = BitmapFactory.decodeStream(stream) + stream.close() + + class SetImage(dynamic_proxy(Runnable)): + def __init__(self, view: Any, bmp: Any) -> None: + super().__init__() + self.view = view + self.bmp = bmp + + def run(self) -> None: + self.view.setImageBitmap(self.bmp) + + self.main_handler.post(SetImage(self.image_view, bitmap)) + except Exception: + pass + + Thread(LoadTask(iv, source, handler)).start() + else: + ctx = _ctx() + res = ctx.getResources() + pkg = ctx.getPackageName() + res_name = source.rsplit(".", 1)[0] if "." in source else source + res_id = res.getIdentifier(res_name, "drawable", pkg) + if res_id != 0: + iv.setImageResource(res_id) + except Exception: + pass + + +class SwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = jclass("android.widget.Switch")(_ctx()) + self._apply(sw, props) + _apply_layout(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setChecked(bool(props["value"])) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): + def __init__(self, callback: Callable[[bool], None]) -> None: + super().__init__() + self.callback = callback + + def onCheckedChanged(self, button: Any, checked: bool) -> None: + self.callback(checked) + + sw.setOnCheckedChangeListener(CheckedProxy(cb)) + + +class ProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + style = jclass("android.R$attr").progressBarStyleHorizontal + pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) + pb.setMax(1000) + self._apply(pb, props) + _apply_layout(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, pb: Any, props: Dict[str, Any]) -> None: + if "value" in props: + pb.setProgress(int(float(props["value"]) * 1000)) + + +class ActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pb = jclass("android.widget.ProgressBar")(_ctx()) + if not props.get("animating", True): + pb.setVisibility(jclass("android.view.View").GONE) + _apply_layout(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + View = jclass("android.view.View") + if "animating" in changed: + native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) + + +class WebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = jclass("android.webkit.WebView")(_ctx()) + if "url" in props and props["url"]: + wv.loadUrl(str(props["url"])) + _apply_layout(wv, props) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + native_view.loadUrl(str(changed["url"])) + + +class SpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = jclass("android.view.View")(_ctx()) + if "size" in props and props["size"] is not None: + px = _dp(float(props["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + v.setLayoutParams(lp) + if "flex" in props and props["flex"] is not None: + lp = v.getLayoutParams() + if lp is None: + lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0) + lp.weight = float(props["flex"]) + v.setLayoutParams(lp) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + px = _dp(float(changed["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + native_view.setLayoutParams(lp) + + +class SafeAreaViewHandler(ViewHandler): + """Safe-area container using FrameLayout with ``fitsSystemWindows``.""" + + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setFitsSystemWindows(True) + if "background_color" in props and props["background_color"] is not None: + fl.setBackgroundColor(parse_color_int(props["background_color"])) + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +class ModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + placeholder = jclass("android.view.View")(_ctx()) + placeholder.setVisibility(jclass("android.view.View").GONE) + return placeholder + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + pass + + +class SliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sb = jclass("android.widget.SeekBar")(_ctx()) + sb.setMax(1000) + self._apply(sb, props) + _apply_layout(sb, props) + return sb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sb: Any, props: Dict[str, Any]) -> None: + min_val = float(props.get("min_value", 0)) + max_val = float(props.get("max_value", 1)) + rng = max_val - min_val if max_val != min_val else 1 + if "value" in props: + normalized = (float(props["value"]) - min_val) / rng + sb.setProgress(int(normalized * 1000)) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)): + def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None: + super().__init__() + self.callback = callback + self.mn = mn + self.rn = rn + + def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None: + if fromUser: + self.callback(self.mn + (progress / 1000.0) * self.rn) + + def onStartTrackingTouch(self, seekBar: Any) -> None: + pass + + def onStopTrackingTouch(self, seekBar: Any) -> None: + pass + + sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) + + +_android_tabbar_state: dict = {"callback": None, "items": []} + + +class TabBarHandler(ViewHandler): + """Native tab bar using ``BottomNavigationView`` from Material Components. + + Falls back to a horizontal ``LinearLayout`` with ``Button`` children + when Material Components is unavailable. + """ + + _is_material: bool = True + + def create(self, props: Dict[str, Any]) -> Any: + try: + bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx()) + bnv.setBackgroundColor(parse_color_int("#FFFFFF")) + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + lp = LayoutParams(ViewGroupLP.MATCH_PARENT, ViewGroupLP.WRAP_CONTENT) + bnv.setLayoutParams(lp) + self._is_material = True + self._apply_full(bnv, props) + return bnv + except Exception: + self._is_material = False + return self._create_fallback(props) + + def _create_fallback(self, props: Dict[str, Any]) -> Any: + """Horizontal LinearLayout with Button children as a tab-bar fallback.""" + LinearLayout = jclass("android.widget.LinearLayout") + ll = LinearLayout(_ctx()) + ll.setOrientation(LinearLayout.HORIZONTAL) + ll.setBackgroundColor(parse_color_int("#F8F8F8")) + self._apply_fallback(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if self._is_material: + self._apply_partial(native_view, changed) + else: + self._apply_fallback(native_view, changed) + + def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None: + """Initial creation — all props are present.""" + items = props.get("items", []) + self._set_menu(bnv, items) + self._set_active(bnv, props.get("active_tab"), items) + cb = props.get("on_tab_select") + if cb is not None: + self._set_listener(bnv, cb, items) + + def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None: + """Reconciler update — only changed props are present.""" + prev_items = _android_tabbar_state["items"] + + if "items" in changed: + items = changed["items"] + self._set_menu(bnv, items) + else: + items = prev_items + + if "active_tab" in changed: + self._set_active(bnv, changed["active_tab"], items) + + if "on_tab_select" in changed: + cb = changed["on_tab_select"] + if cb is not None: + self._set_listener(bnv, cb, items) + + def _set_menu(self, bnv: Any, items: list) -> None: + _android_tabbar_state["items"] = items + try: + menu = bnv.getMenu() + menu.clear() + for i, item in enumerate(items): + title = item.get("title", item.get("name", "")) + menu.add(0, i, i, str(title)) + except Exception: + pass + + def _set_active(self, bnv: Any, active: Any, items: list) -> None: + if active and items: + for i, item in enumerate(items): + if item.get("name") == active: + try: + bnv.setSelectedItemId(i) + except Exception: + pass + break + + def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None: + _android_tabbar_state["callback"] = cb + _android_tabbar_state["items"] = items + try: + listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener") + + class _TabSelectProxy(dynamic_proxy(listener_cls)): + def __init__(self, callback: Callable, tab_items: list) -> None: + super().__init__() + self.callback = callback + self.tab_items = tab_items + + def onNavigationItemSelected(self, menu_item: Any) -> bool: + idx = menu_item.getItemId() + if 0 <= idx < len(self.tab_items): + self.callback(self.tab_items[idx].get("name", "")) + return True + + bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items)) + except Exception: + pass + + def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None: + items = props.get("items", []) + active = props.get("active_tab") + cb = props.get("on_tab_select") + if "items" in props: + ll.removeAllViews() + for item in items: + name = item.get("name", "") + title = item.get("title", name) + btn = jclass("android.widget.Button")(_ctx()) + btn.setText(str(title)) + btn.setEnabled(name != active) + if cb is not None: + tab_name = name + + def _make_click(n: str) -> Callable[[], None]: + return lambda: cb(n) + + class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(_ClickProxy(_make_click(tab_name))) + ll.addView(btn) + + +class PressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setClickable(True) + self._apply(fl, props) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, fl: Any, props: Dict[str, Any]) -> None: + if "on_press" in props and props["on_press"] is not None: + cb = props["on_press"] + + class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + fl.setOnClickListener(PressProxy(cb)) + if "on_long_press" in props and props["on_long_press"] is not None: + cb = props["on_long_press"] + + class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onLongClick(self, view: Any) -> bool: + self.callback() + return True + + fl.setOnLongClickListener(LongPressProxy(cb)) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +# ====================================================================== +# Registration +# ====================================================================== + + +def register_handlers(registry: Any) -> None: + """Register all Android view handlers with the given registry.""" + flex = FlexContainerHandler() + registry.register("Text", TextHandler()) + registry.register("Button", ButtonHandler()) + registry.register("Column", flex) + registry.register("Row", flex) + registry.register("View", flex) + registry.register("ScrollView", ScrollViewHandler()) + registry.register("TextInput", TextInputHandler()) + registry.register("Image", ImageHandler()) + registry.register("Switch", SwitchHandler()) + registry.register("ProgressBar", ProgressBarHandler()) + registry.register("ActivityIndicator", ActivityIndicatorHandler()) + registry.register("WebView", WebViewHandler()) + registry.register("Spacer", SpacerHandler()) + registry.register("SafeAreaView", SafeAreaViewHandler()) + registry.register("Modal", ModalHandler()) + registry.register("Slider", SliderHandler()) + registry.register("TabBar", TabBarHandler()) + registry.register("Pressable", PressableHandler()) diff --git a/src/pythonnative/native_views/base.py b/src/pythonnative/native_views/base.py new file mode 100644 index 0000000..e644968 --- /dev/null +++ b/src/pythonnative/native_views/base.py @@ -0,0 +1,150 @@ +"""Shared base classes and utilities for native view handlers. + +Provides the :class:`ViewHandler` abstract base class and common helper +functions used by both Android and iOS platform implementations. +""" + +from typing import Any, Dict, Union + + +class ViewHandler: + """Protocol for creating, updating, and managing children of a native view type.""" + + def create(self, props: Dict[str, Any]) -> Any: + raise NotImplementedError + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + raise NotImplementedError + + def add_child(self, parent: Any, child: Any) -> None: + pass + + def remove_child(self, parent: Any, child: Any) -> None: + pass + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + self.add_child(parent, child) + + +# ====================================================================== +# Color parsing +# ====================================================================== + + +def parse_color_int(color: Union[str, int]) -> int: + """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. + + Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values + with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. + """ + if isinstance(color, int): + val = color + else: + c = color.strip().lstrip("#") + if len(c) == 6: + c = "FF" + c + val = int(c, 16) + if val > 0x7FFFFFFF: + val -= 0x100000000 + return val + + +# ====================================================================== +# Padding / margin helpers +# ====================================================================== + + +def resolve_padding(padding: Any) -> tuple: + """Normalise various padding representations to ``(left, top, right, bottom)``.""" + if padding is None: + return (0, 0, 0, 0) + if isinstance(padding, (int, float)): + v = int(padding) + return (v, v, v, v) + if isinstance(padding, dict): + h = int(padding.get("horizontal", 0)) + v = int(padding.get("vertical", 0)) + left = int(padding.get("left", h)) + right = int(padding.get("right", h)) + top = int(padding.get("top", v)) + bottom = int(padding.get("bottom", v)) + a = int(padding.get("all", 0)) + if a: + left = left or a + right = right or a + top = top or a + bottom = bottom or a + return (left, top, right, bottom) + return (0, 0, 0, 0) + + +# ====================================================================== +# Flex layout constants +# ====================================================================== + +FLEX_DIRECTION_COLUMN = "column" +FLEX_DIRECTION_ROW = "row" +FLEX_DIRECTION_COLUMN_REVERSE = "column_reverse" +FLEX_DIRECTION_ROW_REVERSE = "row_reverse" + +JUSTIFY_FLEX_START = "flex_start" +JUSTIFY_CENTER = "center" +JUSTIFY_FLEX_END = "flex_end" +JUSTIFY_SPACE_BETWEEN = "space_between" +JUSTIFY_SPACE_AROUND = "space_around" +JUSTIFY_SPACE_EVENLY = "space_evenly" + +ALIGN_STRETCH = "stretch" +ALIGN_FLEX_START = "flex_start" +ALIGN_CENTER = "center" +ALIGN_FLEX_END = "flex_end" + +POSITION_RELATIVE = "relative" +POSITION_ABSOLUTE = "absolute" + +OVERFLOW_VISIBLE = "visible" +OVERFLOW_HIDDEN = "hidden" +OVERFLOW_SCROLL = "scroll" + + +def is_vertical(direction: str) -> bool: + """Return ``True`` if *direction* represents a vertical (column) axis.""" + return direction in (FLEX_DIRECTION_COLUMN, FLEX_DIRECTION_COLUMN_REVERSE) + + +# ====================================================================== +# Layout property keys +# ====================================================================== + +LAYOUT_KEYS = frozenset( + { + "width", + "height", + "flex", + "flex_grow", + "flex_shrink", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + "position", + "top", + "right", + "bottom", + "left", + } +) + +CONTAINER_KEYS = frozenset( + { + "flex_direction", + "justify_content", + "align_items", + "overflow", + "spacing", + "padding", + "background_color", + } +) diff --git a/src/pythonnative/native_views/ios.py b/src/pythonnative/native_views/ios.py new file mode 100644 index 0000000..95fa0b2 --- /dev/null +++ b/src/pythonnative/native_views/ios.py @@ -0,0 +1,777 @@ +"""iOS native view handlers (rubicon-objc). + +Each handler class maps a PythonNative element type to a UIKit widget, +implementing view creation, property updates, and child management. + +This module is only imported on iOS at runtime; desktop tests inject +a mock registry via :func:`~.set_registry` and never trigger this import. +""" + +import ctypes as _ct +from typing import Any, Callable, Dict, Optional + +from rubicon.objc import SEL, ObjCClass, objc_method + +from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding + +NSObject = ObjCClass("NSObject") +UIColor = ObjCClass("UIColor") +UIFont = ObjCClass("UIFont") + + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def _uicolor(color: Any) -> Any: + """Convert a color value to a ``UIColor`` instance.""" + argb = parse_color_int(color) + if argb < 0: + argb += 0x100000000 + a = ((argb >> 24) & 0xFF) / 255.0 + r = ((argb >> 16) & 0xFF) / 255.0 + g = ((argb >> 8) & 0xFF) / 255.0 + b = (argb & 0xFF) / 255.0 + return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + + +def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout constraints to an iOS view.""" + if "width" in props and props["width"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 7: # NSLayoutAttributeWidth + c.setActive_(False) + view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True) + except Exception: + pass + if "height" in props and props["height"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 8: # NSLayoutAttributeHeight + c.setActive_(False) + view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) + except Exception: + pass + if "min_width" in props and props["min_width"] is not None: + try: + view.widthAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_width"])).setActive_(True) + except Exception: + pass + if "min_height" in props and props["min_height"] is not None: + try: + view.heightAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_height"])).setActive_(True) + except Exception: + pass + + +def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None: + """Apply visual properties shared across many handlers.""" + if "background_color" in props and props["background_color"] is not None: + view.setBackgroundColor_(_uicolor(props["background_color"])) + if "overflow" in props: + view.setClipsToBounds_(props["overflow"] == "hidden") + + +def _apply_flex_container(sv: Any, props: Dict[str, Any]) -> None: + """Apply flex container properties to a UIStackView. + + Handles axis, spacing, alignment, distribution, background, padding, and overflow. + """ + if "flex_direction" in props: + vertical = is_vertical(props["flex_direction"]) + sv.setAxis_(1 if vertical else 0) + + if "spacing" in props and props["spacing"]: + sv.setSpacing_(float(props["spacing"])) + + ai = props.get("align_items") or props.get("alignment") + if ai: + direction = props.get("flex_direction") + vertical = is_vertical(direction) if direction else bool(sv.axis()) + if vertical: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "leading": 1, + "center": 3, + "flex_end": 4, + "trailing": 4, + } + else: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "top": 1, + "center": 3, + "flex_end": 4, + "bottom": 4, + } + sv.setAlignment_(alignment_map.get(ai, 0)) + + jc = props.get("justify_content") + if jc: + # UIStackViewDistribution: + # 0 = fill, 1 = fillEqually, 2 = fillProportionally, + # 3 = equalSpacing (≈ space_between), 4 = equalCentering (≈ space_evenly) + distribution_map = { + "flex_start": 0, + "center": 0, + "flex_end": 0, + "space_between": 3, + "space_around": 4, + "space_evenly": 4, + } + sv.setDistribution_(distribution_map.get(jc, 0)) + + _apply_common_visual(sv, props) + + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + sv.setLayoutMarginsRelativeArrangement_(True) + try: + sv.setDirectionalLayoutMargins_((top, left, bottom, right)) + except Exception: + sv.setLayoutMargins_((top, left, bottom, right)) + + +# ====================================================================== +# ObjC callback targets (retained at module level) +# ====================================================================== + +_pn_btn_handler_map: dict = {} +_pn_retained_views: list = [] + + +class _PNButtonTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[], None]] = None + + @objc_method + def onTap_(self, sender: object) -> None: + if self._callback is not None: + self._callback() + + +_pn_tf_handler_map: dict = {} + + +class _PNTextFieldTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[str], None]] = None + + @objc_method + def onEdit_(self, sender: object) -> None: + if self._callback is not None: + try: + text = str(sender.text) if sender and hasattr(sender, "text") else "" + self._callback(text) + except Exception: + pass + + +_pn_switch_handler_map: dict = {} + + +class _PNSwitchTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[bool], None]] = None + + @objc_method + def onToggle_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(bool(sender.isOn())) + except Exception: + pass + + +_pn_slider_handler_map: dict = {} + + +class _PNSliderTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[float], None]] = None + + @objc_method + def onSlide_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(float(sender.value)) + except Exception: + pass + + +# ====================================================================== +# Flex container handler (shared by Column, Row, View) +# ====================================================================== + + +class FlexContainerHandler(ViewHandler): + """Unified handler for flex layout containers (Column, Row, View). + + All three element types use ``UIStackView`` with axis determined + by the ``flex_direction`` prop. + """ + + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) + direction = props.get("flex_direction", "column") + sv.setAxis_(1 if is_vertical(direction) else 0) + _apply_flex_container(sv, props) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if changed.keys() & CONTAINER_KEYS: + _apply_flex_container(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + +# ====================================================================== +# Leaf handlers +# ====================================================================== + + +class TextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + label = ObjCClass("UILabel").alloc().init() + self._apply(label, props) + _apply_ios_layout(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + if "text" in props: + label.setText_(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + if props.get("bold"): + label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) + else: + label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + elif "bold" in props and props["bold"]: + size = label.font().pointSize() if label.font() else 17.0 + label.setFont_(UIFont.boldSystemFontOfSize_(size)) + if "color" in props and props["color"] is not None: + label.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + label.setBackgroundColor_(_uicolor(props["background_color"])) + if "max_lines" in props and props["max_lines"] is not None: + label.setNumberOfLines_(int(props["max_lines"])) + if "text_align" in props: + mapping = {"left": 0, "center": 1, "right": 2} + label.setTextAlignment_(mapping.get(props["text_align"], 0)) + + +class ButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = ObjCClass("UIButton").alloc().init() + btn.retain() + _pn_retained_views.append(btn) + _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) + btn.setTitleColor_forState_(_ios_blue, 0) + self._apply(btn, props) + _apply_ios_layout(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setTitle_forState_(str(props["title"]), 0) + if "font_size" in props and props["font_size"] is not None: + btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor_(_uicolor(props["background_color"])) + if "color" not in props: + _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) + btn.setTitleColor_forState_(_white, 0) + if "color" in props and props["color"] is not None: + btn.setTitleColor_forState_(_uicolor(props["color"]), 0) + if "enabled" in props: + btn.setEnabled_(bool(props["enabled"])) + if "on_click" in props: + existing = _pn_btn_handler_map.get(id(btn)) + if existing is not None: + existing._callback = props["on_click"] + else: + handler = _PNButtonTarget.new() + handler._callback = props["on_click"] + _pn_btn_handler_map[id(btn)] = handler + btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) + + +class ScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIScrollView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + child.setTranslatesAutoresizingMaskIntoConstraints_(False) + parent.addSubview_(child) + content_guide = parent.contentLayoutGuide + frame_guide = parent.frameLayoutGuide + child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) + child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) + child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) + child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) + child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +class TextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tf = ObjCClass("UITextField").alloc().init() + tf.setBorderStyle_(2) # RoundedRect + self._apply(tf, props) + _apply_ios_layout(tf, props) + return tf + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, tf: Any, props: Dict[str, Any]) -> None: + if "value" in props: + tf.setText_(str(props["value"])) + if "placeholder" in props: + tf.setPlaceholder_(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "color" in props and props["color"] is not None: + tf.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tf.setBackgroundColor_(_uicolor(props["background_color"])) + if "secure" in props and props["secure"]: + tf.setSecureTextEntry_(True) + if "on_change" in props: + existing = _pn_tf_handler_map.get(id(tf)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNTextFieldTarget.new() + handler._callback = props["on_change"] + _pn_tf_handler_map[id(tf)] = handler + tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17) + + +class ImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = ObjCClass("UIImageView").alloc().init() + self._apply(iv, props) + _apply_ios_layout(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor_(_uicolor(props["background_color"])) + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4} + iv.setContentMode_(mapping.get(props["scale_type"], 1)) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + NSURL = ObjCClass("NSURL") + NSData = ObjCClass("NSData") + UIImage = ObjCClass("UIImage") + url = NSURL.URLWithString_(source) + data = NSData.dataWithContentsOfURL_(url) + if data: + image = UIImage.imageWithData_(data) + if image: + iv.setImage_(image) + else: + UIImage = ObjCClass("UIImage") + image = UIImage.imageNamed_(source) + if image: + iv.setImage_(image) + except Exception: + pass + + +class SwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = ObjCClass("UISwitch").alloc().init() + self._apply(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setOn_animated_(bool(props["value"]), False) + if "on_change" in props: + existing = _pn_switch_handler_map.get(id(sw)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSwitchTarget.new() + handler._callback = props["on_change"] + _pn_switch_handler_map[id(sw)] = handler + sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12) + + +class ProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pv = ObjCClass("UIProgressView").alloc().init() + if "value" in props: + pv.setProgress_(float(props["value"])) + _apply_ios_layout(pv, props) + return pv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "value" in changed: + native_view.setProgress_(float(changed["value"])) + + +class ActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ai = ObjCClass("UIActivityIndicatorView").alloc().init() + if props.get("animating", True): + ai.startAnimating() + return ai + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "animating" in changed: + if changed["animating"]: + native_view.startAnimating() + else: + native_view.stopAnimating() + + +class WebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = ObjCClass("WKWebView").alloc().init() + if "url" in props and props["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(props["url"])) + wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + _apply_ios_layout(wv, props) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(changed["url"])) + native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + + +class SpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "size" in props and props["size"] is not None: + size = float(props["size"]) + v.setFrame_(((0, 0), (size, size))) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + size = float(changed["size"]) + native_view.setFrame_(((0, 0), (size, size))) + + +class SafeAreaViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + v.setBackgroundColor_(_uicolor(props["background_color"])) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +class ModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setHidden_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + +class SliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sl = ObjCClass("UISlider").alloc().init() + self._apply(sl, props) + _apply_ios_layout(sl, props) + return sl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sl: Any, props: Dict[str, Any]) -> None: + if "min_value" in props: + sl.setMinimumValue_(float(props["min_value"])) + if "max_value" in props: + sl.setMaximumValue_(float(props["max_value"])) + if "value" in props: + sl.setValue_(float(props["value"])) + if "on_change" in props: + existing = _pn_slider_handler_map.get(id(sl)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSliderTarget.new() + handler._callback = props["on_change"] + _pn_slider_handler_map[id(sl)] = handler + sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) + + +_pn_tabbar_state: dict = {"callback": None, "items": []} +_pn_tabbar_delegate_installed: bool = False +_pn_tabbar_delegate_ptr: Any = None + +# --------------------------------------------------------------------------- +# UITabBar delegate via raw ctypes +# +# rubicon-objc's @objc_method crashes (SIGSEGV in PyObject_GetAttr) when +# UIKit invokes the delegate through the FFI closure — the reconstructed +# Python wrappers for ``self`` or ``item`` end up with ob_type == NULL. +# +# We sidestep rubicon-objc entirely: create a minimal ObjC class with +# libobjc, register a CFUNCTYPE IMP for tabBar:didSelectItem:, and use +# objc_msgSend to read ``item.tag`` from the raw pointer. +# --------------------------------------------------------------------------- + +_libobjc = _ct.cdll.LoadLibrary("libobjc.A.dylib") + +_sel_reg = _libobjc.sel_registerName +_sel_reg.restype = _ct.c_void_p +_sel_reg.argtypes = [_ct.c_char_p] + +_get_cls = _libobjc.objc_getClass +_get_cls.restype = _ct.c_void_p +_get_cls.argtypes = [_ct.c_char_p] + +_alloc_cls = _libobjc.objc_allocateClassPair +_alloc_cls.restype = _ct.c_void_p +_alloc_cls.argtypes = [_ct.c_void_p, _ct.c_char_p, _ct.c_size_t] + +_reg_cls = _libobjc.objc_registerClassPair +_reg_cls.argtypes = [_ct.c_void_p] + +_add_method = _libobjc.class_addMethod +_add_method.restype = _ct.c_bool +_add_method.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_char_p] + +_objc_msgSend = _libobjc.objc_msgSend + +# Pre-register selectors used in the raw delegate path +_SEL_ALLOC = _sel_reg(b"alloc") +_SEL_INIT = _sel_reg(b"init") +_SEL_RETAIN = _sel_reg(b"retain") +_SEL_SET_DELEGATE = _sel_reg(b"setDelegate:") +_SEL_TAG = _sel_reg(b"tag") + +# IMP type: void (id self, SEL _cmd, id tabBar, id item) +_DELEGATE_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p) + + +def _tabbar_did_select_imp(self_ptr: int, cmd_ptr: int, tabbar_ptr: int, item_ptr: int) -> None: + """Raw C callback for ``tabBar:didSelectItem:``.""" + try: + _objc_msgSend.restype = _ct.c_long + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p] + tag: int = _objc_msgSend(item_ptr, _SEL_TAG) + + cb = _pn_tabbar_state["callback"] + tab_items = _pn_tabbar_state["items"] + if cb is not None and tab_items and 0 <= tag < len(tab_items): + cb(tab_items[tag].get("name", "")) + except Exception: + pass + + +# prevent GC of the C callback +_tabbar_imp_ref = _DELEGATE_IMP_TYPE(_tabbar_did_select_imp) + +# Create and register a minimal ObjC class for the delegate +_NS_OBJECT_CLS = _get_cls(b"NSObject") +_PN_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTabBarDelegateCTypes", 0) +if _PN_DELEGATE_CLS: + _add_method( + _PN_DELEGATE_CLS, + _sel_reg(b"tabBar:didSelectItem:"), + _ct.cast(_tabbar_imp_ref, _ct.c_void_p), + b"v@:@@", + ) + _reg_cls(_PN_DELEGATE_CLS) + + +def _ensure_tabbar_delegate(tab_bar: Any) -> None: + """Create the singleton delegate (if needed) and assign it to *tab_bar*.""" + global _pn_tabbar_delegate_ptr + if _pn_tabbar_delegate_ptr is None and _PN_DELEGATE_CLS: + _objc_msgSend.restype = _ct.c_void_p + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p] + raw = _objc_msgSend(_PN_DELEGATE_CLS, _SEL_ALLOC) + raw = _objc_msgSend(raw, _SEL_INIT) + raw = _objc_msgSend(raw, _SEL_RETAIN) + _pn_tabbar_delegate_ptr = raw + + if _pn_tabbar_delegate_ptr is not None: + _objc_msgSend.restype = None + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p] + tab_bar_ptr = tab_bar.ptr if hasattr(tab_bar, "ptr") else tab_bar + _objc_msgSend(tab_bar_ptr, _SEL_SET_DELEGATE, _pn_tabbar_delegate_ptr) + + +class TabBarHandler(ViewHandler): + """Native tab bar using ``UITabBar``. + + Each tab is a ``UITabBarItem`` with a ``tag`` matching its index + in the items list. A raw ctypes delegate forwards selection + events back to the Python ``on_tab_select`` callback. + """ + + def create(self, props: Dict[str, Any]) -> Any: + tab_bar = ObjCClass("UITabBar").alloc().initWithFrame_(((0, 0), (0, 49))) + tab_bar.retain() + _pn_retained_views.append(tab_bar) + self._apply_full(tab_bar, props) + _apply_ios_layout(tab_bar, props) + return tab_bar + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply_partial(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply_full(self, tab_bar: Any, props: Dict[str, Any]) -> None: + items = props.get("items", []) + self._set_bar_items(tab_bar, items) + self._set_active(tab_bar, props.get("active_tab"), items) + self._set_callback(tab_bar, props.get("on_tab_select"), items) + + def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None: + prev_items = _pn_tabbar_state["items"] + + if "items" in changed: + items = changed["items"] + self._set_bar_items(tab_bar, items) + else: + items = prev_items + + if "active_tab" in changed: + self._set_active(tab_bar, changed["active_tab"], items) + + if "on_tab_select" in changed: + self._set_callback(tab_bar, changed["on_tab_select"], items) + + def _set_bar_items(self, tab_bar: Any, items: list) -> None: + UITabBarItem = ObjCClass("UITabBarItem") + bar_items = [] + for i, item in enumerate(items): + title = item.get("title", item.get("name", "")) + bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), None, i) + bar_items.append(bar_item) + tab_bar.setItems_animated_(bar_items, False) + + def _set_active(self, tab_bar: Any, active: Any, items: list) -> None: + if not active or not items: + return + for i, item in enumerate(items): + if item.get("name") == active: + try: + all_items = list(tab_bar.items or []) + if i < len(all_items): + tab_bar.setSelectedItem_(all_items[i]) + except Exception: + pass + break + + def _set_callback(self, tab_bar: Any, cb: Any, items: list) -> None: + _pn_tabbar_state["callback"] = cb + _pn_tabbar_state["items"] = items + _ensure_tabbar_delegate(tab_bar) + + +class PressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setUserInteractionEnabled_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +# ====================================================================== +# Registration +# ====================================================================== + + +def register_handlers(registry: Any) -> None: + """Register all iOS view handlers with the given registry.""" + flex = FlexContainerHandler() + registry.register("Text", TextHandler()) + registry.register("Button", ButtonHandler()) + registry.register("Column", flex) + registry.register("Row", flex) + registry.register("View", flex) + registry.register("ScrollView", ScrollViewHandler()) + registry.register("TextInput", TextInputHandler()) + registry.register("Image", ImageHandler()) + registry.register("Switch", SwitchHandler()) + registry.register("ProgressBar", ProgressBarHandler()) + registry.register("ActivityIndicator", ActivityIndicatorHandler()) + registry.register("WebView", WebViewHandler()) + registry.register("Spacer", SpacerHandler()) + registry.register("SafeAreaView", SafeAreaViewHandler()) + registry.register("Modal", ModalHandler()) + registry.register("Slider", SliderHandler()) + registry.register("TabBar", TabBarHandler()) + registry.register("Pressable", PressableHandler()) diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py new file mode 100644 index 0000000..a0cd5a6 --- /dev/null +++ b/src/pythonnative/navigation.py @@ -0,0 +1,571 @@ +"""Declarative navigation for PythonNative. + +Provides a component-based navigation system inspired by React Navigation. +Navigators manage screen state in Python; they render the active screen's +component using the standard reconciler pipeline. + +Usage:: + + from pythonnative.navigation import ( + NavigationContainer, + create_stack_navigator, + create_tab_navigator, + create_drawer_navigator, + ) + + Stack = create_stack_navigator() + + @pn.component + def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + ) +""" + +from typing import Any, Callable, Dict, List, Optional + +from .element import Element +from .hooks import ( + Provider, + _NavigationContext, + component, + create_context, + use_context, + use_effect, + use_memo, + use_ref, + use_state, +) + +# ====================================================================== +# Focus context +# ====================================================================== + +_FocusContext = create_context(False) + +# ====================================================================== +# Data structures +# ====================================================================== + + +class _ScreenDef: + """Configuration for a single screen within a navigator.""" + + __slots__ = ("name", "component", "options") + + def __init__(self, name: str, component_fn: Any, options: Optional[Dict[str, Any]] = None) -> None: + self.name = name + self.component = component_fn + self.options = options or {} + + def __repr__(self) -> str: + return f"Screen({self.name!r})" + + +class _RouteEntry: + """An entry in the navigation stack.""" + + __slots__ = ("name", "params") + + def __init__(self, name: str, params: Optional[Dict[str, Any]] = None) -> None: + self.name = name + self.params = params or {} + + def __repr__(self) -> str: + return f"Route({self.name!r})" + + +# ====================================================================== +# Navigation handle for declarative navigators +# ====================================================================== + + +class _DeclarativeNavHandle: + """Navigation handle provided by declarative navigators. + + Implements the same interface as :class:`~pythonnative.hooks.NavigationHandle` + so that ``use_navigation()`` returns a compatible object regardless of + whether the app uses the legacy page-based navigation or declarative + navigators. + + When *parent* is provided, unknown routes and root-level ``go_back`` + calls are forwarded to the parent handle. This enables nested + navigators (e.g. a stack inside a tab) to delegate navigation actions + that they cannot handle locally. + """ + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + parent: Any = None, + ) -> None: + self._screen_map = screen_map + self._get_stack = get_stack + self._set_stack = set_stack + self._parent = parent + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Navigate to a named route, pushing it onto the stack. + + If *route_name* is not known locally and a parent handle exists, + the call is forwarded to the parent navigator. + """ + if route_name in self._screen_map: + entry = _RouteEntry(route_name, params) + self._set_stack(lambda s: list(s) + [entry]) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + + def go_back(self) -> None: + """Pop the current screen from the stack. + + If the stack is at its root and a parent handle exists, the call + is forwarded to the parent navigator. + """ + stack = self._get_stack() + if len(stack) > 1: + self._set_stack(lambda s: list(s[:-1])) + elif self._parent is not None: + self._parent.go_back() + + def get_params(self) -> Dict[str, Any]: + """Return the parameters for the current route.""" + stack = self._get_stack() + return stack[-1].params if stack else {} + + def reset(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Reset the stack to a single route.""" + if route_name not in self._screen_map: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + self._set_stack([_RouteEntry(route_name, params)]) + + +class _TabNavHandle(_DeclarativeNavHandle): + """Navigation handle for tab navigators with tab switching.""" + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + switch_tab: Callable[[str, Optional[Dict[str, Any]]], None], + parent: Any = None, + ) -> None: + super().__init__(screen_map, get_stack, set_stack, parent=parent) + self._switch_tab = switch_tab + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Switch to a tab by name, or forward to parent for unknown routes.""" + if route_name in self._screen_map: + self._switch_tab(route_name, params) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + + +class _DrawerNavHandle(_DeclarativeNavHandle): + """Navigation handle for drawer navigators with open/close control.""" + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + switch_screen: Callable[[str, Optional[Dict[str, Any]]], None], + set_drawer_open: Callable[[bool], None], + get_drawer_open: Callable[[], bool], + parent: Any = None, + ) -> None: + super().__init__(screen_map, get_stack, set_stack, parent=parent) + self._switch_screen = switch_screen + self._set_drawer_open = set_drawer_open + self._get_drawer_open = get_drawer_open + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Switch to a screen and close the drawer, or forward to parent.""" + if route_name in self._screen_map: + self._switch_screen(route_name, params) + self._set_drawer_open(False) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + + def open_drawer(self) -> None: + """Open the drawer.""" + self._set_drawer_open(True) + + def close_drawer(self) -> None: + """Close the drawer.""" + self._set_drawer_open(False) + + def toggle_drawer(self) -> None: + """Toggle the drawer open/closed.""" + self._set_drawer_open(not self._get_drawer_open()) + + +# ====================================================================== +# Stack navigator +# ====================================================================== + + +def _build_screen_map(screens: Any) -> Dict[str, "_ScreenDef"]: + """Build an ordered dict of name -> _ScreenDef from a list.""" + result: Dict[str, _ScreenDef] = {} + for s in screens or []: + if isinstance(s, _ScreenDef): + result[s.name] = s + return result + + +@component +def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_map = _build_screen_map(screens) + if not screen_map: + return Element("View", {}, []) + + parent_nav = use_context(_NavigationContext) + + first_route = initial_route or next(iter(screen_map)) + stack, set_stack = use_state(lambda: [_RouteEntry(first_route)]) + + stack_ref = use_ref(None) + stack_ref["current"] = stack + + handle = use_memo( + lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref["current"], set_stack, parent=parent_nav), [] + ) + handle._screen_map = screen_map + handle._parent = parent_nav + + current = stack[-1] + screen_def = screen_map.get(current.name) + if screen_def is None: + return Element("Text", {"text": f"Unknown route: {current.name}"}, []) + + screen_el = screen_def.component() + return Provider(_NavigationContext, handle, Provider(_FocusContext, True, screen_el)) + + +def create_stack_navigator() -> Any: + """Create a stack-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Stack = create_stack_navigator() + + Stack.Screen("Home", component=HomeScreen) + + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Home", + ) + """ + + class _StackNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this stack navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the stack navigator with the given screens.""" + return _stack_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _StackNavigator() + + +# ====================================================================== +# Tab navigator +# ====================================================================== + + +@component +def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_list = list(screens or []) + screen_map = _build_screen_map(screen_list) + if not screen_map: + return Element("View", {}, []) + + parent_nav = use_context(_NavigationContext) + + first_route = initial_route or screen_list[0].name + active_tab, set_active_tab = use_state(first_route) + tab_params, set_tab_params = use_state(lambda: {first_route: {}}) + + params_ref = use_ref(None) + params_ref["current"] = tab_params + + def switch_tab(name: str, params: Optional[Dict[str, Any]] = None) -> None: + set_active_tab(name) + if params: + set_tab_params(lambda p: {**p, name: params}) + + def get_stack() -> List[_RouteEntry]: + p = params_ref["current"] or {} + return [_RouteEntry(active_tab, p.get(active_tab, {}))] + + handle = use_memo(lambda: _TabNavHandle(screen_map, get_stack, lambda _: None, switch_tab, parent=parent_nav), []) + handle._screen_map = screen_map + handle._switch_tab = switch_tab + handle._parent = parent_nav + + screen_def = screen_map.get(active_tab) + if screen_def is None: + screen_def = screen_map[screen_list[0].name] + + tab_items: List[Dict[str, str]] = [] + for s in screen_list: + if isinstance(s, _ScreenDef): + tab_items.append({"name": s.name, "title": s.options.get("title", s.name)}) + + def on_tab_select(name: str) -> None: + switch_tab(name) + + tab_bar = Element( + "TabBar", + {"items": tab_items, "active_tab": active_tab, "on_tab_select": on_tab_select}, + [], + key="__tab_bar__", + ) + + screen_el = screen_def.component() + content = Provider( + _NavigationContext, + handle, + Provider(_FocusContext, True, screen_el), + ) + + return Element( + "View", + {"flex_direction": "column", "flex": 1}, + [Element("View", {"flex": 1}, [content]), tab_bar], + ) + + +def create_tab_navigator() -> Any: + """Create a tab-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Tab = create_tab_navigator() + + Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen), + ) + """ + + class _TabNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this tab navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the tab navigator with the given screens.""" + return _tab_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _TabNavigator() + + +# ====================================================================== +# Drawer navigator +# ====================================================================== + + +@component +def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_list = list(screens or []) + screen_map = _build_screen_map(screen_list) + if not screen_map: + return Element("View", {}, []) + + parent_nav = use_context(_NavigationContext) + + first_route = initial_route or screen_list[0].name + active_screen, set_active_screen = use_state(first_route) + drawer_open, set_drawer_open = use_state(False) + screen_params, set_screen_params = use_state(lambda: {first_route: {}}) + + params_ref = use_ref(None) + params_ref["current"] = screen_params + + def switch_screen(name: str, params: Optional[Dict[str, Any]] = None) -> None: + set_active_screen(name) + if params: + set_screen_params(lambda p: {**p, name: params}) + + def get_stack() -> List[_RouteEntry]: + p = params_ref["current"] or {} + return [_RouteEntry(active_screen, p.get(active_screen, {}))] + + handle = use_memo( + lambda: _DrawerNavHandle( + screen_map, + get_stack, + lambda _: None, + switch_screen, + set_drawer_open, + lambda: drawer_open, + parent=parent_nav, + ), + [], + ) + handle._screen_map = screen_map + handle._switch_screen = switch_screen + handle._set_drawer_open = set_drawer_open + handle._get_drawer_open = lambda: drawer_open + handle._parent = parent_nav + + screen_def = screen_map.get(active_screen) + if screen_def is None: + screen_def = screen_map[screen_list[0].name] + + screen_el = screen_def.component() + content = Provider( + _NavigationContext, + handle, + Provider(_FocusContext, True, screen_el), + ) + + children: List[Element] = [Element("View", {"flex": 1}, [content])] + + if drawer_open: + menu_items: List[Element] = [] + for s in screen_list: + if not isinstance(s, _ScreenDef): + continue + label = s.options.get("title", s.name) + item_name = s.name + + def make_select(n: str) -> Callable[[], None]: + def _select() -> None: + switch_screen(n) + set_drawer_open(False) + + return _select + + menu_items.append( + Element("Button", {"title": label, "on_click": make_select(item_name)}, [], key=f"__drawer_{item_name}") + ) + + drawer_panel = Element( + "View", + {"background_color": "#FFFFFF", "width": 250}, + menu_items, + ) + children.insert(0, drawer_panel) + + return Element("View", {"flex_direction": "row", "flex": 1}, children) + + +def create_drawer_navigator() -> Any: + """Create a drawer-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Drawer = create_drawer_navigator() + + Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Drawer.Screen("Settings", component=SettingsScreen), + ) + + The navigation handle returned by ``use_navigation()`` inside a drawer + navigator includes ``open_drawer()``, ``close_drawer()``, and + ``toggle_drawer()`` methods. + """ + + class _DrawerNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this drawer navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the drawer navigator with the given screens.""" + return _drawer_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _DrawerNavigator() + + +# ====================================================================== +# NavigationContainer +# ====================================================================== + + +def NavigationContainer(child: Element, *, key: Optional[str] = None) -> Element: + """Root container for the navigation tree. + + Wraps the child navigator in a full-size view. All declarative + navigators (stack, tab, drawer) should be nested inside a + ``NavigationContainer``:: + + @pn.component + def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + ) + ) + """ + return Element("View", {"flex": 1}, [child], key=key) + + +# ====================================================================== +# Hooks +# ====================================================================== + + +def use_route() -> Dict[str, Any]: + """Return the current route's parameters. + + Convenience hook that reads from the navigation context:: + + @pn.component + def DetailScreen(): + params = pn.use_route() + item_id = params.get("id") + ... + """ + nav = use_context(_NavigationContext) + if nav is None: + return {} + get_params = getattr(nav, "get_params", None) + if get_params: + return get_params() + return {} + + +def use_focus_effect(effect: Callable, deps: Optional[list] = None) -> None: + """Run *effect* only when the screen is focused. + + Like ``use_effect`` but skips execution when the screen is not the + active/focused screen in a navigator:: + + @pn.component + def HomeScreen(): + pn.use_focus_effect(lambda: print("screen focused"), []) + """ + is_focused = use_context(_FocusContext) + all_deps = [is_focused] + (list(deps) if deps is not None else []) + + def wrapped_effect() -> Any: + if is_focused: + return effect() + return None + + use_effect(wrapped_effect, all_deps) diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index e5734e6..0d77c75 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -1,240 +1,261 @@ -""" -Your current approach, which involves creating an Android Activity in Kotlin -and then passing it to Python, is necessary due to the restrictions inherent -in Android's lifecycle. You are correctly following the Android way of managing -Activities. In Android, the system is in control of when and how Activities are -created and destroyed. It is not possible to directly create an instance of an -Activity from Python because that would bypass Android's lifecycle management, -leading to unpredictable results. - -Your Button example works because Button is a View, not an Activity. View -instances in Android can be created and managed directly by your code. This is -why you are able to create an instance of Button from Python. - -Remember that Activities in Android are not just containers for your UI like a -ViewGroup, they are also the main entry points into your app and are closely -tied to the app's lifecycle. Therefore, Android needs to maintain tight control -over them. Activities aren't something you instantiate whenever you need them; -they are created in response to a specific intent and their lifecycle is -managed by Android. - -So, to answer your question: Yes, you need to follow this approach for -Activities in Android. You cannot instantiate an Activity from Python like you -do for Views. - -On the other hand, for iOS, you can instantiate a UIViewController directly -from Python. The example code you provided for this is correct. - -Just ensure that your PythonNative UI framework is aware of these platform -differences and handles them appropriately. +"""Page host — the bridge between native lifecycle and function components. + +Users no longer subclass ``Page``. Instead they write ``@component`` +functions and the native template calls :func:`create_page` to obtain +an :class:`_AppHost` that manages the reconciler and lifecycle. + +Usage (user code):: + + import pythonnative as pn + + @pn.component + def MainPage(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + style={"spacing": 12, "padding": 16}, + ) + +The native template calls:: + + host = pythonnative.page.create_page("app.main_page.MainPage", native_instance) + host.on_create() """ +import importlib import json -from abc import ABC, abstractmethod -from typing import Any, Optional, Union +from typing import Any, Dict, Optional from .utils import IS_ANDROID, set_android_context -from .view import ViewBase -# ======================================== -# Base class -# ======================================== +_MAX_RENDER_PASSES = 25 +# ====================================================================== +# Component path resolution +# ====================================================================== -class PageBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - @abstractmethod - def set_root_view(self, view) -> None: - pass +def _resolve_component_path(page_ref: Any) -> str: + """Resolve a component function to a ``module.name`` path string.""" + if isinstance(page_ref, str): + return page_ref + func = getattr(page_ref, "__wrapped__", page_ref) + module = getattr(func, "__module__", None) + name = getattr(func, "__name__", None) + if module and name: + return f"{module}.{name}" + raise ValueError(f"Cannot resolve component path for {page_ref!r}") - @abstractmethod - def on_create(self) -> None: - pass - @abstractmethod - def on_start(self) -> None: - pass +def _import_component(component_path: str) -> Any: + """Import and return the component function from a dotted path.""" + module_path, component_name = component_path.rsplit(".", 1) + module = importlib.import_module(module_path) + return getattr(module, component_name) - @abstractmethod - def on_resume(self) -> None: - pass - @abstractmethod - def on_pause(self) -> None: - pass +# ====================================================================== +# Shared helpers +# ====================================================================== - @abstractmethod - def on_stop(self) -> None: - pass - @abstractmethod - def on_destroy(self) -> None: - pass +def _init_host_common(host: Any) -> None: + host._args = {} + host._reconciler = None + host._root_native_view = None + host._nav_handle = None + host._is_rendering = False + host._render_queued = False - @abstractmethod - def on_restart(self) -> None: - pass - @abstractmethod - def on_save_instance_state(self) -> None: - pass +def _on_create(host: Any) -> None: + from .hooks import NavigationHandle, Provider, _NavigationContext + from .native_views import get_registry + from .reconciler import Reconciler - @abstractmethod - def on_restore_instance_state(self) -> None: - pass + host._reconciler = Reconciler(get_registry()) + host._reconciler._page_re_render = lambda: _request_render(host) + host._nav_handle = NavigationHandle(host) - @abstractmethod - def set_args(self, args: Optional[dict]) -> None: - pass + app_element = host._component() + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) - @abstractmethod - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - pass + host._is_rendering = True + try: + host._root_native_view = host._reconciler.mount(provider_element) + host._attach_root(host._root_native_view) + _drain_renders(host) + finally: + host._is_rendering = False - @abstractmethod - def pop(self) -> None: - pass - def get_args(self) -> dict: - """Return arguments provided to this Page (empty dict if none).""" - # Concrete classes should set self._args; default empty - return getattr(self, "_args", {}) +def _request_render(host: Any) -> None: + """State-change trigger. Defers if a render is already in progress.""" + if host._is_rendering: + host._render_queued = True + return + _re_render(host) - # Back-compat: navigate_to delegates to push - def navigate_to(self, page) -> None: - self.push(page) - pass +def _re_render(host: Any) -> None: + """Perform a full render pass, draining any state set during effects.""" + from .hooks import Provider, _NavigationContext + + host._is_rendering = True + try: + host._render_queued = False + + app_element = host._component() + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) + + new_root = host._reconciler.reconcile(provider_element) + if new_root is not host._root_native_view: + host._detach_root(host._root_native_view) + host._root_native_view = new_root + host._attach_root(new_root) + + _drain_renders(host) + finally: + host._is_rendering = False + + +def _drain_renders(host: Any) -> None: + """Flush additional renders queued by effects that set state.""" + from .hooks import Provider, _NavigationContext + + for _ in range(_MAX_RENDER_PASSES): + if not host._render_queued: + break + host._render_queued = False + + app_element = host._component() + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) + + new_root = host._reconciler.reconcile(provider_element) + if new_root is not host._root_native_view: + host._detach_root(host._root_native_view) + host._root_native_view = new_root + host._attach_root(new_root) -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/app/Activity - # ======================================== +def _set_args(host: Any, args: Any) -> None: + if isinstance(args, str): + try: + host._args = json.loads(args) or {} + except Exception: + host._args = {} + return + host._args = args if isinstance(args, dict) else {} + + +# ====================================================================== +# Platform implementations +# ====================================================================== + +if IS_ANDROID: from java import jclass - class Page(PageBase, ViewBase): - def __init__(self, native_instance) -> None: - super().__init__() - self.native_class = jclass("android.app.Activity") + class _AppHost: + """Android host backed by an Activity and Fragment navigation.""" + + def __init__(self, native_instance: Any, component_func: Any) -> None: self.native_instance = native_instance - # self.native_instance = self.native_class() - # Stash the Activity so child views can implicitly acquire a Context + self._component = component_func set_android_context(native_instance) - self._args: dict = {} - - def set_root_view(self, view) -> None: - # In fragment-based navigation, attach child view to the current fragment container. - try: - from .utils import get_android_fragment_container - - container = get_android_fragment_container() - # Remove previous children if any, then add the new root - try: - container.removeAllViews() - except Exception: - pass - container.addView(view.native_instance) - except Exception: - # Fallback to setting content view directly on the Activity - self.native_instance.setContentView(view.native_instance) + _init_host_common(self) def on_create(self) -> None: - print("Android on_create() called") + _on_create(self) def on_start(self) -> None: - print("Android on_start() called") + pass def on_resume(self) -> None: - print("Android on_resume() called") + pass def on_pause(self) -> None: - print("Android on_pause() called") + pass def on_stop(self) -> None: - print("Android on_stop() called") + pass def on_destroy(self) -> None: - print("Android on_destroy() called") + pass def on_restart(self) -> None: - print("Android on_restart() called") + pass def on_save_instance_state(self) -> None: - print("Android on_save_instance_state() called") + pass def on_restore_instance_state(self) -> None: - print("Android on_restore_instance_state() called") + pass - def set_args(self, args: Optional[dict]) -> None: - # Accept dict or JSON string for convenience when crossing language boundaries - if isinstance(args, str): - try: - self._args = json.loads(args) or {} - return - except Exception: - self._args = {} - return - self._args = args or {} - - def _resolve_page_path(self, page: Union[str, Any]) -> str: - if isinstance(page, str): - return page - # If a class or instance is passed, derive dotted path - try: - module = getattr(page, "__module__", None) - name = getattr(page, "__name__", None) - if module and name: - return f"{module}.{name}" - # Instance: use its class - cls = page.__class__ - return f"{cls.__module__}.{cls.__name__}" - except Exception: - raise ValueError("Unsupported page reference; expected dotted string or class/instance") + def set_args(self, args: Any) -> None: + _set_args(self, args) - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - # Delegate to Navigator.push to navigate to PageFragment with arguments - page_path = self._resolve_page_path(page) - try: - Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") - args_json = json.dumps(args) if args else None - Navigator.push(self.native_instance, page_path, args_json) - except Exception: - # As a last resort, do nothing rather than crash - pass + def _get_nav_args(self) -> Dict[str, Any]: + return self._args - def pop(self) -> None: - # Delegate to Navigator.pop for back-stack pop + def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: + page_path = _resolve_component_path(page) + Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") + args_json = json.dumps(args) if args else None + Navigator.push(self.native_instance, page_path, args_json) + + def _pop(self) -> None: try: Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") Navigator.pop(self.native_instance) except Exception: + self.native_instance.finish() + + def _attach_root(self, native_view: Any) -> None: + try: + from .utils import get_android_fragment_container + + container = get_android_fragment_container() try: - self.native_instance.finish() + container.removeAllViews() except Exception: pass + LayoutParams = jclass("android.view.ViewGroup$LayoutParams") + lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + container.addView(native_view, lp) + except Exception: + self.native_instance.setContentView(native_view) + + def _detach_root(self, native_view: Any) -> None: + try: + from .utils import get_android_fragment_container + + container = get_android_fragment_container() + container.removeAllViews() + except Exception: + pass else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiviewcontroller - # ======================================== + from typing import Dict as _Dict - from typing import Dict + _rubicon_available = False + try: + from rubicon.objc import ObjCClass, ObjCInstance - from rubicon.objc import ObjCClass, ObjCInstance + _rubicon_available = True - # Global registry mapping native UIViewController pointer address to Page instances. - _IOS_PAGE_REGISTRY: Dict[int, Any] = {} + import gc as _gc + + _gc.disable() + except ImportError: + pass - def _ios_register_page(vc_instance: Any, page_obj: Any) -> None: + _IOS_PAGE_REGISTRY: _Dict[int, Any] = {} + + def _ios_register_page(vc_instance: Any, host_obj: Any) -> None: try: - ptr = int(vc_instance.ptr) # rubicon ObjCInstance -> c_void_p convertible to int - _IOS_PAGE_REGISTRY[ptr] = page_obj + ptr = int(vc_instance.ptr) + _IOS_PAGE_REGISTRY[ptr] = host_obj except Exception: pass @@ -246,151 +267,214 @@ def _ios_unregister_page(vc_instance: Any) -> None: pass def forward_lifecycle(native_addr: int, event: str) -> None: - """Forward a lifecycle event from Swift ViewController to the registered Page. - - :param native_addr: Integer pointer address of the UIViewController - :param event: One of 'on_start', 'on_resume', 'on_pause', 'on_stop', 'on_destroy', - 'on_save_instance_state', 'on_restore_instance_state'. - """ - page = _IOS_PAGE_REGISTRY.get(int(native_addr)) - if not page: + """Forward a lifecycle event from Swift ViewController to the registered host.""" + host = _IOS_PAGE_REGISTRY.get(int(native_addr)) + if host is None: return - try: - handler = getattr(page, event, None) - if handler: - handler() - except Exception: - # Avoid surfacing exceptions across the Swift/Python boundary in lifecycle - pass + handler = getattr(host, event, None) + if handler: + handler() - class Page(PageBase, ViewBase): - def __init__(self, native_instance) -> None: - super().__init__() - self.native_class = ObjCClass("UIViewController") - # If Swift passed us an integer pointer, wrap it as an ObjCInstance. - if isinstance(native_instance, int): - try: - native_instance = ObjCInstance(native_instance) - except Exception: - native_instance = None - self.native_instance = native_instance - # self.native_instance = self.native_class.alloc().init() - self._args: dict = {} - # Register for lifecycle forwarding - if self.native_instance is not None: - _ios_register_page(self.native_instance, self) - - def set_root_view(self, view) -> None: - # UIViewController.view is a property; access without calling. - root_view = self.native_instance.view - # Size the root child to fill the controller's view and enable autoresizing - try: - bounds = root_view.bounds - view.native_instance.setFrame_(bounds) - # UIViewAutoresizingFlexibleWidth (2) | UIViewAutoresizingFlexibleHeight (16) - view.native_instance.setAutoresizingMask_(2 | 16) - except Exception: - pass - root_view.addSubview_(view.native_instance) + if _rubicon_available: - def on_create(self) -> None: - print("iOS on_create() called") + class _AppHost: + """iOS host backed by a UIViewController.""" - def on_start(self) -> None: - print("iOS on_start() called") + def __init__(self, native_instance: Any, component_func: Any) -> None: + if isinstance(native_instance, int): + try: + native_instance = ObjCInstance(native_instance) + except Exception: + native_instance = None + self.native_instance = native_instance + self._component = component_func + _init_host_common(self) + if self.native_instance is not None: + _ios_register_page(self.native_instance, self) - def on_resume(self) -> None: - print("iOS on_resume() called") + def on_create(self) -> None: + _on_create(self) - def on_pause(self) -> None: - print("iOS on_pause() called") + def on_start(self) -> None: + pass - def on_stop(self) -> None: - print("iOS on_stop() called") + def on_resume(self) -> None: + pass - def on_destroy(self) -> None: - print("iOS on_destroy() called") - if self.native_instance is not None: - _ios_unregister_page(self.native_instance) + def on_pause(self) -> None: + pass - def on_restart(self) -> None: - print("iOS on_restart() called") + def on_stop(self) -> None: + pass - def on_save_instance_state(self) -> None: - print("iOS on_save_instance_state() called") + def on_destroy(self) -> None: + if self.native_instance is not None: + _ios_unregister_page(self.native_instance) - def on_restore_instance_state(self) -> None: - print("iOS on_restore_instance_state() called") + def on_restart(self) -> None: + pass - def set_args(self, args: Optional[dict]) -> None: - if isinstance(args, str): - try: - self._args = json.loads(args) or {} - return - except Exception: - self._args = {} - return - self._args = args or {} + def on_save_instance_state(self) -> None: + pass - def _resolve_page_path(self, page: Union[str, Any]) -> str: - if isinstance(page, str): - return page - try: - module = getattr(page, "__module__", None) - name = getattr(page, "__name__", None) - if module and name: - return f"{module}.{name}" - cls = page.__class__ - return f"{cls.__module__}.{cls.__name__}" - except Exception: - raise ValueError("Unsupported page reference; expected dotted string or class/instance") + def on_restore_instance_state(self) -> None: + pass - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - page_path = self._resolve_page_path(page) - # Resolve the Swift ViewController class. Swift classes are namespaced by - # the module name (CFBundleName). Try plain name first, then Module.Name. - ViewController = None - try: - ViewController = ObjCClass("ViewController") - except Exception: + def set_args(self, args: Any) -> None: + _set_args(self, args) + + def _get_nav_args(self) -> Dict[str, Any]: + return self._args + + def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: + page_path = _resolve_component_path(page) + ViewController = None try: - NSBundle = ObjCClass("NSBundle") - bundle = NSBundle.mainBundle - module_name = None + ViewController = ObjCClass("ViewController") + except Exception: try: - # Prefer CFBundleName; fallback to CFBundleExecutable + NSBundle = ObjCClass("NSBundle") + bundle = NSBundle.mainBundle module_name = bundle.objectForInfoDictionaryKey_("CFBundleName") if module_name is None: module_name = bundle.objectForInfoDictionaryKey_("CFBundleExecutable") + if module_name: + ViewController = ObjCClass(f"{module_name}.ViewController") + except Exception: + pass + + if ViewController is None: + raise NameError("ViewController class not found; ensure Swift class is ObjC-visible") + + next_vc = ViewController.alloc().init() + try: + next_vc.setValue_forKey_(page_path, "requestedPagePath") + if args: + next_vc.setValue_forKey_(json.dumps(args), "requestedPageArgsJSON") + except Exception: + pass + nav = getattr(self.native_instance, "navigationController", None) + if nav is None: + raise RuntimeError( + "No UINavigationController available; " "ensure template embeds root in navigation controller" + ) + nav.pushViewController_animated_(next_vc, True) + + def _pop(self) -> None: + nav = getattr(self.native_instance, "navigationController", None) + if nav is not None: + nav.popViewControllerAnimated_(True) + + def _attach_root(self, native_view: Any) -> None: + root_view = self.native_instance.view + native_view.setTranslatesAutoresizingMaskIntoConstraints_(False) + root_view.addSubview_(native_view) + try: + safe = root_view.safeAreaLayoutGuide + native_view.topAnchor.constraintEqualToAnchor_(safe.topAnchor).setActive_(True) + native_view.bottomAnchor.constraintEqualToAnchor_(safe.bottomAnchor).setActive_(True) + native_view.leadingAnchor.constraintEqualToAnchor_(safe.leadingAnchor).setActive_(True) + native_view.trailingAnchor.constraintEqualToAnchor_(safe.trailingAnchor).setActive_(True) + except Exception: + native_view.setTranslatesAutoresizingMaskIntoConstraints_(True) + try: + native_view.setFrame_(root_view.bounds) + native_view.setAutoresizingMask_(2 | 16) except Exception: - module_name = None - if module_name: - ViewController = ObjCClass(f"{module_name}.ViewController") + pass + + def _detach_root(self, native_view: Any) -> None: + try: + native_view.removeFromSuperview() except Exception: - ViewController = None + pass - if ViewController is None: - raise NameError("ViewController class not found; ensure Swift class is ObjC-visible") + else: - next_vc = ViewController.alloc().init() - try: - # Use KVC to pass metadata to Swift - next_vc.setValue_forKey_(page_path, "requestedPagePath") - if args: - next_vc.setValue_forKey_(json.dumps(args), "requestedPageArgsJSON") - except Exception: + class _AppHost: + """Desktop stub — no native runtime available. + + Fully functional for testing with a mock backend via + ``native_views.set_registry()``. + """ + + def __init__(self, native_instance: Any = None, component_func: Any = None) -> None: + self.native_instance = native_instance + self._component = component_func + _init_host_common(self) + + def on_create(self) -> None: + _on_create(self) + + def on_start(self) -> None: + pass + + def on_resume(self) -> None: + pass + + def on_pause(self) -> None: pass - # On iOS, `navigationController` is exposed as a property; treat it as such. - nav = getattr(self.native_instance, "navigationController", None) - if nav is None: - # If no navigation controller, this push will be a no-op; rely on template to embed one. - raise RuntimeError( - "No UINavigationController available; ensure template embeds root in navigation controller" - ) - # Method name maps from pushViewController:animated: - nav.pushViewController_animated_(next_vc, True) - - def pop(self) -> None: - nav = getattr(self.native_instance, "navigationController", None) - if nav is not None: - nav.popViewControllerAnimated_(True) + + def on_stop(self) -> None: + pass + + def on_destroy(self) -> None: + pass + + def on_restart(self) -> None: + pass + + def on_save_instance_state(self) -> None: + pass + + def on_restore_instance_state(self) -> None: + pass + + def set_args(self, args: Any) -> None: + _set_args(self, args) + + def _get_nav_args(self) -> Dict[str, Any]: + return self._args + + def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: + raise RuntimeError("navigate() requires a native runtime (iOS or Android)") + + def _pop(self) -> None: + raise RuntimeError("go_back() requires a native runtime (iOS or Android)") + + def _attach_root(self, native_view: Any) -> None: + pass + + def _detach_root(self, native_view: Any) -> None: + pass + + +# ====================================================================== +# Public factory +# ====================================================================== + + +def create_page( + component_path: str, + native_instance: Any = None, + args_json: Optional[str] = None, +) -> _AppHost: + """Create a page host for a function component. + + Called by native templates (PageFragment.kt / ViewController.swift) + to bridge the native lifecycle to a ``@component`` function. + + Parameters + ---------- + component_path: + Dotted Python path to the component, e.g. ``"app.main_page.MainPage"``. + native_instance: + The native Activity (Android) or ViewController pointer (iOS). + args_json: + Optional JSON string of navigation arguments. + """ + component_func = _import_component(component_path) + host = _AppHost(native_instance, component_func) + if args_json: + _set_args(host, args_json) + return host diff --git a/src/pythonnative/picker_view.py b/src/pythonnative/picker_view.py deleted file mode 100644 index fc7ca98..0000000 --- a/src/pythonnative/picker_view.py +++ /dev/null @@ -1,65 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class PickerViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_selected(self, index: int) -> None: - pass - - @abstractmethod - def get_selected(self) -> int: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Spinner - # ======================================== - - from java import jclass - - class PickerView(PickerViewBase, ViewBase): - def __init__(self, context, index: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.Spinner") - self.native_instance = self.native_class(context) - self.set_selected(index) - - def set_selected(self, index: int) -> None: - self.native_instance.setSelection(index) - - def get_selected(self) -> int: - return self.native_instance.getSelectedItemPosition() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uipickerview - # ======================================== - - from rubicon.objc import ObjCClass - - class PickerView(PickerViewBase, ViewBase): - def __init__(self, index: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIPickerView") - self.native_instance = self.native_class.alloc().init() - self.set_selected(index) - - def set_selected(self, index: int) -> None: - self.native_instance.selectRow_inComponent_animated_(index, 0, False) - - def get_selected(self) -> int: - return self.native_instance.selectedRowInComponent_(0) diff --git a/src/pythonnative/progress_view.py b/src/pythonnative/progress_view.py deleted file mode 100644 index c1a08b3..0000000 --- a/src/pythonnative/progress_view.py +++ /dev/null @@ -1,68 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ProgressViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_progress(self, progress: float) -> None: - pass - - @abstractmethod - def get_progress(self) -> float: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ProgressBar - # ======================================== - - from java import jclass - - class ProgressView(ProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ProgressBar") - # self.native_instance = self.native_class(context, None, android.R.attr.progressBarStyleHorizontal) - context = get_android_context() - self.native_instance = self.native_class(context, None, jclass("android.R$attr").progressBarStyleHorizontal) - self.native_instance.setIndeterminate(False) - - def set_progress(self, progress: float) -> None: - self.native_instance.setProgress(int(progress * 100)) - - def get_progress(self) -> float: - return self.native_instance.getProgress() / 100.0 - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiprogressview - # ======================================== - - from rubicon.objc import ObjCClass - - class ProgressView(ProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIProgressView") - self.native_instance = self.native_class.alloc().initWithProgressViewStyle_( - 0 - ) # 0: UIProgressViewStyleDefault - - def set_progress(self, progress: float) -> None: - self.native_instance.setProgress_animated_(progress, False) - - def get_progress(self) -> float: - return self.native_instance.progress() diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py new file mode 100644 index 0000000..f24ab5d --- /dev/null +++ b/src/pythonnative/reconciler.py @@ -0,0 +1,350 @@ +"""Virtual-tree reconciler. + +Maintains a tree of :class:`VNode` objects (each wrapping a native view) +and diffs incoming :class:`Element` trees to apply the minimal set of +native mutations. + +Supports: + +- **Native elements** (type is a string like ``"Text"``). +- **Function components** (type is a callable decorated with + ``@component``). Their hook state is preserved across renders. +- **Provider elements** (type ``"__Provider__"``), which push/pop + context values during tree traversal. +- **Error boundary elements** (type ``"__ErrorBoundary__"``), which + catch exceptions in child subtrees and render a fallback. +- **Key-based child reconciliation** for stable identity across + re-renders. +- **Post-render effect flushing**: after each mount or reconcile pass, + all queued effects are executed so they see the committed native tree. +""" + +from typing import Any, List, Optional + +from .element import Element + + +class VNode: + """A mounted element paired with its native view and child VNodes.""" + + __slots__ = ("element", "native_view", "children", "hook_state", "_rendered") + + def __init__(self, element: Element, native_view: Any, children: List["VNode"]) -> None: + self.element = element + self.native_view = native_view + self.children = children + self.hook_state: Any = None + self._rendered: Optional[Element] = None + + +class Reconciler: + """Create, diff, and patch native view trees from Element descriptors. + + After each ``mount`` or ``reconcile`` call the reconciler walks the + committed tree and flushes all pending effects so that effect + callbacks run **after** native mutations are applied. + + Parameters + ---------- + backend: + An object implementing the :class:`NativeViewRegistry` protocol + (``create_view``, ``update_view``, ``add_child``, ``remove_child``, + ``insert_child``). + """ + + def __init__(self, backend: Any) -> None: + self.backend = backend + self._tree: Optional[VNode] = None + self._page_re_render: Optional[Any] = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def mount(self, element: Element) -> Any: + """Build native views from *element* and return the root native view.""" + self._tree = self._create_tree(element) + self._flush_effects() + return self._tree.native_view + + def reconcile(self, new_element: Element) -> Any: + """Diff *new_element* against the current tree and patch native views. + + Returns the (possibly replaced) root native view. + """ + if self._tree is None: + self._tree = self._create_tree(new_element) + self._flush_effects() + return self._tree.native_view + + self._tree = self._reconcile_node(self._tree, new_element) + self._flush_effects() + return self._tree.native_view + + # ------------------------------------------------------------------ + # Effect flushing + # ------------------------------------------------------------------ + + def _flush_effects(self) -> None: + """Walk the committed tree and flush pending effects (depth-first).""" + if self._tree is not None: + self._flush_tree_effects(self._tree) + + def _flush_tree_effects(self, node: VNode) -> None: + for child in node.children: + self._flush_tree_effects(child) + if node.hook_state is not None: + node.hook_state.flush_pending_effects() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _create_tree(self, element: Element) -> VNode: + # Provider: push context, create child, pop context + if element.type == "__Provider__": + context = element.props["__context__"] + context._stack.append(element.props["__value__"]) + try: + child_node = self._create_tree(element.children[0]) if element.children else None + finally: + context._stack.pop() + native_view = child_node.native_view if child_node else None + children = [child_node] if child_node else [] + return VNode(element, native_view, children) + + # Error boundary: catch exceptions in the child subtree + if element.type == "__ErrorBoundary__": + return self._create_error_boundary(element) + + # Function component: call with hook context + if callable(element.type): + from .hooks import HookState, _set_hook_state + + hook_state = HookState() + hook_state._trigger_render = self._page_re_render + _set_hook_state(hook_state) + try: + rendered = element.type(**element.props) + finally: + _set_hook_state(None) + + child_node = self._create_tree(rendered) + vnode = VNode(element, child_node.native_view, [child_node]) + vnode.hook_state = hook_state + vnode._rendered = rendered + return vnode + + # Native element + native_view = self.backend.create_view(element.type, element.props) + children: List[VNode] = [] + for child_el in element.children: + child_node = self._create_tree(child_el) + self.backend.add_child(native_view, child_node.native_view, element.type) + children.append(child_node) + return VNode(element, native_view, children) + + def _create_error_boundary(self, element: Element) -> VNode: + fallback_fn = element.props.get("__fallback__") + try: + child_node = self._create_tree(element.children[0]) if element.children else None + except Exception as exc: + if fallback_fn is not None: + fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn + child_node = self._create_tree(fallback_el) + else: + raise + native_view = child_node.native_view if child_node else None + children = [child_node] if child_node else [] + return VNode(element, native_view, children) + + def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: + if not self._same_type(old.element, new_el): + new_node = self._create_tree(new_el) + self._destroy_tree(old) + return new_node + + # Provider + if new_el.type == "__Provider__": + context = new_el.props["__context__"] + context._stack.append(new_el.props["__value__"]) + try: + if old.children and new_el.children: + child = self._reconcile_node(old.children[0], new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + elif new_el.children: + child = self._create_tree(new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + finally: + context._stack.pop() + old.element = new_el + return old + + # Error boundary + if new_el.type == "__ErrorBoundary__": + return self._reconcile_error_boundary(old, new_el) + + # Function component + if callable(new_el.type): + from .hooks import _set_hook_state + + hook_state = old.hook_state + if hook_state is None: + from .hooks import HookState + + hook_state = HookState() + hook_state.reset_index() + hook_state._trigger_render = self._page_re_render + _set_hook_state(hook_state) + try: + rendered = new_el.type(**new_el.props) + finally: + _set_hook_state(None) + + if old.children: + child = self._reconcile_node(old.children[0], rendered) + else: + child = self._create_tree(rendered) + old.children = [child] + old.native_view = child.native_view + old.element = new_el + old.hook_state = hook_state + old._rendered = rendered + return old + + # Native element + changed = self._diff_props(old.element.props, new_el.props) + if changed: + self.backend.update_view(old.native_view, old.element.type, changed) + + self._reconcile_children(old, new_el.children) + old.element = new_el + return old + + def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode: + fallback_fn = new_el.props.get("__fallback__") + try: + if old.children and new_el.children: + child = self._reconcile_node(old.children[0], new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + elif new_el.children: + child = self._create_tree(new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + except Exception as exc: + for c in old.children: + self._destroy_tree(c) + if fallback_fn is not None: + fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn + child = self._create_tree(fallback_el) + old.children = [child] + old.native_view = child.native_view + else: + raise + old.element = new_el + return old + + def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None: + old_children = parent.children + parent_type = parent.element.type + is_native = isinstance(parent_type, str) and parent_type not in ("__Provider__", "__ErrorBoundary__") + + old_by_key: dict = {} + old_unkeyed: list = [] + for child in old_children: + if child.element.key is not None: + old_by_key[child.element.key] = child + else: + old_unkeyed.append(child) + + new_child_nodes: List[VNode] = [] + used_keyed: set = set() + unkeyed_iter = iter(old_unkeyed) + + for i, new_el in enumerate(new_children): + matched: Optional[VNode] = None + + if new_el.key is not None and new_el.key in old_by_key: + matched = old_by_key[new_el.key] + used_keyed.add(new_el.key) + elif new_el.key is None: + matched = next(unkeyed_iter, None) + + if matched is None: + node = self._create_tree(new_el) + if is_native: + self.backend.add_child(parent.native_view, node.native_view, parent_type) + new_child_nodes.append(node) + elif not self._same_type(matched.element, new_el): + if is_native: + self.backend.remove_child(parent.native_view, matched.native_view, parent_type) + self._destroy_tree(matched) + node = self._create_tree(new_el) + if is_native: + self.backend.insert_child(parent.native_view, node.native_view, parent_type, i) + new_child_nodes.append(node) + else: + old_native = matched.native_view + updated = self._reconcile_node(matched, new_el) + if is_native and updated.native_view is not old_native: + self.backend.remove_child(parent.native_view, old_native, parent_type) + self.backend.insert_child(parent.native_view, updated.native_view, parent_type, i) + new_child_nodes.append(updated) + + # Destroy unused old nodes + for key, node in old_by_key.items(): + if key not in used_keyed: + if is_native: + self.backend.remove_child(parent.native_view, node.native_view, parent_type) + self._destroy_tree(node) + for node in unkeyed_iter: + if is_native: + self.backend.remove_child(parent.native_view, node.native_view, parent_type) + self._destroy_tree(node) + + # Reorder native children when keyed children changed positions. + # Without this, native sibling order drifts from the logical tree + # when keyed children swap positions across reconcile passes. + if is_native and used_keyed: + old_key_order = [c.element.key for c in old_children if c.element.key in used_keyed] + new_key_order = [n.element.key for n in new_child_nodes if n.element.key in used_keyed] + if old_key_order != new_key_order: + for node in new_child_nodes: + self.backend.remove_child(parent.native_view, node.native_view, parent_type) + for node in new_child_nodes: + self.backend.add_child(parent.native_view, node.native_view, parent_type) + + parent.children = new_child_nodes + + def _destroy_tree(self, node: VNode) -> None: + if node.hook_state is not None: + node.hook_state.cleanup_all_effects() + for child in node.children: + self._destroy_tree(child) + node.children = [] + + @staticmethod + def _same_type(old_el: Element, new_el: Element) -> bool: + if isinstance(old_el.type, str): + return old_el.type == new_el.type + return old_el.type is new_el.type + + @staticmethod + def _diff_props(old: dict, new: dict) -> dict: + """Return props that changed (callables always count as changed).""" + changed = {} + for key, new_val in new.items(): + if key.startswith("__"): + continue + old_val = old.get(key) + if callable(new_val) or old_val != new_val: + changed[key] = new_val + for key in old: + if key.startswith("__"): + continue + if key not in new: + changed[key] = None + return changed diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py deleted file mode 100644 index 1c19d62..0000000 --- a/src/pythonnative/scroll_view.py +++ /dev/null @@ -1,63 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, List - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ScrollViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - self.views: List[Any] = [] - - @abstractmethod - def add_view(self, view) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ScrollView - # ======================================== - - from java import jclass - - class ScrollView(ScrollViewBase, ViewBase): - def __init__(self, context) -> None: - super().__init__() - self.native_class = jclass("android.widget.ScrollView") - self.native_instance = self.native_class(context) - - def add_view(self, view): - self.views.append(view) - # In Android, ScrollView can host only one direct child - if len(self.views) == 1: - self.native_instance.addView(view.native_instance) - else: - raise Exception("ScrollView can host only one direct child") - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiscrollview - # ======================================== - - from rubicon.objc import ObjCClass - - class ScrollView(ScrollViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIScrollView") - self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - - def add_view(self, view): - self.views.append(view) - # Ensure view is a subview of scrollview - if view.native_instance not in self.native_instance.subviews: - self.native_instance.addSubview_(view.native_instance) diff --git a/src/pythonnative/search_bar.py b/src/pythonnative/search_bar.py deleted file mode 100644 index 72609ff..0000000 --- a/src/pythonnative/search_bar.py +++ /dev/null @@ -1,65 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class SearchBarBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_query(self, query: str) -> None: - pass - - @abstractmethod - def get_query(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/SearchView - # ======================================== - - from java import jclass - - class SearchBar(SearchBarBase, ViewBase): - def __init__(self, context, query: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.SearchView") - self.native_instance = self.native_class(context) - self.set_query(query) - - def set_query(self, query: str) -> None: - self.native_instance.setQuery(query, False) - - def get_query(self) -> str: - return self.native_instance.getQuery().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uisearchbar - # ======================================== - - from rubicon.objc import ObjCClass - - class SearchBar(SearchBarBase, ViewBase): - def __init__(self, query: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UISearchBar") - self.native_instance = self.native_class.alloc().init() - self.set_query(query) - - def set_query(self, query: str) -> None: - self.native_instance.set_text_(query) - - def get_query(self) -> str: - return self.native_instance.text() diff --git a/src/pythonnative/stack_view.py b/src/pythonnative/stack_view.py deleted file mode 100644 index 3fc57b1..0000000 --- a/src/pythonnative/stack_view.py +++ /dev/null @@ -1,60 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, List - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class StackViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - self.views: List[Any] = [] - - @abstractmethod - def add_view(self, view) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/LinearLayout - # ======================================== - - from java import jclass - - class StackView(StackViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.LinearLayout") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setOrientation(self.native_class.VERTICAL) - - def add_view(self, view): - self.views.append(view) - self.native_instance.addView(view.native_instance) - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uistackview - # ======================================== - - from rubicon.objc import ObjCClass - - class StackView(StackViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIStackView") - self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - self.native_instance.setAxis_(0) # Set axis to vertical - - def add_view(self, view): - self.views.append(view) - self.native_instance.addArrangedSubview_(view.native_instance) diff --git a/src/pythonnative/style.py b/src/pythonnative/style.py new file mode 100644 index 0000000..7d645f4 --- /dev/null +++ b/src/pythonnative/style.py @@ -0,0 +1,135 @@ +"""StyleSheet, style resolution, and theming support. + +Provides a :class:`StyleSheet` helper for creating and composing +reusable style dictionaries, a :func:`resolve_style` utility for +flattening the ``style`` prop, and built-in theme contexts. + +Usage:: + + import pythonnative as pn + + styles = pn.StyleSheet.create( + title={"font_size": 24, "bold": True, "color": "#333"}, + container={"padding": 16, "spacing": 12}, + ) + + pn.Text("Hello", style=styles["title"]) + pn.Column(..., style=styles["container"]) +""" + +from typing import Any, Dict, List, Optional, Union + +from .hooks import Context, create_context + +_StyleDict = Dict[str, Any] +StyleValue = Union[None, _StyleDict, List[Optional[_StyleDict]]] + + +def resolve_style(style: StyleValue) -> _StyleDict: + """Flatten a ``style`` prop into a single dict. + + Accepts ``None``, a single dict, or a list of dicts (later entries + override earlier ones, mirroring React Native's array style pattern). + """ + if style is None: + return {} + if isinstance(style, dict): + return dict(style) + result: _StyleDict = {} + for entry in style: + if entry: + result.update(entry) + return result + + +# ====================================================================== +# StyleSheet +# ====================================================================== + + +class StyleSheet: + """Utility for creating and composing style dictionaries.""" + + @staticmethod + def create(**named_styles: _StyleDict) -> Dict[str, _StyleDict]: + """Create a set of named styles. + + Each keyword argument is a style name mapping to a dict of + property values:: + + styles = StyleSheet.create( + heading={"font_size": 28, "bold": True}, + body={"font_size": 16}, + ) + """ + return {name: dict(props) for name, props in named_styles.items()} + + @staticmethod + def compose(*styles: _StyleDict) -> _StyleDict: + """Merge multiple style dicts, later values overriding earlier ones.""" + merged: _StyleDict = {} + for style in styles: + if style: + merged.update(style) + return merged + + @staticmethod + def flatten(styles: Any) -> _StyleDict: + """Flatten a style or list of styles into a single dict. + + Accepts a single dict, a list of dicts, or ``None``. + """ + if styles is None: + return {} + if isinstance(styles, dict): + return dict(styles) + result: _StyleDict = {} + for s in styles: + if s: + result.update(s) + return result + + +# ====================================================================== +# Theming +# ====================================================================== + +DEFAULT_LIGHT_THEME: _StyleDict = { + "primary_color": "#007AFF", + "secondary_color": "#5856D6", + "background_color": "#FFFFFF", + "surface_color": "#F2F2F7", + "text_color": "#000000", + "text_secondary_color": "#8E8E93", + "error_color": "#FF3B30", + "success_color": "#34C759", + "warning_color": "#FF9500", + "font_size": 16, + "font_size_small": 13, + "font_size_large": 20, + "font_size_title": 28, + "spacing": 8, + "spacing_large": 16, + "border_radius": 8, +} + +DEFAULT_DARK_THEME: _StyleDict = { + "primary_color": "#0A84FF", + "secondary_color": "#5E5CE6", + "background_color": "#000000", + "surface_color": "#1C1C1E", + "text_color": "#FFFFFF", + "text_secondary_color": "#8E8E93", + "error_color": "#FF453A", + "success_color": "#30D158", + "warning_color": "#FF9F0A", + "font_size": 16, + "font_size_small": 13, + "font_size_large": 20, + "font_size_title": 28, + "spacing": 8, + "spacing_large": 16, + "border_radius": 8, +} + +ThemeContext: Context = create_context(DEFAULT_LIGHT_THEME) diff --git a/src/pythonnative/switch.py b/src/pythonnative/switch.py deleted file mode 100644 index bdd38bc..0000000 --- a/src/pythonnative/switch.py +++ /dev/null @@ -1,66 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class SwitchBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_on(self, value: bool) -> None: - pass - - @abstractmethod - def is_on(self) -> bool: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Switch - # ======================================== - - from java import jclass - - class Switch(SwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = jclass("android.widget.Switch") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_on(value) - - def set_on(self, value: bool) -> None: - self.native_instance.setChecked(value) - - def is_on(self) -> bool: - return self.native_instance.isChecked() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiswitch - # ======================================== - - from rubicon.objc import ObjCClass - - class Switch(SwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = ObjCClass("UISwitch") - self.native_instance = self.native_class.alloc().init() - self.set_on(value) - - def set_on(self, value: bool) -> None: - self.native_instance.setOn_animated_(value, False) - - def is_on(self) -> bool: - return self.native_instance.isOn() diff --git a/src/pythonnative/templates/android_template/app/build.gradle b/src/pythonnative/templates/android_template/app/build.gradle index 2fb8251..5550ba8 100644 --- a/src/pythonnative/templates/android_template/app/build.gradle +++ b/src/pythonnative/templates/android_template/app/build.gradle @@ -6,12 +6,12 @@ plugins { android { namespace 'com.pythonnative.android_template' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "com.pythonnative.android_template" minSdk 24 - targetSdk 33 + targetSdk 34 versionCode 1 versionName "1.0" @@ -20,14 +20,9 @@ android { abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" } python { - version "3.8" + version "3.11" pip { - install "matplotlib" - install "pythonnative" - - // "-r"` followed by a requirements filename, relative to the - // project directory: -// install "-r", "requirements.txt" + install "-r", "requirements.txt" } } } diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt index b5d1dab..af9bb3e 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt @@ -25,15 +25,8 @@ class PageFragment : Fragment() { val py = Python.getInstance() val pagePath = arguments?.getString("page_path") ?: "app.main_page.MainPage" val argsJson = arguments?.getString("args_json") - val moduleName = pagePath.substringBeforeLast('.') - val className = pagePath.substringAfterLast('.') - val pyModule = py.getModule(moduleName) - val pageClass = pyModule.get(className) - // Pass the hosting Activity as native_instance for context - page = pageClass?.call(requireActivity()) - if (!argsJson.isNullOrEmpty()) { - page?.callAttr("set_args", argsJson) - } + val pnPage = py.getModule("pythonnative.page") + page = pnPage.callAttr("create_page", pagePath, requireActivity(), argsJson) } catch (e: Exception) { Log.e(TAG, "Failed to instantiate page", e) } @@ -65,7 +58,8 @@ class PageFragment : Fragment() { utils.callAttr("set_android_fragment_container", view) // Now that container exists, invoke on_create so Python can attach its root view page?.callAttr("on_create") - } catch (_: Exception) { + } catch (e: Exception) { + Log.e(TAG, "on_create failed", e) } } diff --git a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml index cbf90d7..182bed8 100644 --- a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +++ b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml @@ -16,7 +16,7 @@ + app:nullable="true" /> diff --git a/src/pythonnative/templates/android_template/build.gradle b/src/pythonnative/templates/android_template/build.gradle index ff78675..719a616 100644 --- a/src/pythonnative/templates/android_template/build.gradle +++ b/src/pythonnative/templates/android_template/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.0.2' apply false - id 'com.android.library' version '8.0.2' apply false - id 'org.jetbrains.kotlin.android' version '1.8.20' apply false - id 'com.chaquo.python' version '14.0.2' apply false + id 'com.android.application' version '8.2.2' apply false + id 'com.android.library' version '8.2.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false + id 'com.chaquo.python' version '15.0.1' apply false } \ No newline at end of file diff --git a/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties b/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties index f53bdea..bfe5fe7 100644 --- a/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +++ b/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Jun 19 11:09:16 PDT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/pythonnative/templates/ios_template/ios_template/ViewController.swift b/src/pythonnative/templates/ios_template/ios_template/ViewController.swift index 88e7afc..81e7c2d 100644 --- a/src/pythonnative/templates/ios_template/ios_template/ViewController.swift +++ b/src/pythonnative/templates/ios_template/ios_template/ViewController.swift @@ -85,28 +85,15 @@ class ViewController: UIViewController { // Determine which Python page to load let pagePath: String = requestedPagePath ?? "app.main_page.MainPage" do { - let moduleName = String(pagePath.split(separator: ".").dropLast().joined(separator: ".")) - let className = String(pagePath.split(separator: ".").last ?? "MainPage") - let pyModule = try Python.attemptImport(moduleName) - // Resolve class by name via builtins.getattr to avoid subscripting issues - let builtins = Python.import("builtins") - let getattrFn = builtins.getattr - let pageClass = try getattrFn.throwing.dynamicallyCall(withArguments: [pyModule, className]) - // Pass native pointer so Python Page can wrap via rubicon.objc + let pnPage = try Python.attemptImport("pythonnative.page") let ptr = Unmanaged.passUnretained(self).toOpaque() let addr = UInt(bitPattern: ptr) - let page = try pageClass.throwing.dynamicallyCall(withArguments: [addr]) - // If args provided, pass into Page via set_args(dict) - if let jsonStr = requestedPageArgsJSON { - let json = Python.import("json") - do { - let args = try json.loads.throwing.dynamicallyCall(withArguments: [jsonStr]) - _ = try page.set_args.throwing.dynamicallyCall(withArguments: [args]) - } catch { - NSLog("[PN] Failed to decode requestedPageArgsJSON: \(error)") - } - } - // Call on_create immediately so Python can insert its root view + let argsJson: PythonObject = (requestedPageArgsJSON != nil) + ? PythonObject(requestedPageArgsJSON!) + : Python.None + let page = try pnPage.create_page.throwing.dynamicallyCall( + withArguments: [pagePath, addr, argsJson] + ) _ = try page.on_create.throwing.dynamicallyCall(withArguments: []) return } catch { diff --git a/src/pythonnative/text_field.py b/src/pythonnative/text_field.py deleted file mode 100644 index fcf1288..0000000 --- a/src/pythonnative/text_field.py +++ /dev/null @@ -1,67 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TextFieldBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> None: - pass - - @abstractmethod - def get_text(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/EditText - # ======================================== - - from java import jclass - - class TextField(TextFieldBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.EditText") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setSingleLine(True) - self.set_text(text) - - def set_text(self, text: str) -> None: - self.native_instance.setText(text) - - def get_text(self) -> str: - return self.native_instance.getText().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitextfield - # ======================================== - - from rubicon.objc import ObjCClass - - class TextField(TextFieldBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UITextField") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> None: - self.native_instance.setText_(text) - - def get_text(self) -> str: - return self.native_instance.text() diff --git a/src/pythonnative/text_view.py b/src/pythonnative/text_view.py deleted file mode 100644 index 8e9154d..0000000 --- a/src/pythonnative/text_view.py +++ /dev/null @@ -1,70 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TextViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> None: - pass - - @abstractmethod - def get_text(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/EditText - # ======================================== - - from java import jclass - - class TextView(TextViewBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.EditText") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setLines(3) - self.native_instance.setMaxLines(5) - self.native_instance.setVerticalScrollBarEnabled(True) - # self.native_instance.movementMethod = ScrollingMovementMethod() - self.set_text(text) - - def set_text(self, text: str) -> None: - self.native_instance.setText(text) - - def get_text(self) -> str: - return self.native_instance.getText().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitextview - # ======================================== - - from rubicon.objc import ObjCClass - - class TextView(TextViewBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UITextView") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> None: - self.native_instance.setText_(text) - - def get_text(self) -> str: - return self.native_instance.text() diff --git a/src/pythonnative/time_picker.py b/src/pythonnative/time_picker.py deleted file mode 100644 index b12a395..0000000 --- a/src/pythonnative/time_picker.py +++ /dev/null @@ -1,73 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TimePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_time(self, hour: int, minute: int) -> None: - pass - - @abstractmethod - def get_time(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/TimePicker - # ======================================== - - from java import jclass - - class TimePicker(TimePickerBase, ViewBase): - def __init__(self, context, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.TimePicker") - self.native_instance = self.native_class(context) - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> None: - self.native_instance.setHour(hour) - self.native_instance.setMinute(minute) - - def get_time(self) -> tuple: - hour = self.native_instance.getHour() - minute = self.native_instance.getMinute() - return hour, minute - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import time - - from rubicon.objc import ObjCClass - - class TimePicker(TimePickerBase, ViewBase): - def __init__(self, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.native_instance.setDatePickerMode_(1) # Setting mode to Time - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> None: - t = time(hour, minute) - self.native_instance.setTime_(t) - - def get_time(self) -> tuple: - t = self.native_instance.time() - return t.hour, t.minute diff --git a/src/pythonnative/utils.py b/src/pythonnative/utils.py index 1cbde95..4505a0e 100644 --- a/src/pythonnative/utils.py +++ b/src/pythonnative/utils.py @@ -1,27 +1,29 @@ +"""Platform detection and shared helpers. + +This module is imported early by most other modules, so it avoids +importing platform-specific packages at module level. +""" + import os from typing import Any, Optional -# Platform detection with multiple fallbacks suitable for Chaquopy/Android +# ====================================================================== +# Platform detection +# ====================================================================== + _is_android: Optional[bool] = None def _detect_android() -> bool: - # 1) Direct environment hints commonly present on Android env = os.environ if "ANDROID_BOOTLOGO" in env or "ANDROID_ROOT" in env or "ANDROID_DATA" in env or "ANDROID_ARGUMENT" in env: return True - - # 2) Chaquopy-specific: the builtin 'java' package is available try: - # Import inside try so importing this module doesn't explode off-device - from java import jclass + from java import jclass # noqa: F401 - _ = jclass # silence linter unused return True except Exception: pass - - # 3) Last resort: some Android Python dists set os.name/others, but avoid false positives return False @@ -39,53 +41,43 @@ def _get_is_android() -> bool: IS_ANDROID: bool = _get_is_android() -# Global hooks to access current Android Activity/Context and Fragment container from Python code +# ====================================================================== +# Android context management +# ====================================================================== + _android_context: Any = None _android_fragment_container: Any = None def set_android_context(context: Any) -> None: - """Record the current Android Activity/Context for implicit constructor use. - - On Android, Python UI components require a Context to create native views. - We capture it when a Page is constructed from the host Activity so component - constructors can be platform-consistent and avoid explicit context params. - """ - + """Record the current Android Activity/Context for view construction.""" global _android_context _android_context = context def set_android_fragment_container(container_view: Any) -> None: - """Record the current Fragment root container ViewGroup for rendering pages. - - The current Page's `set_root_view` will attach its native view to this container. - """ + """Record the current Fragment root container ViewGroup.""" global _android_fragment_container _android_fragment_container = container_view def get_android_context() -> Any: - """Return the previously set Android Activity/Context or raise if missing.""" - + """Return the current Android Activity/Context.""" if not IS_ANDROID: raise RuntimeError("get_android_context() called on non-Android platform") if _android_context is None: raise RuntimeError( - "Android context is not set. Ensure Page is initialized from an Activity " "before constructing views." + "Android context not set. Ensure Page is initialized from an Activity before constructing views." ) return _android_context def get_android_fragment_container() -> Any: - """Return the previously set Fragment container ViewGroup or raise if missing. - - This is set by the host `PageFragment` when its view is created. - """ + """Return the current Fragment container ViewGroup.""" if not IS_ANDROID: raise RuntimeError("get_android_fragment_container() called on non-Android platform") if _android_fragment_container is None: raise RuntimeError( - "Android fragment container is not set. Ensure PageFragment has been created before set_root_view." + "Android fragment container not set. Ensure PageFragment has been created before set_root_view." ) return _android_fragment_container diff --git a/src/pythonnative/view.py b/src/pythonnative/view.py deleted file mode 100644 index 4afbbfe..0000000 --- a/src/pythonnative/view.py +++ /dev/null @@ -1,25 +0,0 @@ -from abc import ABC -from typing import Any - -# ======================================== -# Base class -# ======================================== - - -class ViewBase(ABC): - def __init__(self) -> None: - # Native bridge handles return types dynamically; these attributes are set at runtime. - self.native_instance: Any = None - self.native_class: Any = None - - # @abstractmethod - # def add_view(self, view): - # pass - # - # @abstractmethod - # def set_layout(self, layout): - # pass - # - # @abstractmethod - # def show(self): - # pass diff --git a/src/pythonnative/web_view.py b/src/pythonnative/web_view.py deleted file mode 100644 index ee3a16e..0000000 --- a/src/pythonnative/web_view.py +++ /dev/null @@ -1,58 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class WebViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def load_url(self, url: str) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/webkit/WebView - # ======================================== - - from java import jclass - - class WebView(WebViewBase, ViewBase): - def __init__(self, url: str = "") -> None: - super().__init__() - self.native_class = jclass("android.webkit.WebView") - context = get_android_context() - self.native_instance = self.native_class(context) - self.load_url(url) - - def load_url(self, url: str) -> None: - self.native_instance.loadUrl(url) - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/webkit/wkwebview - # ======================================== - - from rubicon.objc import NSURL, NSURLRequest, ObjCClass - - class WebView(WebViewBase, ViewBase): - def __init__(self, url: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("WKWebView") - self.native_instance = self.native_class.alloc().init() - self.load_url(url) - - def load_url(self, url: str) -> None: - ns_url = NSURL.URLWithString_(url) - request = NSURLRequest.requestWithURL_(ns_url) - self.native_instance.loadRequest_(request) diff --git a/tests/e2e/android.yaml b/tests/e2e/android.yaml new file mode 100644 index 0000000..5b9a6ff --- /dev/null +++ b/tests/e2e/android.yaml @@ -0,0 +1,6 @@ +appId: com.pythonnative.android_template +env: + APP_ID: com.pythonnative.android_template +--- +- runFlow: flows/main_page.yaml +- runFlow: flows/navigation.yaml diff --git a/tests/e2e/flows/main_page.yaml b/tests/e2e/flows/main_page.yaml new file mode 100644 index 0000000..9c7ab0c --- /dev/null +++ b/tests/e2e/flows/main_page.yaml @@ -0,0 +1,14 @@ +appId: ${APP_ID} +--- +# Verify main page renders correctly and the counter works. +- launchApp +- extendedWaitUntil: + visible: "Hello from PythonNative Demo!" + timeout: 30000 +- assertVisible: "Tapped 0 times" +- assertVisible: "Tap me" +- assertVisible: "Go to Second Page" +- tapOn: "Tap me" +- assertVisible: "Tapped 1 times" +- tapOn: "Tap me" +- assertVisible: "Tapped 2 times" diff --git a/tests/e2e/flows/navigation.yaml b/tests/e2e/flows/navigation.yaml new file mode 100644 index 0000000..f71a7c9 --- /dev/null +++ b/tests/e2e/flows/navigation.yaml @@ -0,0 +1,19 @@ +appId: ${APP_ID} +--- +# Navigate through all three pages and back to main. +- launchApp +- extendedWaitUntil: + visible: "Hello from PythonNative Demo!" + timeout: 30000 +- tapOn: "Go to Second Page" +- assertVisible: "Greetings from MainPage" +- assertVisible: "Go to Third Page" +- assertVisible: "Back" +- tapOn: "Go to Third Page" +- assertVisible: "Third Page" +- assertVisible: "You navigated two levels deep." +- assertVisible: "Back to Second" +- tapOn: "Back to Second" +- assertVisible: "Greetings from MainPage" +- tapOn: "Back" +- assertVisible: "Hello from PythonNative Demo!" diff --git a/tests/e2e/ios.yaml b/tests/e2e/ios.yaml new file mode 100644 index 0000000..c41eaa8 --- /dev/null +++ b/tests/e2e/ios.yaml @@ -0,0 +1,6 @@ +appId: com.pythonnative.ios-template +env: + APP_ID: com.pythonnative.ios-template +--- +- runFlow: flows/main_page.yaml +- runFlow: flows/navigation.yaml diff --git a/tests/test_cli.py b/tests/test_cli.py index f9b0eaf..ba6dcd6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,14 +3,15 @@ import subprocess import sys import tempfile +from typing import List -def run_pn(args, cwd): +def run_pn(args: List[str], cwd: str) -> subprocess.CompletedProcess[str]: cmd = [sys.executable, "-m", "pythonnative.cli.pn"] + args return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True) -def test_cli_init_and_clean(): +def test_cli_init_and_clean() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: # init @@ -22,7 +23,7 @@ def test_cli_init_and_clean(): assert os.path.isfile(main_page_path) with open(main_page_path, "r", encoding="utf-8") as f: content = f.read() - assert "class MainPage(" in content + assert "def MainPage(" in content assert os.path.isfile(os.path.join(tmpdir, "pythonnative.json")) assert os.path.isfile(os.path.join(tmpdir, "requirements.txt")) assert os.path.isfile(os.path.join(tmpdir, ".gitignore")) @@ -40,7 +41,7 @@ def test_cli_init_and_clean(): shutil.rmtree(tmpdir, ignore_errors=True) -def test_cli_run_prepare_only_android_and_ios(): +def test_cli_run_prepare_only_android_and_ios() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: # init to create app scaffold diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000..eaac14e --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,403 @@ +"""Unit tests for the built-in element-creating functions.""" + +from pythonnative.components import ( + ActivityIndicator, + Button, + Column, + ErrorBoundary, + FlatList, + Image, + Modal, + Pressable, + ProgressBar, + Row, + SafeAreaView, + ScrollView, + Slider, + Spacer, + Switch, + Text, + TextInput, + View, + WebView, +) + +# --------------------------------------------------------------------------- +# Text +# --------------------------------------------------------------------------- + + +def test_text_defaults() -> None: + el = Text() + assert el.type == "Text" + assert el.props.get("text", "") == "" + assert el.children == [] + + +def test_text_with_style() -> None: + el = Text("Hello", style={"font_size": 18, "color": "#FF0000", "bold": True, "text_align": "center"}) + assert el.props["text"] == "Hello" + assert el.props["font_size"] == 18 + assert el.props["color"] == "#FF0000" + assert el.props["bold"] is True + assert el.props["text_align"] == "center" + + +def test_text_no_style_no_extra_props() -> None: + el = Text("Hi") + assert "font_size" not in el.props + assert "color" not in el.props + + +def test_text_layout_via_style() -> None: + el = Text("Hi", style={"width": 100, "height": 50, "flex": 1, "margin": 8, "align_self": "center"}) + assert el.props["width"] == 100 + assert el.props["height"] == 50 + assert el.props["flex"] == 1 + assert el.props["margin"] == 8 + assert el.props["align_self"] == "center" + + +def test_text_style_list() -> None: + base = {"font_size": 16, "color": "#000"} + override = {"color": "#FFF", "bold": True} + el = Text("combo", style=[base, override]) + assert el.props["font_size"] == 16 + assert el.props["color"] == "#FFF" + assert el.props["bold"] is True + + +# --------------------------------------------------------------------------- +# Button +# --------------------------------------------------------------------------- + + +def test_button_defaults() -> None: + el = Button() + assert el.type == "Button" + assert el.props["title"] == "" + assert "on_click" not in el.props + + +def test_button_with_callback() -> None: + cb = lambda: None # noqa: E731 + el = Button("Tap", on_click=cb, style={"background_color": "#123456"}) + assert el.props["title"] == "Tap" + assert el.props["on_click"] is cb + assert el.props["background_color"] == "#123456" + + +def test_button_disabled() -> None: + el = Button("Off", enabled=False) + assert el.props["enabled"] is False + + +# --------------------------------------------------------------------------- +# Column / Row +# --------------------------------------------------------------------------- + + +def test_column_with_children() -> None: + el = Column(Text("a"), Text("b"), style={"spacing": 10, "padding": 16, "align_items": "stretch"}) + assert el.type == "Column" + assert len(el.children) == 2 + assert el.props["spacing"] == 10 + assert el.props["padding"] == 16 + assert el.props["align_items"] == "stretch" + assert el.props["flex_direction"] == "column" + + +def test_row_with_children() -> None: + el = Row(Text("x"), Text("y"), style={"spacing": 5}) + assert el.type == "Row" + assert len(el.children) == 2 + assert el.props["spacing"] == 5 + assert el.props["flex_direction"] == "row" + + +def test_column_no_style_has_flex_direction() -> None: + el = Column() + assert el.props == {"flex_direction": "column"} + + +def test_column_layout_via_style() -> None: + el = Column(style={"flex": 2, "margin": {"horizontal": 8}}) + assert el.props["flex"] == 2 + assert el.props["margin"] == {"horizontal": 8} + assert el.props["flex_direction"] == "column" + + +def test_column_justify_content() -> None: + el = Column(style={"justify_content": "center", "align_items": "center"}) + assert el.props["justify_content"] == "center" + assert el.props["align_items"] == "center" + + +def test_row_justify_content() -> None: + el = Row(style={"justify_content": "space_between"}) + assert el.props["justify_content"] == "space_between" + + +def test_column_direction_cannot_be_overridden() -> None: + el = Column(style={"flex_direction": "row"}) + assert el.props["flex_direction"] == "column" + + +def test_row_direction_cannot_be_overridden() -> None: + el = Row(style={"flex_direction": "column"}) + assert el.props["flex_direction"] == "row" + + +# --------------------------------------------------------------------------- +# ScrollView +# --------------------------------------------------------------------------- + + +def test_scrollview_with_child() -> None: + child = Column(Text("a")) + el = ScrollView(child) + assert el.type == "ScrollView" + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_scrollview_empty() -> None: + el = ScrollView() + assert el.children == [] + + +# --------------------------------------------------------------------------- +# TextInput +# --------------------------------------------------------------------------- + + +def test_textinput_defaults() -> None: + el = TextInput() + assert el.type == "TextInput" + assert el.props["value"] == "" + + +def test_textinput_with_props() -> None: + cb = lambda s: None # noqa: E731 + el = TextInput(value="hi", placeholder="type...", on_change=cb, secure=True) + assert el.props["value"] == "hi" + assert el.props["placeholder"] == "type..." + assert el.props["on_change"] is cb + assert el.props["secure"] is True + + +# --------------------------------------------------------------------------- +# Other leaf components +# --------------------------------------------------------------------------- + + +def test_image() -> None: + el = Image("icon.png", style={"width": 48, "height": 48}) + assert el.type == "Image" + assert el.props["source"] == "icon.png" + assert el.props["width"] == 48 + + +def test_switch() -> None: + el = Switch(value=True) + assert el.type == "Switch" + assert el.props["value"] is True + + +def test_progress_bar() -> None: + el = ProgressBar(value=0.5) + assert el.type == "ProgressBar" + assert el.props["value"] == 0.5 + + +def test_activity_indicator() -> None: + el = ActivityIndicator(animating=False) + assert el.type == "ActivityIndicator" + assert el.props["animating"] is False + + +def test_webview() -> None: + el = WebView(url="https://example.com") + assert el.type == "WebView" + assert el.props["url"] == "https://example.com" + + +def test_spacer() -> None: + el = Spacer(size=20) + assert el.type == "Spacer" + assert el.props["size"] == 20 + + +def test_spacer_empty() -> None: + el = Spacer() + assert el.type == "Spacer" + assert el.props == {} + + +# --------------------------------------------------------------------------- +# Key support +# --------------------------------------------------------------------------- + + +def test_key_propagation() -> None: + el = Text("keyed", key="k1") + assert el.key == "k1" + + +def test_column_key() -> None: + el = Column(key="col-1") + assert el.key == "col-1" + + +# --------------------------------------------------------------------------- +# View (flex container) +# --------------------------------------------------------------------------- + + +def test_view_container() -> None: + child = Text("inside") + el = View(child, style={"background_color": "#FFF", "padding": 8, "width": 200}) + assert el.type == "View" + assert len(el.children) == 1 + assert el.props["background_color"] == "#FFF" + assert el.props["padding"] == 8 + assert el.props["width"] == 200 + assert el.props["flex_direction"] == "column" + + +def test_view_default_direction_column() -> None: + el = View() + assert el.props["flex_direction"] == "column" + + +def test_view_direction_override() -> None: + el = View(style={"flex_direction": "row"}) + assert el.props["flex_direction"] == "row" + + +def test_view_flex_props() -> None: + el = View( + Text("a"), + style={ + "flex_direction": "row", + "justify_content": "space_between", + "align_items": "center", + "overflow": "hidden", + }, + ) + assert el.props["flex_direction"] == "row" + assert el.props["justify_content"] == "space_between" + assert el.props["align_items"] == "center" + assert el.props["overflow"] == "hidden" + + +def test_view_flex_grow_shrink() -> None: + el = Text("flex child", style={"flex_grow": 1, "flex_shrink": 0}) + assert el.props["flex_grow"] == 1 + assert el.props["flex_shrink"] == 0 + + +# --------------------------------------------------------------------------- +# Other containers +# --------------------------------------------------------------------------- + + +def test_safe_area_view() -> None: + el = SafeAreaView(Text("safe"), style={"background_color": "#000"}) + assert el.type == "SafeAreaView" + assert len(el.children) == 1 + + +def test_modal() -> None: + cb = lambda: None # noqa: E731 + el = Modal(Text("content"), visible=True, on_dismiss=cb, title="Alert") + assert el.type == "Modal" + assert el.props["visible"] is True + assert el.props["on_dismiss"] is cb + assert el.props["title"] == "Alert" + assert len(el.children) == 1 + + +def test_slider() -> None: + cb = lambda v: None # noqa: E731 + el = Slider(value=0.5, min_value=0, max_value=10, on_change=cb) + assert el.type == "Slider" + assert el.props["value"] == 0.5 + assert el.props["min_value"] == 0 + assert el.props["max_value"] == 10 + assert el.props["on_change"] is cb + + +def test_pressable() -> None: + cb = lambda: None # noqa: E731 + child = Text("tap me") + el = Pressable(child, on_press=cb) + assert el.type == "Pressable" + assert el.props["on_press"] is cb + assert len(el.children) == 1 + + +def test_flat_list_basic() -> None: + el = FlatList( + data=["a", "b", "c"], + render_item=lambda item, i: Text(item), + ) + assert el.type == "ScrollView" + assert len(el.children) == 1 + inner = el.children[0] + assert inner.type == "Column" + assert len(inner.children) == 3 + assert inner.children[0].props["text"] == "a" + + +def test_flat_list_with_keys() -> None: + el = FlatList( + data=[{"id": "x", "name": "X"}, {"id": "y", "name": "Y"}], + render_item=lambda item, i: Text(item["name"]), + key_extractor=lambda item, i: item["id"], + ) + inner = el.children[0] + assert inner.children[0].key == "x" + assert inner.children[1].key == "y" + + +def test_flat_list_empty() -> None: + el = FlatList(data=[], render_item=lambda item, i: Text(str(item))) + inner = el.children[0] + assert len(inner.children) == 0 + + +def test_spacer_flex() -> None: + el = Spacer(flex=1) + assert el.props["flex"] == 1 + + +# ====================================================================== +# ErrorBoundary +# ====================================================================== + + +def test_error_boundary_creates_element() -> None: + child = Text("risky") + fallback = Text("error") + el = ErrorBoundary(child, fallback=fallback) + assert el.type == "__ErrorBoundary__" + assert el.props["__fallback__"] is fallback + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_error_boundary_callable_fallback() -> None: + fn = lambda exc: Text(str(exc)) # noqa: E731 + el = ErrorBoundary(Text("risky"), fallback=fn) + assert callable(el.props["__fallback__"]) + + +def test_error_boundary_no_child() -> None: + el = ErrorBoundary(fallback=Text("empty")) + assert len(el.children) == 0 + + +def test_error_boundary_with_key() -> None: + el = ErrorBoundary(Text("x"), fallback=Text("err"), key="eb1") + assert el.key == "eb1" diff --git a/tests/test_element.py b/tests/test_element.py new file mode 100644 index 0000000..bd9e887 --- /dev/null +++ b/tests/test_element.py @@ -0,0 +1,71 @@ +"""Unit tests for Element descriptors.""" + +from pythonnative.element import Element + + +def test_element_creation() -> None: + el = Element("Text", {"text": "hello"}, []) + assert el.type == "Text" + assert el.props == {"text": "hello"} + assert el.children == [] + assert el.key is None + + +def test_element_with_key() -> None: + el = Element("Text", {}, [], key="abc") + assert el.key == "abc" + + +def test_element_with_children() -> None: + child1 = Element("Text", {"text": "a"}, []) + child2 = Element("Text", {"text": "b"}, []) + parent = Element("Column", {}, [child1, child2]) + assert len(parent.children) == 2 + assert parent.children[0].props["text"] == "a" + assert parent.children[1].props["text"] == "b" + + +def test_element_equality() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Text", {"text": "hi"}, []) + assert a == b + + +def test_element_inequality_type() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Button", {"text": "hi"}, []) + assert a != b + + +def test_element_inequality_props() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Text", {"text": "bye"}, []) + assert a != b + + +def test_element_inequality_children() -> None: + child = Element("Text", {}, []) + a = Element("Column", {}, [child]) + b = Element("Column", {}, []) + assert a != b + + +def test_element_not_equal_to_other_types() -> None: + el = Element("Text", {}, []) + assert el != "not an element" + assert el != 42 + + +def test_element_repr() -> None: + el = Element("Button", {"title": "ok"}, []) + r = repr(el) + assert "Button" in r + assert "children=0" in r + + +def test_deeply_nested_equality() -> None: + leaf = Element("Text", {"text": "x"}, []) + mid = Element("Row", {}, [leaf]) + root_a = Element("Column", {}, [mid]) + root_b = Element("Column", {}, [Element("Row", {}, [Element("Text", {"text": "x"}, [])])]) + assert root_a == root_b diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..1eddbeb --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,724 @@ +"""Unit tests for function components and hooks.""" + +from typing import Any, Dict, List + +from pythonnative.element import Element +from pythonnative.hooks import ( + HookState, + NavigationHandle, + Provider, + _NavigationContext, + _set_hook_state, + batch_updates, + component, + create_context, + use_callback, + use_context, + use_effect, + use_memo, + use_navigation, + use_reducer, + use_ref, + use_state, +) +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend (shared with test_reconciler) +# ====================================================================== + + +class MockView: + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + +class MockBackend: + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, view: MockView, type_name: str, changed: Dict[str, Any]) -> None: + view.props.update(changed) + self.ops.append(("update", type_name, view.id, tuple(sorted(changed.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# use_state +# ====================================================================== + + +def test_use_state_returns_initial_value() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + val, setter = use_state(42) + assert val == 42 + finally: + _set_hook_state(None) + + +def test_use_state_lazy_initialiser() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + val, _ = use_state(lambda: 99) + assert val == 99 + finally: + _set_hook_state(None) + + +def test_use_state_setter_triggers_render() -> None: + renders = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + val, setter = use_state(0) + setter(1) + assert len(renders) == 1 + assert ctx.states[0] == 1 + finally: + _set_hook_state(None) + + +def test_use_state_setter_functional_update() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + _, setter = use_state(10) + _set_hook_state(None) + setter(lambda prev: prev + 5) + assert ctx.states[0] == 15 + finally: + _set_hook_state(None) + + +# ====================================================================== +# use_reducer +# ====================================================================== + + +def test_use_reducer_returns_initial_state() -> None: + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + _set_hook_state(ctx) + try: + state, dispatch = use_reducer(reducer, 42) + assert state == 42 + finally: + _set_hook_state(None) + + +def test_use_reducer_lazy_initial_state() -> None: + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + _set_hook_state(ctx) + try: + state, _ = use_reducer(reducer, lambda: 99) + assert state == 99 + finally: + _set_hook_state(None) + + +def test_use_reducer_dispatch_triggers_render() -> None: + renders: list = [] + + def reducer(state: int, action: str) -> int: + if action == "increment": + return state + 1 + if action == "reset": + return 0 + return state + + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + state, dispatch = use_reducer(reducer, 0) + dispatch("increment") + assert ctx.states[0] == 1 + assert len(renders) == 1 + dispatch("increment") + assert ctx.states[0] == 2 + assert len(renders) == 2 + dispatch("reset") + assert ctx.states[0] == 0 + assert len(renders) == 3 + finally: + _set_hook_state(None) + + +def test_use_reducer_no_render_on_same_state() -> None: + renders: list = [] + + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, dispatch = use_reducer(reducer, 5) + dispatch("noop") + assert len(renders) == 0 + finally: + _set_hook_state(None) + + +def test_use_reducer_in_reconciler() -> None: + captured_dispatch: list = [None] + + def reducer(state: int, action: str) -> int: + if action == "increment": + return state + 1 + return state + + @component + def counter() -> Element: + count, dispatch = use_reducer(reducer, 0) + captured_dispatch[0] = dispatch + return Element("Text", {"text": str(count)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + re_rendered: list = [] + rec._page_re_render = lambda: re_rendered.append(1) + + root = rec.mount(counter()) + assert root.props["text"] == "0" + + dispatch_fn = captured_dispatch[0] + assert dispatch_fn is not None + dispatch_fn("increment") + assert len(re_rendered) == 1 + + +# ====================================================================== +# use_effect (deferred execution) +# ====================================================================== + + +def test_use_effect_is_deferred() -> None: + """Effects are queued during render, not run immediately.""" + calls: list = [] + ctx = HookState() + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("mounted"), []) + assert calls == [], "Effect should NOT run during render" + finally: + _set_hook_state(None) + + ctx.flush_pending_effects() + assert calls == ["mounted"], "Effect should run after flush" + + +def test_use_effect_cleanup_on_rerun() -> None: + cleanups: list = [] + + def make_effect(label: str): # type: ignore[no-untyped-def] + def effect() -> Any: + return lambda: cleanups.append(label) + + return effect + + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(make_effect("first"), None) + finally: + _set_hook_state(None) + ctx.flush_pending_effects() + + ctx.reset_index() + _set_hook_state(ctx) + try: + use_effect(make_effect("second"), None) + finally: + _set_hook_state(None) + ctx.flush_pending_effects() + + assert "first" in cleanups + + +def test_use_effect_skips_with_same_deps() -> None: + calls: list = [] + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("run"), [1, 2]) + finally: + _set_hook_state(None) + ctx.flush_pending_effects() + + ctx.reset_index() + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("run"), [1, 2]) + finally: + _set_hook_state(None) + ctx.flush_pending_effects() + + assert calls == ["run"] + + +def test_use_effect_runs_after_reconciler_mount() -> None: + """Effects run automatically after Reconciler.mount() completes.""" + calls: list = [] + + @component + def my_comp() -> Element: + use_effect(lambda: calls.append("effect"), []) + return Element("Text", {"text": "hi"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp()) + assert calls == ["effect"] + + +def test_use_effect_runs_after_reconciler_reconcile() -> None: + """Effects run automatically after Reconciler.reconcile() completes.""" + calls: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + use_effect(lambda: calls.append(f"effect-{dep}"), [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=0)) + assert calls == ["effect-0"] + + rec.reconcile(my_comp(dep=1)) + assert calls == ["effect-0", "effect-1"] + + +def test_use_effect_cleanup_on_unmount() -> None: + """Cleanup functions run when component is destroyed.""" + cleanups: list = [] + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(lambda: (lambda: cleanups.append("cleaned")), []) + finally: + _set_hook_state(None) + ctx.flush_pending_effects() + + assert cleanups == [] + ctx.cleanup_all_effects() + assert cleanups == ["cleaned"] + + +# ====================================================================== +# batch_updates +# ====================================================================== + + +def test_batch_updates_defers_render() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(0) + _, set_b = use_state(0) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(1) + set_b(2) + assert len(renders) == 0, "Render should be deferred inside batch" + + assert len(renders) == 1, "Exactly one render after batch exits" + + +def test_batch_updates_nested() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(0) + _, set_b = use_state(0) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(1) + with batch_updates(): + set_b(2) + assert len(renders) == 0 + assert len(renders) == 0, "Nested batch should not trigger render" + + assert len(renders) == 1 + + +def test_batch_updates_no_render_when_unchanged() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(5) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(5) + + assert len(renders) == 0 + + +# ====================================================================== +# use_memo / use_callback +# ====================================================================== + + +def test_use_memo_caches() -> None: + calls: list = [] + ctx = HookState() + + def factory_a() -> int: + calls.append(1) + return 42 + + def factory_b() -> int: + calls.append(1) + return 99 + + _set_hook_state(ctx) + try: + val1 = use_memo(factory_a, [1]) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + val2 = use_memo(factory_b, [1]) + finally: + _set_hook_state(None) + + assert val1 == 42 + assert val2 == 42 + assert len(calls) == 1 + + +def test_use_memo_recomputes_on_dep_change() -> None: + ctx = HookState() + + _set_hook_state(ctx) + try: + val1 = use_memo(lambda: "first", ["a"]) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + val2 = use_memo(lambda: "second", ["b"]) + finally: + _set_hook_state(None) + + assert val1 == "first" + assert val2 == "second" + + +def test_use_callback_returns_stable_reference() -> None: + ctx = HookState() + fn = lambda: None # noqa: E731 + + _set_hook_state(ctx) + try: + cb1 = use_callback(fn, [1]) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + cb2 = use_callback(lambda: None, [1]) + finally: + _set_hook_state(None) + + assert cb1 is fn + assert cb2 is fn + + +# ====================================================================== +# use_ref +# ====================================================================== + + +def test_use_ref_persists() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + ref = use_ref(0) + assert ref["current"] == 0 + ref["current"] = 5 + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + ref2 = use_ref(0) + assert ref2["current"] == 5 + assert ref2 is ref + finally: + _set_hook_state(None) + + +# ====================================================================== +# Context +# ====================================================================== + + +def test_create_context_default() -> None: + ctx = create_context("default_val") + assert ctx._current() == "default_val" + + +def test_context_stack() -> None: + ctx = create_context("default") + ctx._stack.append("override") + assert ctx._current() == "override" + ctx._stack.pop() + assert ctx._current() == "default" + + +def test_use_context_reads_current() -> None: + my_ctx = create_context("fallback") + my_ctx._stack.append("active") + hook_state = HookState() + _set_hook_state(hook_state) + try: + val = use_context(my_ctx) + assert val == "active" + finally: + _set_hook_state(None) + my_ctx._stack.pop() + + +# ====================================================================== +# @component decorator +# ====================================================================== + + +def test_component_decorator_creates_element() -> None: + @component + def my_comp(label: str = "hello") -> Element: + return Element("Text", {"text": label}, []) + + el = my_comp(label="world") + assert isinstance(el, Element) + assert el.type is getattr(my_comp, "__wrapped__") + assert el.props == {"label": "world"} + + +def test_component_with_positional_args() -> None: + @component + def greeting(name: str, age: int = 0) -> Element: + return Element("Text", {"text": f"{name}, {age}"}, []) + + el = greeting("Alice", age=30) + assert el.props == {"name": "Alice", "age": 30} + + +def test_component_key_extraction() -> None: + @component + def widget(text: str = "") -> Element: + return Element("Text", {"text": text}, []) + + el = widget(text="hi", key="k1") + assert el.key == "k1" + assert "key" not in el.props + + +# ====================================================================== +# Function components in reconciler +# ====================================================================== + + +def test_reconciler_mounts_function_component() -> None: + @component + def greeting(name: str = "World") -> Element: + return Element("Text", {"text": f"Hello {name}"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + root = rec.mount(greeting(name="Python")) + assert root.type_name == "Text" + assert root.props["text"] == "Hello Python" + + +def test_reconciler_reconciles_function_component() -> None: + @component + def display(value: int = 0) -> Element: + return Element("Text", {"text": str(value)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(display(value=1)) + + backend.ops.clear() + rec.reconcile(display(value=2)) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "text" in update_ops[0][3] + + +def test_function_component_use_state() -> None: + render_count = [0] + captured_setter: list = [None] + + @component + def counter() -> Element: + count, set_count = use_state(0) + render_count[0] += 1 + captured_setter[0] = set_count + return Element("Text", {"text": str(count)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + re_rendered: list = [] + rec._page_re_render = lambda: re_rendered.append(1) + + root = rec.mount(counter()) + assert root.props["text"] == "0" + assert render_count[0] == 1 + + setter_fn = captured_setter[0] + assert setter_fn is not None + setter_fn(5) + assert len(re_rendered) == 1 + + +def test_function_component_preserves_state_across_reconcile() -> None: + @component + def stateful(label: str = "") -> Element: + count, set_count = use_state(0) + return Element("Text", {"text": f"{label}:{count}"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(stateful(label="A")) + + tree_node = rec._tree + assert tree_node is not None + assert tree_node.hook_state is not None + tree_node.hook_state.states[0] = 42 + + rec.reconcile(stateful(label="B")) + assert rec._tree is not None + assert rec._tree.hook_state is not None + assert rec._tree.hook_state.states[0] == 42 + + +# ====================================================================== +# Provider in reconciler +# ====================================================================== + + +def test_provider_in_reconciler() -> None: + theme = create_context("light") + + @component + def themed() -> Element: + t = use_context(theme) + return Element("Text", {"text": t}, []) + + backend = MockBackend() + rec = Reconciler(backend) + el = Provider(theme, "dark", themed()) + root = rec.mount(el) + assert root.props["text"] == "dark" + + +# ====================================================================== +# use_navigation +# ====================================================================== + + +def test_use_navigation_reads_context() -> None: + class FakeHost: + def _get_nav_args(self) -> dict: + return {"id": 42} + + def _push(self, page: Any, args: Any = None) -> None: + pass + + def _pop(self) -> None: + pass + + handle = NavigationHandle(FakeHost()) + _NavigationContext._stack.append(handle) + hook_state = HookState() + _set_hook_state(hook_state) + try: + nav = use_navigation() + assert nav is handle + assert nav.get_params() == {"id": 42} + finally: + _set_hook_state(None) + _NavigationContext._stack.pop() + + +def test_navigation_handle_methods() -> None: + pushed: list = [] + popped: list = [] + + class FakeHost: + def _push(self, page: Any, args: Any = None) -> None: + pushed.append((page, args)) + + def _pop(self) -> None: + popped.append(1) + + def _get_nav_args(self) -> dict: + return {"key": "value"} + + handle = NavigationHandle(FakeHost()) + + handle.navigate("SomePage", params={"x": 1}) + assert pushed == [("SomePage", {"x": 1})] + + handle.go_back() + assert len(popped) == 1 + + assert handle.get_params() == {"key": "value"} diff --git a/tests/test_native_views.py b/tests/test_native_views.py new file mode 100644 index 0000000..41f6080 --- /dev/null +++ b/tests/test_native_views.py @@ -0,0 +1,252 @@ +"""Unit tests for the native_views package. + +Tests the registry, base handler protocol, and shared utility functions. +Platform-specific handlers (android/ios) are not tested here since they +require their respective runtime environments; they are exercised by +E2E tests on device. +""" + +from typing import Any, Dict + +import pytest + +from pythonnative.native_views import NativeViewRegistry, set_registry +from pythonnative.native_views.base import ( + CONTAINER_KEYS, + LAYOUT_KEYS, + ViewHandler, + is_vertical, + parse_color_int, + resolve_padding, +) + +# ====================================================================== +# parse_color_int +# ====================================================================== + + +def test_parse_color_hex6() -> None: + result = parse_color_int("#FF0000") + assert result == parse_color_int("FF0000") + expected = int("FFFF0000", 16) + if expected > 0x7FFFFFFF: + expected -= 0x100000000 + assert result == expected + + +def test_parse_color_hex8() -> None: + result = parse_color_int("#80FF0000") + raw = int("80FF0000", 16) + expected = raw - 0x100000000 # signed conversion + assert result == expected + + +def test_parse_color_int_passthrough() -> None: + assert parse_color_int(0x00FF00) == 0x00FF00 + + +def test_parse_color_signed_conversion() -> None: + result = parse_color_int("#FFFFFFFF") + assert result < 0 + + +def test_parse_color_with_whitespace() -> None: + assert parse_color_int(" #FF0000 ") == parse_color_int("#FF0000") + + +# ====================================================================== +# resolve_padding +# ====================================================================== + + +def test_resolve_padding_none() -> None: + assert resolve_padding(None) == (0, 0, 0, 0) + + +def test_resolve_padding_int() -> None: + assert resolve_padding(16) == (16, 16, 16, 16) + + +def test_resolve_padding_float() -> None: + assert resolve_padding(8.5) == (8, 8, 8, 8) + + +def test_resolve_padding_dict_horizontal_vertical() -> None: + result = resolve_padding({"horizontal": 10, "vertical": 20}) + assert result == (10, 20, 10, 20) + + +def test_resolve_padding_dict_individual() -> None: + result = resolve_padding({"left": 1, "top": 2, "right": 3, "bottom": 4}) + assert result == (1, 2, 3, 4) + + +def test_resolve_padding_dict_all() -> None: + result = resolve_padding({"all": 12}) + assert result == (12, 12, 12, 12) + + +def test_resolve_padding_unsupported_type() -> None: + assert resolve_padding("invalid") == (0, 0, 0, 0) + + +# ====================================================================== +# is_vertical +# ====================================================================== + + +def test_is_vertical_column() -> None: + assert is_vertical("column") is True + + +def test_is_vertical_column_reverse() -> None: + assert is_vertical("column_reverse") is True + + +def test_is_vertical_row() -> None: + assert is_vertical("row") is False + + +def test_is_vertical_row_reverse() -> None: + assert is_vertical("row_reverse") is False + + +# ====================================================================== +# LAYOUT_KEYS / CONTAINER_KEYS +# ====================================================================== + + +def test_layout_keys_contains_expected() -> None: + expected = { + "width", + "height", + "flex", + "flex_grow", + "flex_shrink", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + "position", + "top", + "right", + "bottom", + "left", + } + assert expected == LAYOUT_KEYS + + +def test_container_keys_contains_expected() -> None: + expected = { + "flex_direction", + "justify_content", + "align_items", + "overflow", + "spacing", + "padding", + "background_color", + } + assert expected == CONTAINER_KEYS + + +# ====================================================================== +# ViewHandler protocol +# ====================================================================== + + +def test_view_handler_create_raises() -> None: + handler = ViewHandler() + with pytest.raises(NotImplementedError): + handler.create({}) + + +def test_view_handler_update_raises() -> None: + handler = ViewHandler() + with pytest.raises(NotImplementedError): + handler.update(None, {}) + + +def test_view_handler_add_child_noop() -> None: + handler = ViewHandler() + handler.add_child(None, None) + + +def test_view_handler_remove_child_noop() -> None: + handler = ViewHandler() + handler.remove_child(None, None) + + +def test_view_handler_insert_child_delegates() -> None: + calls: list = [] + + class TestHandler(ViewHandler): + def add_child(self, parent: Any, child: Any) -> None: + calls.append(("add", parent, child)) + + handler = TestHandler() + handler.insert_child("parent", "child", 0) + assert calls == [("add", "parent", "child")] + + +# ====================================================================== +# NativeViewRegistry +# ====================================================================== + + +class StubView: + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + self.type_name = type_name + self.props = dict(props) + + +class StubHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> StubView: + return StubView("Stub", props) + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + native_view.props.update(changed_props) + + +def test_registry_create_view() -> None: + reg = NativeViewRegistry() + reg.register("Text", StubHandler()) + view = reg.create_view("Text", {"text": "hello"}) + assert isinstance(view, StubView) + assert view.props["text"] == "hello" + + +def test_registry_unknown_type_raises() -> None: + reg = NativeViewRegistry() + with pytest.raises(ValueError, match="Unknown element type"): + reg.create_view("NonExistent", {}) + + +def test_registry_update_view() -> None: + reg = NativeViewRegistry() + reg.register("Text", StubHandler()) + view = reg.create_view("Text", {"text": "old"}) + reg.update_view(view, "Text", {"text": "new"}) + assert view.props["text"] == "new" + + +def test_registry_update_unknown_type_noop() -> None: + reg = NativeViewRegistry() + reg.update_view(StubView("X", {}), "X", {"a": 1}) + + +def test_registry_child_ops_unknown_type_noop() -> None: + reg = NativeViewRegistry() + reg.add_child(None, None, "Unknown") + reg.remove_child(None, None, "Unknown") + reg.insert_child(None, None, "Unknown", 0) + + +def test_set_registry_injects() -> None: + reg = NativeViewRegistry() + set_registry(reg) + from pythonnative.native_views import _registry + + assert _registry is reg + set_registry(None) diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..f45ca55 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,846 @@ +"""Comprehensive tests for the declarative navigation system.""" + +from typing import Any, Dict, List + +import pytest + +from pythonnative.element import Element +from pythonnative.hooks import HookState, _NavigationContext, _set_hook_state, component, use_navigation +from pythonnative.navigation import ( + NavigationContainer, + _build_screen_map, + _DeclarativeNavHandle, + _DrawerNavHandle, + _FocusContext, + _RouteEntry, + _ScreenDef, + _TabNavHandle, + create_drawer_navigator, + create_stack_navigator, + create_tab_navigator, + use_focus_effect, + use_route, +) +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend (same as test_reconciler / test_hooks) +# ====================================================================== + + +class MockView: + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + +class MockBackend: + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, view: MockView, type_name: str, changed: Dict[str, Any]) -> None: + view.props.update(changed) + self.ops.append(("update", type_name, view.id, tuple(sorted(changed.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# Data structures +# ====================================================================== + + +def test_screen_def_creation() -> None: + s = _ScreenDef("Home", lambda: None, {"title": "Home"}) + assert s.name == "Home" + assert s.options == {"title": "Home"} + assert "Home" in repr(s) + + +def test_screen_def_defaults() -> None: + s = _ScreenDef("Detail", lambda: None) + assert s.options == {} + + +def test_route_entry() -> None: + r = _RouteEntry("Home", {"id": 42}) + assert r.name == "Home" + assert r.params == {"id": 42} + assert "Home" in repr(r) + + +def test_route_entry_defaults() -> None: + r = _RouteEntry("Home") + assert r.params == {} + + +def test_build_screen_map() -> None: + screens = [ + _ScreenDef("A", lambda: None), + _ScreenDef("B", lambda: None), + "not a screen", + ] + result = _build_screen_map(screens) + assert set(result.keys()) == {"A", "B"} + + +def test_build_screen_map_empty() -> None: + assert _build_screen_map(None) == {} + assert _build_screen_map([]) == {} + + +# ====================================================================== +# _DeclarativeNavHandle +# ====================================================================== + + +def test_declarative_handle_navigate() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home")] + screens = {"Home": _ScreenDef("Home", lambda: None), "Detail": _ScreenDef("Detail", lambda: None)} + + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle(screens, lambda: stack, set_stack) + handle.navigate("Detail", {"id": 5}) + + assert len(captured) == 1 + assert captured[0][-1].name == "Detail" + assert captured[0][-1].params == {"id": 5} + + +def test_declarative_handle_go_back() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home"), _RouteEntry("Detail")] + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle({}, lambda: stack, set_stack) + handle.go_back() + + assert len(captured[-1]) == 1 + assert captured[-1][0].name == "Home" + + +def test_declarative_handle_go_back_stops_at_root() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home")] + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle({}, lambda: stack, set_stack) + handle.go_back() + + assert captured == [] + assert len(stack) == 1 + assert stack[0].name == "Home" + + +def test_declarative_handle_get_params() -> None: + stack = [_RouteEntry("Home"), _RouteEntry("Detail", {"id": 42})] + handle = _DeclarativeNavHandle({}, lambda: stack, lambda _: None) + + assert handle.get_params() == {"id": 42} + + +def test_declarative_handle_get_params_empty_stack() -> None: + handle = _DeclarativeNavHandle({}, lambda: [], lambda _: None) + assert handle.get_params() == {} + + +def test_declarative_handle_reset() -> None: + stack: List[_RouteEntry] = [_RouteEntry("A"), _RouteEntry("B"), _RouteEntry("C")] + screens = {"Home": _ScreenDef("Home", lambda: None)} + captured: list = [] + + def set_stack(val: Any) -> None: + captured.append(val) + + handle = _DeclarativeNavHandle(screens, lambda: stack, set_stack) + handle.reset("Home", {"fresh": True}) + + assert len(captured) == 1 + assert len(captured[0]) == 1 + assert captured[0][0].name == "Home" + + +def test_declarative_handle_navigate_unknown_raises() -> None: + handle = _DeclarativeNavHandle({"Home": _ScreenDef("Home", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.navigate("Missing") + + +def test_declarative_handle_reset_unknown_raises() -> None: + handle = _DeclarativeNavHandle({"Home": _ScreenDef("Home", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.reset("Missing") + + +# ====================================================================== +# Tab nav handle +# ====================================================================== + + +def test_tab_handle_navigate_switches_tab() -> None: + switched: list = [] + screens = {"A": _ScreenDef("A", lambda: None), "B": _ScreenDef("B", lambda: None)} + + def switch_tab(name: str, params: Any = None) -> None: + switched.append((name, params)) + + handle = _TabNavHandle(screens, lambda: [], lambda _: None, switch_tab) + handle.navigate("B", {"x": 1}) + + assert switched == [("B", {"x": 1})] + + +# ====================================================================== +# Drawer nav handle +# ====================================================================== + + +def test_drawer_handle_open_close_toggle() -> None: + drawer_state = [False] + + def set_open(val: bool) -> None: + drawer_state[0] = val + + screens = {"A": _ScreenDef("A", lambda: None)} + + def noop_switch(n: str, p: Any = None) -> None: + pass + + handle = _DrawerNavHandle(screens, lambda: [], lambda _: None, noop_switch, set_open, lambda: drawer_state[0]) + + handle.open_drawer() + assert drawer_state[0] is True + + handle.close_drawer() + assert drawer_state[0] is False + + handle.toggle_drawer() + assert drawer_state[0] is True + + handle.toggle_drawer() + assert drawer_state[0] is False + + +def test_drawer_handle_navigate_closes_drawer() -> None: + drawer_state = [True] + switched: list = [] + + def set_open(val: bool) -> None: + drawer_state[0] = val + + def switch_screen(name: str, params: Any = None) -> None: + switched.append(name) + + screens = {"A": _ScreenDef("A", lambda: None), "B": _ScreenDef("B", lambda: None)} + handle = _DrawerNavHandle(screens, lambda: [], lambda _: None, switch_screen, set_open, lambda: drawer_state[0]) + + handle.navigate("B") + assert switched == ["B"] + assert drawer_state[0] is False + + +# ====================================================================== +# NavigationContainer +# ====================================================================== + + +def test_navigation_container_wraps_child() -> None: + child = Element("Text", {"text": "hi"}, []) + el = NavigationContainer(child) + assert el.type == "View" + assert el.props.get("flex") == 1 + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_navigation_container_with_key() -> None: + child = Element("Text", {"text": "hi"}, []) + el = NavigationContainer(child, key="nav") + assert el.key == "nav" + + +# ====================================================================== +# create_stack_navigator +# ====================================================================== + + +def test_stack_screen_creates_screen_def() -> None: + Stack = create_stack_navigator() + s = Stack.Screen("Home", component=lambda: None, options={"title": "Home"}) + assert isinstance(s, _ScreenDef) + assert s.name == "Home" + + +def test_stack_navigator_element() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) + assert isinstance(el, Element) + assert callable(el.type) + + +def test_stack_navigator_renders_initial_screen() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + root = rec.mount(el) + + assert any(op[0] == "create" and op[1] == "Text" for op in backend.ops) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "home" + + +def test_stack_navigator_respects_initial_route() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Detail", + ) + root = rec.mount(el) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "detail" + + +def test_stack_navigator_provides_navigation_context() -> None: + Stack = create_stack_navigator() + captured_nav: list = [None] + + @component + def HomeScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "home"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) + rec.mount(el) + + assert captured_nav[0] is not None + assert hasattr(captured_nav[0], "navigate") + assert hasattr(captured_nav[0], "go_back") + assert hasattr(captured_nav[0], "get_params") + + +def test_stack_navigator_empty_screens() -> None: + Stack = create_stack_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# create_tab_navigator +# ====================================================================== + + +def test_tab_screen_creates_screen_def() -> None: + Tab = create_tab_navigator() + s = Tab.Screen("Home", component=lambda: None, options={"title": "Home"}) + assert isinstance(s, _ScreenDef) + + +def test_tab_navigator_renders_initial_screen() -> None: + Tab = create_tab_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def SettingsScreen() -> Element: + return Element("Text", {"text": "settings"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + root = rec.mount(el) + + def find_texts(view: MockView) -> list: + result = [] + if view.type_name == "Text": + result.append(view.props.get("text")) + for c in view.children: + result.extend(find_texts(c)) + return result + + texts = find_texts(root) + assert "home" in texts + + +def test_tab_navigator_renders_native_tab_bar() -> None: + Tab = create_tab_navigator() + + @component + def ScreenA() -> Element: + return Element("Text", {"text": "a"}, []) + + @component + def ScreenB() -> Element: + return Element("Text", {"text": "b"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("TabA", component=ScreenA, options={"title": "Tab A"}), + Tab.Screen("TabB", component=ScreenB, options={"title": "Tab B"}), + ) + root = rec.mount(el) + + def find_tab_bar(view: MockView) -> Any: + if view.type_name == "TabBar": + return view + for c in view.children: + r = find_tab_bar(c) + if r is not None: + return r + return None + + tab_bar = find_tab_bar(root) + assert tab_bar is not None + assert tab_bar.props["items"] == [ + {"name": "TabA", "title": "Tab A"}, + {"name": "TabB", "title": "Tab B"}, + ] + assert tab_bar.props["active_tab"] == "TabA" + assert callable(tab_bar.props["on_tab_select"]) + + +def test_tab_navigator_empty_screens() -> None: + Tab = create_tab_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# create_drawer_navigator +# ====================================================================== + + +def test_drawer_screen_creates_screen_def() -> None: + Drawer = create_drawer_navigator() + s = Drawer.Screen("Home", component=lambda: None) + assert isinstance(s, _ScreenDef) + + +def test_drawer_navigator_renders_initial_screen() -> None: + Drawer = create_drawer_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def SettingsScreen() -> Element: + return Element("Text", {"text": "settings"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen), + Drawer.Screen("Settings", component=SettingsScreen), + ) + root = rec.mount(el) + + def find_texts(view: MockView) -> list: + result = [] + if view.type_name == "Text": + result.append(view.props.get("text")) + for c in view.children: + result.extend(find_texts(c)) + return result + + texts = find_texts(root) + assert "home" in texts + + +def test_drawer_navigator_empty_screens() -> None: + Drawer = create_drawer_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Drawer.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# use_route +# ====================================================================== + + +def test_use_route_returns_params() -> None: + stack = [_RouteEntry("Home"), _RouteEntry("Detail", {"id": 99})] + screens = {"Home": _ScreenDef("Home", lambda: None), "Detail": _ScreenDef("Detail", lambda: None)} + handle = _DeclarativeNavHandle(screens, lambda: stack, lambda _: None) + + _NavigationContext._stack.append(handle) + ctx = HookState() + _set_hook_state(ctx) + try: + params = use_route() + assert params == {"id": 99} + finally: + _set_hook_state(None) + _NavigationContext._stack.pop() + + +def test_use_route_no_context() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + params = use_route() + assert params == {} + finally: + _set_hook_state(None) + + +# ====================================================================== +# use_focus_effect +# ====================================================================== + + +def test_use_focus_effect_runs_when_focused() -> None: + calls: list = [] + + _FocusContext._stack.append(True) + ctx = HookState() + _set_hook_state(ctx) + try: + use_focus_effect(lambda: calls.append("focused"), []) + finally: + _set_hook_state(None) + _FocusContext._stack.pop() + + ctx.flush_pending_effects() + assert calls == ["focused"] + + +def test_use_focus_effect_skips_when_not_focused() -> None: + calls: list = [] + + _FocusContext._stack.append(False) + ctx = HookState() + _set_hook_state(ctx) + try: + use_focus_effect(lambda: calls.append("focused"), []) + finally: + _set_hook_state(None) + _FocusContext._stack.pop() + + ctx.flush_pending_effects() + assert calls == [] + + +# ====================================================================== +# Integration: stack navigator with reconciler +# ====================================================================== + + +def test_stack_navigator_navigate_and_go_back() -> None: + Stack = create_stack_navigator() + captured_nav: list = [None] + + @component + def HomeScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + renders: list = [] + rec._page_re_render = lambda: renders.append(1) + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + rec.mount(el) + + nav = captured_nav[0] + assert nav is not None + + nav.navigate("Detail", {"id": 1}) + assert len(renders) == 1 + + +def test_stack_navigator_with_navigation_container() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = NavigationContainer(Stack.Navigator(Stack.Screen("Home", component=HomeScreen))) + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# Parent forwarding (nested navigators) +# ====================================================================== + + +def test_declarative_handle_forwards_navigate_to_parent() -> None: + """Unknown routes in a child navigator forward to the parent.""" + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + child_screens = {"A": _ScreenDef("A", lambda: None)} + handle = _DeclarativeNavHandle(child_screens, lambda: [_RouteEntry("A")], lambda _: None, parent=_MockParent()) + + handle.navigate("UnknownRoute", {"key": "value"}) + assert parent_calls == [("navigate", "UnknownRoute", {"key": "value"})] + + +def test_declarative_handle_forwards_go_back_at_root() -> None: + """go_back at the root of a child navigator forwards to the parent.""" + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + child_screens = {"A": _ScreenDef("A", lambda: None)} + stack: List[_RouteEntry] = [_RouteEntry("A")] + handle = _DeclarativeNavHandle(child_screens, lambda: stack, lambda _: None, parent=_MockParent()) + + handle.go_back() + assert parent_calls == [("go_back",)] + + +def test_declarative_handle_no_parent_raises_on_unknown() -> None: + """Without a parent, unknown routes still raise ValueError.""" + handle = _DeclarativeNavHandle({"A": _ScreenDef("A", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.navigate("Missing") + + +def test_tab_handle_forwards_unknown_to_parent() -> None: + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + screens = {"TabA": _ScreenDef("TabA", lambda: None)} + + def noop_switch(name: str, params: Any = None) -> None: + pass + + handle = _TabNavHandle(screens, lambda: [], lambda _: None, noop_switch, parent=_MockParent()) + handle.navigate("ExternalRoute", {"x": 1}) + assert parent_calls == [("navigate", "ExternalRoute", {"x": 1})] + + +def test_drawer_handle_forwards_unknown_to_parent() -> None: + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + screens = {"DrawerA": _ScreenDef("DrawerA", lambda: None)} + + def noop_switch(n: str, p: Any = None) -> None: + pass + + handle = _DrawerNavHandle( + screens, lambda: [], lambda _: None, noop_switch, lambda _: None, lambda: False, parent=_MockParent() + ) + handle.navigate("ExternalRoute") + assert parent_calls == [("navigate", "ExternalRoute", None)] + + +def test_stack_inside_tab_forwards_to_parent() -> None: + """A Stack.Navigator nested inside a Tab.Navigator can forward.""" + Stack = create_stack_navigator() + Tab = create_tab_navigator() + + captured_nav: list = [None] + + @component + def InnerScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "inner"}, []) + + @component + def InnerStack() -> Element: + return Stack.Navigator(Stack.Screen("Inner", component=InnerScreen)) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("TabA", component=InnerStack), + Tab.Screen("TabB", component=lambda: Element("Text", {"text": "b"}, [])), + ) + rec.mount(el) + + nav = captured_nav[0] + assert nav is not None + + nav.navigate("TabB") + assert True # no error means forwarding worked + + +# ====================================================================== +# Public API surface +# ====================================================================== + + +def test_navigation_exports_from_package() -> None: + import pythonnative as pn + + assert hasattr(pn, "NavigationContainer") + assert hasattr(pn, "create_stack_navigator") + assert hasattr(pn, "create_tab_navigator") + assert hasattr(pn, "create_drawer_navigator") + assert hasattr(pn, "use_route") + assert hasattr(pn, "use_focus_effect") diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py new file mode 100644 index 0000000..08ac250 --- /dev/null +++ b/tests/test_reconciler.py @@ -0,0 +1,564 @@ +"""Unit tests for the reconciler using a mock native backend.""" + +from typing import Any, Dict, List + +import pytest + +from pythonnative.element import Element +from pythonnative.hooks import component +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend +# ====================================================================== + + +class MockView: + """Simulates a native view for testing.""" + + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + def __repr__(self) -> str: + return f"MockView({self.type_name}#{self.id})" + + +class MockBackend: + """Records operations for assertions.""" + + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, native_view: MockView, type_name: str, changed_props: Dict[str, Any]) -> None: + native_view.props.update(changed_props) + self.ops.append(("update", type_name, native_view.id, tuple(sorted(changed_props.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# Tests: mount +# ====================================================================== + + +def test_mount_single_element() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element("Text", {"text": "hello"}, []) + root = rec.mount(el) + assert isinstance(root, MockView) + assert root.type_name == "Text" + assert root.props["text"] == "hello" + + +def test_mount_nested_elements() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element( + "Column", + {}, + [ + Element("Text", {"text": "a"}, []), + Element("Button", {"title": "b"}, []), + ], + ) + root = rec.mount(el) + assert root.type_name == "Column" + assert len(root.children) == 2 + assert root.children[0].type_name == "Text" + assert root.children[1].type_name == "Button" + + +def test_mount_deeply_nested() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element( + "ScrollView", + {}, + [ + Element( + "Column", + {}, + [ + Element("Text", {"text": "deep"}, []), + ], + ), + ], + ) + root = rec.mount(el) + assert root.children[0].children[0].props["text"] == "deep" + + +# ====================================================================== +# Tests: reconcile (update props) +# ====================================================================== + + +def test_reconcile_updates_props() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "hello"}, []) + rec.mount(el1) + + backend.ops.clear() + el2 = Element("Text", {"text": "world"}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "text" in update_ops[0][3] + + +def test_reconcile_no_change_no_update() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element("Text", {"text": "same"}, []) + rec.mount(el) + + backend.ops.clear() + rec.reconcile(Element("Text", {"text": "same"}, [])) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 0 + + +# ====================================================================== +# Tests: reconcile children (add / remove) +# ====================================================================== + + +def test_reconcile_add_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + root = rec.mount(el1) + assert len(root.children) == 1 + + backend.ops.clear() + el2 = Element( + "Column", + {}, + [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])], + ) + rec.reconcile(el2) + + assert len(root.children) == 2 + + +def test_reconcile_remove_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element( + "Column", + {}, + [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])], + ) + root = rec.mount(el1) + assert len(root.children) == 2 + + backend.ops.clear() + el2 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + rec.reconcile(el2) + + assert len(root.children) == 1 + + +def test_reconcile_replace_child_type() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + root = rec.mount(el1) + + backend.ops.clear() + el2 = Element("Column", {}, [Element("Button", {"title": "b"}, [])]) + rec.reconcile(el2) + + assert root.children[0].type_name == "Button" + + +# ====================================================================== +# Tests: reconcile root type change +# ====================================================================== + + +def test_reconcile_root_type_change() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "a"}, []) + root1 = rec.mount(el1) + + el2 = Element("Button", {"title": "b"}, []) + root2 = rec.reconcile(el2) + assert root2.type_name == "Button" + assert root2 is not root1 + + +# ====================================================================== +# Tests: callback props always counted as changed +# ====================================================================== + + +def test_reconcile_callback_always_updated() -> None: + backend = MockBackend() + rec = Reconciler(backend) + cb1 = lambda: None # noqa: E731 + cb2 = lambda: None # noqa: E731 + el1 = Element("Button", {"title": "x", "on_click": cb1}, []) + rec.mount(el1) + + backend.ops.clear() + el2 = Element("Button", {"title": "x", "on_click": cb2}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "on_click" in update_ops[0][3] + + +# ====================================================================== +# Tests: removed props signalled as None +# ====================================================================== + + +def test_reconcile_removed_prop_becomes_none() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "hi", "color": "#FF0000"}, []) + root = rec.mount(el1) + + backend.ops.clear() + el2 = Element("Text", {"text": "hi"}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "color" in update_ops[0][3] + assert root.props.get("color") is None + + +# ====================================================================== +# Tests: complex multi-step reconciliation +# ====================================================================== + + +def test_multiple_reconcile_cycles() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + rec.mount(Element("Column", {}, [Element("Text", {"text": "0"}, [])])) + + for i in range(1, 5): + rec.reconcile(Element("Column", {}, [Element("Text", {"text": str(i)}, [])])) + + assert rec._tree is not None + assert rec._tree.children[0].element.props["text"] == "4" + + +# ====================================================================== +# Tests: key-based reconciliation +# ====================================================================== + + +def test_keyed_children_preserve_identity() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + el1 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + root = rec.mount(el1) + view_a = rec._tree.children[0].native_view + view_b = rec._tree.children[1].native_view + view_c = rec._tree.children[2].native_view + + backend.ops.clear() + el2 = Element( + "Column", + {}, + [ + Element("Text", {"text": "C"}, [], key="c"), + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + ], + ) + rec.reconcile(el2) + + assert rec._tree.children[0].native_view is view_c + assert rec._tree.children[1].native_view is view_a + assert rec._tree.children[2].native_view is view_b + + # Native children must also reflect the new order + assert root.children[0] is view_c + assert root.children[1] is view_a + assert root.children[2] is view_b + + +def test_keyed_children_remove_by_key() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + el1 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.mount(el1) + + el2 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.reconcile(el2) + + assert len(rec._tree.children) == 2 + assert rec._tree.children[0].element.key == "a" + assert rec._tree.children[1].element.key == "c" + + +def test_keyed_children_insert_new() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + el1 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.mount(el1) + + el2 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.reconcile(el2) + + assert len(rec._tree.children) == 3 + assert rec._tree.children[1].element.key == "b" + + +# ====================================================================== +# Tests: error boundaries +# ====================================================================== + + +def test_error_boundary_catches_mount_error() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad_component(**props: Any) -> Element: + raise ValueError("boom") + + fallback = Element("Text", {"text": "error caught"}, []) + child = Element(bad_component, {}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert root.props["text"] == "error caught" + + +def test_error_boundary_callable_fallback() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad_component(**props: Any) -> Element: + raise RuntimeError("oops") + + def fallback_fn(exc: Exception) -> Element: + return Element("Text", {"text": f"caught: {exc}"}, []) + + child = Element(bad_component, {}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert "caught: oops" in root.props["text"] + + +def test_error_boundary_no_error_renders_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + child = Element("Text", {"text": "ok"}, []) + fallback = Element("Text", {"text": "error"}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert root.props["text"] == "ok" + + +def test_error_boundary_catches_reconcile_error() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + call_count = [0] + + @component + def flaky() -> Element: + call_count[0] += 1 + if call_count[0] > 1: + raise RuntimeError("reconcile boom") + return Element("Text", {"text": "ok"}, []) + + def fallback_fn(exc: Exception) -> Element: + return Element("Text", {"text": f"recovered: {exc}"}, []) + + eb1 = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [flaky()]) + root = rec.mount(eb1) + assert root.props["text"] == "ok" + + eb2 = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [flaky()]) + root = rec.reconcile(eb2) + assert "recovered" in root.props["text"] + + +def test_error_boundary_without_fallback_propagates() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad(**props: Any) -> Element: + raise ValueError("no fallback") + + child = Element(bad, {}, []) + eb = Element("__ErrorBoundary__", {}, [child]) + + with pytest.raises(ValueError, match="no fallback"): + rec.mount(eb) + + +# ====================================================================== +# Tests: post-render effect flushing +# ====================================================================== + + +def test_effects_flushed_after_mount() -> None: + calls: list = [] + + @component + def my_comp() -> Element: + from pythonnative.hooks import use_effect + + use_effect(lambda: calls.append("mounted"), []) + return Element("Text", {"text": "hi"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp()) + assert calls == ["mounted"] + + +def test_effects_flushed_after_reconcile() -> None: + calls: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + from pythonnative.hooks import use_effect + + use_effect(lambda: calls.append(f"e{dep}"), [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=1)) + assert calls == ["e1"] + + rec.reconcile(my_comp(dep=2)) + assert calls == ["e1", "e2"] + + +def test_effect_cleanup_runs_on_rerun() -> None: + log: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + from pythonnative.hooks import use_effect + + def effect() -> Any: + log.append(f"run-{dep}") + return lambda: log.append(f"cleanup-{dep}") + + use_effect(effect, [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=1)) + assert log == ["run-1"] + + rec.reconcile(my_comp(dep=2)) + assert log == ["run-1", "cleanup-1", "run-2"] + + +def test_provider_child_native_view_swap() -> None: + """When a Provider wraps different component types across renders, + the parent native container must swap the old native subview for the new one.""" + from pythonnative.hooks import Provider, create_context + + ctx = create_context(None) + + @component + def CompA() -> Element: + return Element("Text", {"text": "A"}, []) + + @component + def CompB() -> Element: + return Element("Text", {"text": "B"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + + tree1 = Element("View", {}, [Provider(ctx, "v1", CompA())]) + root = rec.mount(tree1) + assert len(root.children) == 1 + assert root.children[0].props["text"] == "A" + old_child_id = root.children[0].id + + tree2 = Element("View", {}, [Provider(ctx, "v2", CompB())]) + rec.reconcile(tree2) + assert len(root.children) == 1 + assert root.children[0].props["text"] == "B" + assert root.children[0].id != old_child_id diff --git a/tests/test_smoke.py b/tests/test_smoke.py index f5c04c4..23c865a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,2 +1,63 @@ -def test_pytest_workflow_smoke(): - assert 2 + 2 == 4 +"""Verify the package is importable and exports the public API.""" + +import pythonnative as pn +from pythonnative.element import Element + + +def test_package_version() -> None: + assert pn.__version__ + + +def test_element_class_exported() -> None: + assert pn.Element is Element + + +def test_public_api_names() -> None: + expected = { + "ActivityIndicator", + "Button", + "Column", + "Element", + "ErrorBoundary", + "FlatList", + "Image", + "Modal", + "Pressable", + "ProgressBar", + "Row", + "SafeAreaView", + "ScrollView", + "Slider", + "Spacer", + "Switch", + "Text", + "TextInput", + "View", + "WebView", + # Core + "create_page", + # Hooks + "batch_updates", + "component", + "create_context", + "use_callback", + "use_context", + "use_effect", + "use_focus_effect", + "use_memo", + "use_navigation", + "use_reducer", + "use_ref", + "use_route", + "use_state", + "Provider", + # Navigation + "NavigationContainer", + "create_drawer_navigator", + "create_stack_navigator", + "create_tab_navigator", + # Styling + "StyleSheet", + "ThemeContext", + } + assert expected.issubset(set(pn.__all__)) diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..a0687d2 --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,80 @@ +"""Unit tests for StyleSheet, resolve_style, and theming.""" + +from pythonnative.style import ( + DEFAULT_DARK_THEME, + DEFAULT_LIGHT_THEME, + StyleSheet, + ThemeContext, + resolve_style, +) + + +def test_resolve_style_none() -> None: + assert resolve_style(None) == {} + + +def test_resolve_style_dict() -> None: + result = resolve_style({"font_size": 20, "color": "#000"}) + assert result == {"font_size": 20, "color": "#000"} + + +def test_resolve_style_list() -> None: + base = {"font_size": 16, "color": "#000"} + override = {"color": "#FFF", "bold": True} + result = resolve_style([base, override]) + assert result == {"font_size": 16, "color": "#FFF", "bold": True} + + +def test_resolve_style_list_with_none_entries() -> None: + result = resolve_style([None, {"a": 1}, None, {"b": 2}]) + assert result == {"a": 1, "b": 2} + + +def test_stylesheet_create() -> None: + styles = StyleSheet.create( + heading={"font_size": 28, "bold": True}, + body={"font_size": 16}, + ) + assert "heading" in styles + assert styles["heading"]["font_size"] == 28 + assert styles["body"]["font_size"] == 16 + + +def test_stylesheet_compose() -> None: + base = {"font_size": 16, "color": "#000"} + override = {"color": "#FFF", "bold": True} + merged = StyleSheet.compose(base, override) + assert merged["font_size"] == 16 + assert merged["color"] == "#FFF" + assert merged["bold"] is True + + +def test_stylesheet_compose_none_safe() -> None: + result = StyleSheet.compose(None, {"a": 1}, None) + assert result == {"a": 1} + + +def test_stylesheet_flatten_dict() -> None: + result = StyleSheet.flatten({"font_size": 20}) + assert result == {"font_size": 20} + + +def test_stylesheet_flatten_list() -> None: + result = StyleSheet.flatten([{"a": 1}, {"b": 2}]) + assert result == {"a": 1, "b": 2} + + +def test_stylesheet_flatten_none() -> None: + result = StyleSheet.flatten(None) + assert result == {} + + +def test_theme_context_has_default() -> None: + val = ThemeContext._current() + assert val is DEFAULT_LIGHT_THEME + assert "primary_color" in val + + +def test_light_and_dark_themes_differ() -> None: + assert DEFAULT_LIGHT_THEME["background_color"] != DEFAULT_DARK_THEME["background_color"] + assert DEFAULT_LIGHT_THEME["text_color"] != DEFAULT_DARK_THEME["text_color"]