From 298f884ce3e1c58a17c92484c5832ebae6f1beaa Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:52:06 -0700 Subject: [PATCH 01/11] style(cli): reformat pn.py for Black 2026 stable style --- src/pythonnative/cli/pn.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 22848c4..491381e 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -44,8 +44,7 @@ def init_project(args: argparse.Namespace) -> None: 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 + f.write("""import pythonnative as pn class MainPage(pn.Page): @@ -67,8 +66,7 @@ def render(self): alignment="fill", ) ) -""" - ) +""") # Create config config = { From 01d19683f41a4b00048dfbce687e510bec2e2d31 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:59:52 -0700 Subject: [PATCH 02/11] ci(workflows,cli): fix e2e workflow script chaining and GitHub API auth --- .github/workflows/e2e.yml | 13 +++++++------ src/pythonnative/cli/pn.py | 6 +++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0f8db75..7935a34 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -40,12 +40,11 @@ jobs: with: api-level: 31 arch: x86_64 - script: | - export PATH="$HOME/.maestro/bin:$PATH" - cd examples/hello-world - pn run android - sleep 5 - cd ../.. + script: >- + cd examples/hello-world && + pn run android && + sleep 5 && + cd ../.. && maestro test tests/e2e/android.yaml e2e-ios: @@ -73,6 +72,8 @@ jobs: - 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/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 491381e..12485c5 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -156,7 +156,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")) From 552cd9958c463a51af9e33f0e254dab18135130f Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:19:05 -0700 Subject: [PATCH 03/11] build(deps): drop Python 3.9 support (EOL October 2025) --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 +- pyproject.toml | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e801b3..27db41c 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'] steps: - name: Checkout diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78e1a10..a4f3201 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) diff --git a/pyproject.toml b/pyproject.toml index 049088c..1a00c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ 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", From 4ff6b9453a7687eeaf7777bf4a2ab542b32a7e25 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:26:52 -0700 Subject: [PATCH 04/11] test: increase app startup wait for slow CI emulators --- tests/e2e/flows/main_page.yaml | 4 +++- tests/e2e/flows/navigation.yaml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/flows/main_page.yaml b/tests/e2e/flows/main_page.yaml index 43c184d..9c7ab0c 100644 --- a/tests/e2e/flows/main_page.yaml +++ b/tests/e2e/flows/main_page.yaml @@ -2,7 +2,9 @@ appId: ${APP_ID} --- # Verify main page renders correctly and the counter works. - launchApp -- assertVisible: "Hello from PythonNative Demo!" +- extendedWaitUntil: + visible: "Hello from PythonNative Demo!" + timeout: 30000 - assertVisible: "Tapped 0 times" - assertVisible: "Tap me" - assertVisible: "Go to Second Page" diff --git a/tests/e2e/flows/navigation.yaml b/tests/e2e/flows/navigation.yaml index 6e1b521..f71a7c9 100644 --- a/tests/e2e/flows/navigation.yaml +++ b/tests/e2e/flows/navigation.yaml @@ -2,7 +2,9 @@ appId: ${APP_ID} --- # Navigate through all three pages and back to main. - launchApp -- assertVisible: "Hello from PythonNative Demo!" +- extendedWaitUntil: + visible: "Hello from PythonNative Demo!" + timeout: 30000 - tapOn: "Go to Second Page" - assertVisible: "Greetings from MainPage" - assertVisible: "Go to Third Page" From ecc39af78708bc5a83ba81501c7b65d985890de9 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:32:59 -0700 Subject: [PATCH 05/11] docs(repo): align conventional commit scopes with module structure --- CONTRIBUTING.md | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4f3201..b05ec9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,27 +94,27 @@ 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`) + - `native_views` – platform-specific native view creation and updates (`native_views.py`) + - `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`) + - `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 +124,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): From c6e0e08cb0757dad6495c6fee36063699afba87a Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:06:05 -0700 Subject: [PATCH 06/11] docs: align branch prefixes with conventional commit types --- CONTRIBUTING.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b05ec9e..cfc287d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -229,8 +229,8 @@ Co-authored-by: Name ### Branch naming (suggested) - Use lowercase kebab‑case; concise (≤ 40 chars). -- Prefix conventions: - - `feature/-` +- Branch prefixes match Conventional Commit types: + - `feat/-` - `fix/-` - `chore/` - `docs/` @@ -239,21 +239,18 @@ 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) From ab162c5b658b2367857ab998d3b3f750eca15b4a Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:21:03 -0700 Subject: [PATCH 07/11] docs(repo): Remove component table from README --- README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/README.md b/README.md index d5c0706..b0c0d9a 100644 --- a/README.md +++ b/README.md @@ -69,22 +69,6 @@ class MainPage(pn.Page): ) ``` -### Available Components - -| Component | Description | -|---|---| -| `Text` | Display text | -| `Button` | Tappable button with `on_click` callback | -| `Column` / `Row` | Vertical / horizontal layout containers | -| `ScrollView` | Scrollable wrapper | -| `TextInput` | Text entry field with `on_change` callback | -| `Image` | Display images | -| `Switch` | Toggle with `on_change` callback | -| `ProgressBar` | Determinate progress (0.0–1.0) | -| `ActivityIndicator` | Indeterminate loading spinner | -| `WebView` | Embedded web content | -| `Spacer` | Empty space | - ## Documentation 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. From a529834a7bfe817a51ef2a5846c97c2f4deee321 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:16:22 -0700 Subject: [PATCH 08/11] feat(cli,templates): add pythonVersion config, fix Android build, and wire pip requirements --- .github/workflows/ci.yml | 2 +- examples/hello-world/app/main_page.py | 9 +- examples/hello-world/pythonnative.json | 1 + examples/hello-world/requirements.txt | 2 +- src/pythonnative/cli/pn.py | 97 ++++++++++++++++++- .../android_template/app/build.gradle | 9 +- .../templates/android_template/build.gradle | 2 +- 7 files changed, 106 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27db41c..03d9985 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.10', '3.11'] + python-version: ['3.10', '3.11', '3.12'] steps: - name: Checkout diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 9faa74f..b281d92 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,7 +1,11 @@ from typing import Any +import emoji + import pythonnative as pn +MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] + class MainPage(pn.Page): def __init__(self, native_instance: Any) -> None: @@ -12,10 +16,13 @@ def increment(self) -> None: self.set_state(count=self.state["count"] + 1) def render(self) -> pn.Element: + count = self.state["count"] + medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") return pn.ScrollView( pn.Column( pn.Text("Hello from PythonNative Demo!", font_size=24, bold=True), - pn.Text(f"Tapped {self.state['count']} times", font_size=16), + pn.Text(f"Tapped {count} times", font_size=16), + pn.Text(medal, font_size=32), pn.Button("Tap me", on_click=self.increment, background_color="#FF1E88E5"), pn.Button( "Go to Second Page", 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/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 12485c5..5a83b36 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 @@ -73,16 +74,17 @@ def render(self): "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" @@ -215,6 +217,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. @@ -223,8 +262,13 @@ def run_project(args: argparse.Namespace) -> None: platform: str = args.platform prepare_only: bool = getattr(args, "prepare_only", 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) @@ -268,10 +312,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) @@ -523,6 +587,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: diff --git a/src/pythonnative/templates/android_template/app/build.gradle b/src/pythonnative/templates/android_template/app/build.gradle index f23ab96..5550ba8 100644 --- a/src/pythonnative/templates/android_template/app/build.gradle +++ b/src/pythonnative/templates/android_template/app/build.gradle @@ -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/build.gradle b/src/pythonnative/templates/android_template/build.gradle index 3d20b92..719a616 100644 --- a/src/pythonnative/templates/android_template/build.gradle +++ b/src/pythonnative/templates/android_template/build.gradle @@ -3,5 +3,5 @@ plugins { 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 '14.0.2' apply false + id 'com.chaquo.python' version '15.0.1' apply false } \ No newline at end of file From 3bd87de4a8775e23eb4f081a31b9125f9b20861c Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:30:45 -0700 Subject: [PATCH 09/11] feat!: add function components, hooks, layout, styling, hot reload, native APIs, and new UI components --- CONTRIBUTING.md | 4 + docs/api/component-properties.md | 71 ++- docs/api/pythonnative.md | 36 +- docs/concepts/architecture.md | 56 +- docs/concepts/components.md | 93 ++- docs/concepts/hooks.md | 176 ++++++ docs/guides/styling.md | 109 +++- examples/hello-world/app/main_page.py | 38 +- mkdocs.yml | 1 + src/pythonnative/__init__.py | 60 +- src/pythonnative/cli/pn.py | 39 ++ src/pythonnative/collection_view.py | 0 src/pythonnative/components.py | 366 ++++++++++- src/pythonnative/element.py | 22 +- src/pythonnative/hooks.py | 287 +++++++++ src/pythonnative/hot_reload.py | 143 +++++ .../material_bottom_navigation_view.py | 0 src/pythonnative/material_toolbar.py | 0 src/pythonnative/native_modules/__init__.py | 19 + src/pythonnative/native_modules/camera.py | 105 ++++ .../native_modules/file_system.py | 131 ++++ src/pythonnative/native_modules/location.py | 61 ++ .../native_modules/notifications.py | 151 +++++ src/pythonnative/native_views.py | 570 +++++++++++++++++- src/pythonnative/page.py | 1 + src/pythonnative/reconciler.py | 173 +++++- src/pythonnative/style.py | 115 ++++ tests/test_components.py | 106 ++++ tests/test_hooks.py | 433 +++++++++++++ tests/test_reconciler.py | 107 +++- tests/test_smoke.py | 19 + tests/test_style.py | 58 ++ 32 files changed, 3430 insertions(+), 120 deletions(-) create mode 100644 docs/concepts/hooks.md delete mode 100644 src/pythonnative/collection_view.py create mode 100644 src/pythonnative/hooks.py create mode 100644 src/pythonnative/hot_reload.py delete mode 100644 src/pythonnative/material_bottom_navigation_view.py delete mode 100644 src/pythonnative/material_toolbar.py create mode 100644 src/pythonnative/native_modules/__init__.py create mode 100644 src/pythonnative/native_modules/camera.py create mode 100644 src/pythonnative/native_modules/file_system.py create mode 100644 src/pythonnative/native_modules/location.py create mode 100644 src/pythonnative/native_modules/notifications.py create mode 100644 src/pythonnative/style.py create mode 100644 tests/test_hooks.py create mode 100644 tests/test_style.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfc287d..2a24afc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,10 +100,14 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc - `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.py`) - `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: diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index c6dd7ed..85e7a95 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -2,6 +2,19 @@ All style and behaviour properties are passed as keyword arguments to element functions. +## Common layout properties + +All components accept these layout properties: + +- `width` — fixed width in dp (Android) / pt (iOS) +- `height` — fixed height +- `flex` — flex grow factor within Column/Row +- `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 (`"fill"`, `"center"`, etc.) +- `key` — stable identity for reconciliation + ## Text ```python @@ -42,6 +55,22 @@ pn.Row(*children, spacing=0, padding=None, alignment=None, background_color=None - `alignment` — cross-axis: `"fill"`, `"center"`, `"leading"`, `"trailing"`, `"start"`, `"end"`, `"top"`, `"bottom"` - `background_color` — container background +## View + +```python +pn.View(*children, background_color=None, padding=None) +``` + +Generic container (UIView / FrameLayout). Supports all layout properties. + +## SafeAreaView + +```python +pn.SafeAreaView(*children, background_color=None, padding=None) +``` + +Container that respects safe area insets (notch, status bar). + ## ScrollView ```python @@ -63,6 +92,9 @@ pn.TextInput(value="", placeholder="", on_change=None, secure=False, pn.Image(source="", width=None, height=None, scale_type=None, background_color=None) ``` +- `source` — image URL (`http://...` / `https://...`) or local resource name +- `scale_type` — `"cover"`, `"contain"`, `"stretch"`, `"center"` + ## Switch ```python @@ -71,6 +103,14 @@ pn.Switch(value=False, on_change=None) - `on_change` — callback `(bool) -> None` +## Slider + +```python +pn.Slider(value=0.0, min_value=0.0, max_value=1.0, on_change=None) +``` + +- `on_change` — callback `(float) -> None` + ## ProgressBar ```python @@ -94,7 +134,36 @@ pn.WebView(url="") ## Spacer ```python -pn.Spacer(size=None) +pn.Spacer(size=None, flex=None) ``` - `size` — fixed dimension in dp / pt +- `flex` — flex grow factor + +## Pressable + +```python +pn.Pressable(child, on_press=None, on_long_press=None) +``` + +Wraps any child element with tap/long-press handling. + +## Modal + +```python +pn.Modal(*children, visible=False, on_dismiss=None, title=None, background_color=None) +``` + +Overlay dialog shown when `visible=True`. + +## FlatList + +```python +pn.FlatList(data=None, render_item=None, key_extractor=None, + separator_height=0, background_color=None) +``` + +- `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 8147bf9..8caac58 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -9,6 +9,7 @@ ### 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. See the Component Property Reference for full signatures. @@ -16,17 +17,48 @@ Each returns an `Element` descriptor. See the Component Property Reference for f `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_effect(effect, deps)` — side effects +- `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 + +### 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 by `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. ## Reconciler -`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Used internally by `Page`. +`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, and context providers. Used internally by `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 diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 9ab94b0..903db1e 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -5,11 +5,13 @@ PythonNative combines **direct native bindings** with a **declarative reconciler ## High-level model 1. **Declarative element tree:** Your `Page.render()` method returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes). -2. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by `set_state`), it diffs the new tree against the old one and applies the minimal set of native mutations. -3. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: +2. **Function components and hooks:** Reusable components with independent state via `@pn.component`, `use_state`, `use_effect`, 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 `set_state` or hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations. +4. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content. +5. **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. -4. **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 through the reconciler. +6. **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 through the reconciler. ## How it works @@ -17,15 +19,44 @@ PythonNative combines **direct native bindings** with a **declarative reconciler Page.render() → Element tree → Reconciler → Native views ↑ Page.set_state() → re-render → diff → patch native views +Hook set_state() → re-render → diff → patch native views ``` -The reconciler uses **positional diffing** (comparing children by index). When a child at a given position has the same element type, 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 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. + +## Component model + +PythonNative supports two kinds of components: + +### Page classes (screens) + +Each screen is a `Page` subclass that bridges native lifecycle events to Python. Pages have `render()`, `set_state()`, navigation (`push`/`pop`), and lifecycle hooks (`on_create`, `on_resume`, etc.). + +### Function components (reusable UI) + +Decorated with `@pn.component`, these are Python functions that return `Element` trees and can use hooks for state, effects, memoisation, and context. Each call site creates an independent instance with its own hook state. + +```python +@pn.component +def counter(initial: int = 0) -> pn.Element: + count, set_count = pn.use_state(initial) + return pn.Text(f"Count: {count}") +``` + +## Styling + +- **Inline styles:** Pass props directly to components (`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 + +All components support layout properties: `width`, `height`, `flex`, `margin`, `min_width`, `max_width`, `min_height`, `max_height`, `align_self`. Containers (`Column`, `Row`) support `spacing`, `padding`, and `alignment`. ## 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 that NativeScript does not have by default. -- **Versus the old imperative API:** The previous PythonNative API required manual `add_view()` calls and explicit setter methods. The new declarative model handles view lifecycle automatically. +- **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) @@ -39,6 +70,19 @@ The reconciler uses **positional diffing** (comparing children by index). When a - `PageFragment` calls `on_create()` on the Python `Page`, which renders and attaches views to the fragment container. - State changes trigger re-render; the reconciler patches Android views in-place. +## Hot reload + +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 + +PythonNative provides cross-platform modules for common device APIs: + +- `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. diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 016c21c..a39d869 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -26,12 +26,14 @@ pn.Column( - `Column(*children, spacing, padding, alignment, background_color)` — vertical stack - `Row(*children, spacing, padding, alignment, background_color)` — horizontal stack - `ScrollView(child, background_color)` — scrollable container -- `Spacer(size)` — empty space +- `View(*children, background_color, padding)` — generic container +- `SafeAreaView(*children, background_color, padding)` — safe-area-aware container +- `Spacer(size, flex)` — empty space **Display:** - `Text(text, font_size, color, bold, text_align, background_color, max_lines)` — text display -- `Image(source, width, height, scale_type)` — image display +- `Image(source, width, height, scale_type)` — image display (supports URLs and resource names) - `WebView(url)` — embedded web content **Input:** @@ -39,12 +41,32 @@ pn.Column( - `Button(title, on_click, color, background_color, font_size, enabled)` — tappable button - `TextInput(value, placeholder, on_change, secure, font_size, color)` — 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 + +**Lists:** + +- `FlatList(data, render_item, key_extractor, separator_height)` — scrollable data list + +### Layout properties + +All components support common layout properties: + +- `width`, `height` — fixed dimensions (dp / pt) +- `flex` — flex grow factor +- `margin` — outer margin (int, float, or dict like padding) +- `min_width`, `max_width`, `min_height`, `max_height` — size constraints +- `align_self` — override parent alignment for this child + ## Page — the root component Each screen is a `Page` subclass with a `render()` method that returns an element tree: @@ -80,17 +102,23 @@ class CounterPage(pn.Page): ) ``` -## Reusable components as functions +## Function components with hooks -For reusable UI pieces, use regular Python functions that return elements: +For reusable UI pieces **with their own state**, use the `@pn.component` decorator and hooks: ```python -def greeting_card(name, on_tap): +@pn.component +def counter(label: str = "Count", initial: int = 0) -> pn.Element: + count, set_count = pn.use_state(initial) + return pn.Column( - pn.Text(f"Hello, {name}!", font_size=20, bold=True), - pn.Button("Say hi", on_click=on_tap), - spacing=8, - padding=12, + pn.Text(f"{label}: {count}", font_size=18), + pn.Row( + pn.Button("-", on_click=lambda: set_count(count - 1)), + pn.Button("+", on_click=lambda: set_count(count + 1)), + spacing=8, + ), + spacing=4, ) class MainPage(pn.Page): @@ -99,12 +127,55 @@ class MainPage(pn.Page): def render(self): return pn.Column( - greeting_card("Alice", lambda: print("Hi Alice")), - greeting_card("Bob", lambda: print("Hi Bob")), + counter(label="Apples", initial=0), + counter(label="Oranges", initial=5), spacing=16, + padding=16, ) ``` +Each `counter` instance has **independent state** — changing one doesn't affect the other. + +### Available hooks + +- `use_state(initial)` — local component state; returns `(value, setter)` +- `use_effect(effect, deps)` — side effects (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 + +### Custom hooks + +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 +``` + +### Context and Provider + +Share values across the tree without prop drilling: + +```python +theme = pn.create_context({"primary": "#007AFF"}) + +# In a page's render(): +pn.Provider(theme, {"primary": "#FF0000"}, + my_component() +) + +# In my_component: +@pn.component +def my_component() -> pn.Element: + t = pn.use_context(theme) + return pn.Button("Click", color=t["primary"]) +``` + ## Platform detection Use `pythonnative.utils.IS_ANDROID` when you need platform-specific logic: diff --git a/docs/concepts/hooks.md b/docs/concepts/hooks.md new file mode 100644 index 0000000..a52197a --- /dev/null +++ b/docs/concepts/hooks.md @@ -0,0 +1,176 @@ +# Function Components and Hooks + +PythonNative supports React-like function components with hooks for managing state, effects, memoisation, and context. This is the recommended way to build reusable UI pieces. + +## Creating a function component + +Decorate a Python function with `@pn.component`: + +```python +import pythonnative as pn + +@pn.component +def greeting(name: str = "World") -> pn.Element: + return pn.Text(f"Hello, {name}!", font_size=20) +``` + +Use it like any other component: + +```python +class MyPage(pn.Page): + def render(self): + return pn.Column( + greeting(name="Alice"), + greeting(name="Bob"), + 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) -> pn.Element: + 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_effect + +Run side effects after render. The effect function may return a cleanup callable. + +```python +@pn.component +def timer() -> pn.Element: + 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") +``` + +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_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 +# Create a context with a default value +user_context = pn.create_context({"name": "Guest"}) + +# Provide a value to descendants +pn.Provider(user_context, {"name": "Alice"}, + user_profile() +) + +# Consume in any descendant +@pn.component +def user_profile() -> pn.Element: + user = pn.use_context(user_context) + return pn.Text(f"Welcome, {user['name']}") +``` + +## 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() -> pn.Element: + dark_mode, toggle_dark = use_toggle(False) + + return pn.Column( + pn.Text("Settings", 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/guides/styling.md b/docs/guides/styling.md index 000e40f..cf93f8f 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -1,6 +1,57 @@ # Styling -Style properties are passed as keyword arguments to element functions. This replaces the old fluent setter pattern. +Style properties are passed as keyword arguments to element functions. PythonNative also provides a `StyleSheet` utility for creating reusable styles and a theming system via context. + +## Inline styles + +Pass style props directly to components: + +```python +pn.Text("Hello", color="#FF3366", font_size=24, bold=True) +pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") +pn.Column(pn.Text("Content"), 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, "alignment": "fill"}, +) + +# Apply with dict unpacking +pn.Text("Welcome", **styles["title"]) +pn.Column( + pn.Text("Subtitle", **styles["subtitle"]), + **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} +``` + +### 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 @@ -9,7 +60,6 @@ Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color props: ```python pn.Text("Hello", color="#FF3366") pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") -pn.Column(pn.Text("Content"), background_color="#FFF5F5F5") ``` ## Text styling @@ -21,9 +71,25 @@ pn.Text("Title", font_size=24, bold=True, text_align="center") pn.Text("Subtitle", font_size=14, color="#666666") ``` +## Layout properties + +All components support common layout properties: + +```python +pn.Text("Fixed size", width=200, height=50) +pn.View(child, flex=1, margin=8) +pn.Column(items, margin={"horizontal": 16, "vertical": 8}) +``` + +- `width`, `height` — fixed dimensions in dp (Android) / pt (iOS) +- `flex` — flex grow factor within Column/Row +- `margin` — outer spacing (int for all sides, or dict) +- `min_width`, `max_width`, `min_height`, `max_height` — size constraints +- `align_self` — override parent alignment + ## Layout with Column and Row -`Column` (vertical) and `Row` (horizontal) replace the old `StackView`: +`Column` (vertical) and `Row` (horizontal): ```python pn.Column( @@ -48,12 +114,45 @@ pn.Column( - `padding={"horizontal": 12, "vertical": 8}` — per axis - `padding={"left": 8, "top": 16, "right": 8, "bottom": 16}` — per side -Android: applied via `setPadding` in dp. iOS: best-effort via layout margins. - ### Alignment Cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"`. +## 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 themed_text(text: str = "") -> pn.Element: + theme = pn.use_context(pn.ThemeContext) + return pn.Text(text, color=theme["text_color"], font_size=theme["font_size"]) + +class MyPage(pn.Page): + def render(self): + return pn.Provider(pn.ThemeContext, DEFAULT_DARK_THEME, + pn.Column( + themed_text(text="Dark mode!"), + 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`: diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index b281d92..dbf2fe8 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -7,23 +7,37 @@ MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] +styles = pn.StyleSheet.create( + title={"font_size": 24, "bold": True}, + subtitle={"font_size": 16, "color": "#666666"}, + medal={"font_size": 32}, + section={"spacing": 12, "padding": 16, "alignment": "fill"}, +) + + +@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:") + + return pn.Column( + pn.Text(f"Tapped {count} times", **styles["subtitle"]), + pn.Text(medal, **styles["medal"]), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + spacing=4, + ) + + class MainPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - self.state = {"count": 0} - - def increment(self) -> None: - self.set_state(count=self.state["count"] + 1) def render(self) -> pn.Element: - count = self.state["count"] - medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") return pn.ScrollView( pn.Column( - pn.Text("Hello from PythonNative Demo!", font_size=24, bold=True), - pn.Text(f"Tapped {count} times", font_size=16), - pn.Text(medal, font_size=32), - pn.Button("Tap me", on_click=self.increment, background_color="#FF1E88E5"), + pn.Text("Hello from PythonNative Demo!", **styles["title"]), + counter_badge(), pn.Button( "Go to Second Page", on_click=lambda: self.push( @@ -31,8 +45,6 @@ def render(self) -> pn.Element: args={"message": "Greetings from MainPage"}, ), ), - spacing=12, - padding=16, - alignment="fill", + **styles["section"], ) ) diff --git a/mkdocs.yml b/mkdocs.yml index 7aeff94..308e6c7 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 diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 053a95e..923a403 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -4,16 +4,25 @@ 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}", font_size=24), + pn.Button("+", on_click=lambda: set_count(count + 1)), + spacing=12, + ) + class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - self.state = {"count": 0} def render(self): return pn.Column( - pn.Text(f"Count: {self.state['count']}", font_size=24), - pn.Button("Increment", on_click=lambda: self.set_state(count=self.state["count"] + 1)), - spacing=12, + counter(initial=0), + counter(initial=10), + spacing=16, + padding=16, ) """ @@ -23,32 +32,71 @@ def render(self): ActivityIndicator, Button, Column, + FlatList, Image, + Modal, + Pressable, ProgressBar, Row, + SafeAreaView, ScrollView, + Slider, Spacer, Switch, Text, TextInput, + View, WebView, ) from .element import Element +from .hooks import ( + Provider, + component, + create_context, + use_callback, + use_context, + use_effect, + use_memo, + use_ref, + use_state, +) from .page import Page +from .style import StyleSheet, ThemeContext __all__ = [ + # Components "ActivityIndicator", "Button", "Column", - "Element", + "FlatList", "Image", - "Page", + "Modal", + "Pressable", "ProgressBar", "Row", + "SafeAreaView", "ScrollView", + "Slider", "Spacer", "Switch", "Text", "TextInput", + "View", "WebView", + # Core + "Element", + "Page", + # Hooks + "component", + "create_context", + "use_callback", + "use_context", + "use_effect", + "use_memo", + "use_ref", + "use_state", + "Provider", + # Styling + "StyleSheet", + "ThemeContext", ] diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 5a83b36..18d1bf8 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -261,6 +261,7 @@ 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() @@ -656,6 +657,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: """ @@ -690,6 +724,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 index ad57eb5..08bb221 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -4,34 +4,56 @@ These are pure data — no native views are created until the reconciler mounts the element tree. -Naming follows React Native conventions: - -- ``Text`` (was *Label*) -- ``Button`` -- ``Column`` / ``Row`` (was *StackView* vertical/horizontal) -- ``ScrollView`` -- ``TextInput`` (was *TextField*) -- ``Image`` (was *ImageView*) -- ``Switch`` -- ``ProgressBar`` (was *ProgressView*) -- ``ActivityIndicator`` (was *ActivityIndicatorView*) -- ``WebView`` -- ``Spacer`` (new) +Layout properties (``width``, ``height``, ``flex``, ``margin``, +``min_width``, ``max_width``, ``min_height``, ``max_height``, +``align_self``) are supported by all components. """ -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from .element import Element +# ====================================================================== +# Shared helpers +# ====================================================================== + +PaddingValue = Union[int, float, Dict[str, Union[int, float]]] +MarginValue = Union[int, float, Dict[str, Union[int, float]]] + def _filter_none(**kwargs: Any) -> Dict[str, Any]: """Return *kwargs* with ``None``-valued entries removed.""" return {k: v for k, v in kwargs.items() if v is not None} -# --------------------------------------------------------------------------- +def _layout_props( + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, +) -> Dict[str, Any]: + """Collect common layout props into a dict (excluding Nones).""" + return _filter_none( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + + +# ====================================================================== # Leaf components -# --------------------------------------------------------------------------- +# ====================================================================== def Text( @@ -43,6 +65,15 @@ def Text( text_align: Optional[str] = None, background_color: Optional[str] = None, max_lines: Optional[int] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Display text.""" @@ -55,6 +86,19 @@ def Text( background_color=background_color, max_lines=max_lines, ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Text", props, [], key=key) @@ -66,6 +110,15 @@ def Button( background_color: Optional[str] = None, font_size: Optional[float] = None, enabled: bool = True, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Create a tappable button.""" @@ -80,6 +133,19 @@ def Button( props["font_size"] = font_size if not enabled: props["enabled"] = False + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Button", props, [], key=key) @@ -92,6 +158,15 @@ def TextInput( font_size: Optional[float] = None, color: Optional[str] = None, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Create a single-line text entry field.""" @@ -108,6 +183,19 @@ def TextInput( props["color"] = color if background_color is not None: props["background_color"] = background_color + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("TextInput", props, [], key=key) @@ -118,6 +206,13 @@ def Image( height: Optional[float] = None, scale_type: Optional[str] = None, background_color: Optional[str] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Display an image from a resource path or URL.""" @@ -128,6 +223,17 @@ def Image( scale_type=scale_type, background_color=background_color, ) + props.update( + _layout_props( + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Image", props, [], key=key) @@ -135,12 +241,18 @@ def Switch( *, value: bool = False, on_change: Optional[Callable[[bool], None]] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = 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(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) return Element("Switch", props, [], key=key) @@ -148,49 +260,66 @@ def ProgressBar( *, value: float = 0.0, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Show determinate progress (0.0 – 1.0).""" props = _filter_none(value=value, background_color=background_color) + props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) return Element("ProgressBar", props, [], key=key) def ActivityIndicator( *, animating: bool = True, + width: Optional[float] = None, + height: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Show an indeterminate loading spinner.""" - return Element("ActivityIndicator", {"animating": animating}, [], key=key) + props: Dict[str, Any] = {"animating": animating} + props.update(_layout_props(width=width, height=height, margin=margin, align_self=align_self)) + return Element("ActivityIndicator", props, [], key=key) def WebView( *, url: str = "", + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Embed web content.""" props: Dict[str, Any] = {} if url: props["url"] = url + props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) 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.""" - props = _filter_none(size=size) + props = _filter_none(size=size, flex=flex) return Element("Spacer", props, [], key=key) -# --------------------------------------------------------------------------- +# ====================================================================== # Container components -# --------------------------------------------------------------------------- - -PaddingValue = Union[int, float, Dict[str, Union[int, float]]] +# ====================================================================== def Column( @@ -199,6 +328,15 @@ def Column( padding: Optional[PaddingValue] = None, alignment: Optional[str] = None, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Arrange children vertically.""" @@ -208,6 +346,19 @@ def Column( alignment=alignment, background_color=background_color, ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Column", props, list(children), key=key) @@ -217,6 +368,15 @@ def Row( padding: Optional[PaddingValue] = None, alignment: Optional[str] = None, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Arrange children horizontally.""" @@ -226,6 +386,19 @@ def Row( alignment=alignment, background_color=background_color, ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Row", props, list(children), key=key) @@ -233,9 +406,158 @@ def ScrollView( child: Optional[Element] = None, *, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Wrap a single child in a scrollable container.""" children = [child] if child is not None else [] props = _filter_none(background_color=background_color) + props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) return Element("ScrollView", props, children, key=key) + + +def View( + *children: Element, + background_color: Optional[str] = None, + padding: Optional[PaddingValue] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Generic container view (``UIView`` / ``android.view.View``).""" + props = _filter_none( + background_color=background_color, + padding=padding, + ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) + return Element("View", props, list(children), key=key) + + +def SafeAreaView( + *children: Element, + background_color: Optional[str] = None, + padding: Optional[PaddingValue] = None, + key: Optional[str] = None, +) -> Element: + """Container that respects safe area insets (notch, status bar).""" + props = _filter_none(background_color=background_color, padding=padding) + 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, + background_color: Optional[str] = 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 + if background_color is not None: + props["background_color"] = background_color + return Element("Modal", props, list(children), 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, + width: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = 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(_layout_props(width=width, margin=margin, align_self=align_self)) + return Element("Slider", props, [], 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 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, + background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = 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. This enables the + reconciler to preserve widget state across data changes. + """ + 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, spacing=separator_height) + sv_props = _filter_none(background_color=background_color) + sv_props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) + return Element("ScrollView", sv_props, [inner], key=key) diff --git a/src/pythonnative/element.py b/src/pythonnative/element.py index 8930386..63684d3 100644 --- a/src/pythonnative/element.py +++ b/src/pythonnative/element.py @@ -1,23 +1,28 @@ """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, 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. +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 +from typing import Any, Dict, List, Optional, Union class Element: - """Immutable description of a single UI node.""" + """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: str, + type_name: Union[str, Any], props: Dict[str, Any], children: List["Element"], key: Optional[str] = None, @@ -28,7 +33,8 @@ def __init__( self.key = key def __repr__(self) -> str: - return f"Element({self.type!r}, props={set(self.props)}, children={len(self.children)})" + 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): diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py new file mode 100644 index 0000000..79ff061 --- /dev/null +++ b/src/pythonnative/hooks.py @@ -0,0 +1,287 @@ +"""Hook primitives for function components. + +Provides React-like hooks for managing state, effects, memoisation, +and context 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 typing import Any, Callable, List, Optional, Tuple, TypeVar + +from .element import Element + +T = TypeVar("T") + +_SENTINEL = object() + +_hook_context: threading.local = threading.local() + + +# ====================================================================== +# Hook state container +# ====================================================================== + + +class HookState: + """Stores all hook data for a single function component instance.""" + + __slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render") + + 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 + + def reset_index(self) -> None: + self.hook_index = 0 + + def run_pending_effects(self) -> None: + """Execute effects whose deps changed during the last render pass.""" + for i, (deps, cleanup) in enumerate(self.effects): + if deps is _SENTINEL: + continue + + 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) + + +# ====================================================================== +# 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)) + + +# ====================================================================== +# 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: + ctx._trigger_render() + + return current, setter + + +def use_effect(effect: Callable, deps: Optional[list] = None) -> None: + """Schedule *effect* to run after render. + + *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)) + + prev_deps, prev_cleanup = ctx.effects[idx] + if _deps_changed(prev_deps, deps): + if callable(prev_cleanup): + try: + prev_cleanup() + except Exception: + pass + cleanup = effect() + ctx.effects[idx] = (list(deps) if deps is not None else None, cleanup) + else: + ctx.effects[idx] = (prev_deps, prev_cleanup) + + +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]) + + +# ====================================================================== +# @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..1933118 --- /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 _re_render + + if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None: + _re_render(page_instance) 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_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.py b/src/pythonnative/native_views.py index 2efab0e..ff6435f 100644 --- a/src/pythonnative/native_views.py +++ b/src/pythonnative/native_views.py @@ -123,6 +123,21 @@ def _resolve_padding( return (0, 0, 0, 0) +_LAYOUT_KEYS = frozenset( + { + "width", + "height", + "flex", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + } +) + + # ====================================================================== # Platform handler registration (lazy imports inside functions) # ====================================================================== @@ -142,15 +157,52 @@ def _density() -> float: def _dp(value: float) -> int: return int(value * _density()) + def _apply_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout properties to an Android view.""" + lp = view.getLayoutParams() + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + 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 + if "flex" in props and props["flex"] is not None: + try: + lp.weight = float(props["flex"]) + 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 needs_set: + view.setLayoutParams(lp) + # ---- Text ----------------------------------------------------------- class AndroidTextHandler(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: @@ -175,10 +227,13 @@ class AndroidButtonHandler(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: @@ -213,10 +268,13 @@ def create(self, props: Dict[str, Any]) -> Any: ll = jclass("android.widget.LinearLayout")(_ctx()) ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) self._apply(ll, props) + _apply_layout(ll, props) return ll 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, ll: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -259,10 +317,13 @@ def create(self, props: Dict[str, Any]) -> Any: ll = jclass("android.widget.LinearLayout")(_ctx()) ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) self._apply(ll, props) + _apply_layout(ll, props) return ll 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, ll: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -303,11 +364,14 @@ 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) @@ -315,15 +379,18 @@ def add_child(self, parent: Any, child: Any) -> None: def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) - # ---- TextInput (EditText) ------------------------------------------- + # ---- TextInput (EditText) with on_change ---------------------------- class AndroidTextInputHandler(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: @@ -339,26 +406,113 @@ def _apply(self, et: Any, props: Dict[str, Any]) -> None: 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)) - # ---- Image ---------------------------------------------------------- + # ---- Image (with URL loading) --------------------------------------- class AndroidImageHandler(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"])) - - # ---- Switch --------------------------------------------------------- + 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 + + # ---- Switch (with on_change) ---------------------------------------- class AndroidSwitchHandler(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: @@ -387,6 +541,7 @@ def create(self, props: Dict[str, Any]) -> Any: 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: @@ -402,6 +557,7 @@ 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: @@ -415,6 +571,7 @@ 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: @@ -429,6 +586,12 @@ def create(self, props: Dict[str, Any]) -> Any: 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: @@ -437,6 +600,156 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) native_view.setLayoutParams(lp) + # ---- View (generic container FrameLayout) --------------------------- + class AndroidViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + 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)) + _apply_layout(fl, props) + 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"])) + if "padding" in changed: + left, top, right, bottom = _resolve_padding(changed["padding"]) + native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + 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) + + # ---- SafeAreaView (FrameLayout with fitsSystemWindows) --------------- + class AndroidSafeAreaViewHandler(ViewHandler): + 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) + + # ---- Modal (AlertDialog) ------------------------------------------- + class AndroidModalHandler(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 + + # ---- Slider (SeekBar) ----------------------------------------------- + class AndroidSliderHandler(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)) + + # ---- Pressable (FrameLayout with click listener) -------------------- + class AndroidPressableHandler(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) + registry.register("Text", AndroidTextHandler()) registry.register("Button", AndroidButtonHandler()) registry.register("Column", AndroidColumnHandler()) @@ -449,6 +762,11 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: registry.register("ActivityIndicator", AndroidActivityIndicatorHandler()) registry.register("WebView", AndroidWebViewHandler()) registry.register("Spacer", AndroidSpacerHandler()) + registry.register("View", AndroidViewHandler()) + registry.register("SafeAreaView", AndroidSafeAreaViewHandler()) + registry.register("Modal", AndroidModalHandler()) + registry.register("Slider", AndroidSliderHandler()) + registry.register("Pressable", AndroidPressableHandler()) def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 @@ -468,15 +786,37 @@ def _uicolor(color: Any) -> Any: 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 + # ---- Text ----------------------------------------------------------- class IOSTextHandler(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: @@ -501,10 +841,6 @@ def _apply(self, label: Any, props: Dict[str, Any]) -> None: # ---- Button --------------------------------------------------------- - # btn id(ObjCInstance) -> _PNButtonTarget. Keeps a strong ref to - # each handler (preventing GC) and lets us swap the callback on - # re-render without calling removeTarget/addTarget (which crashes - # due to rubicon-objc wrapper lifecycle issues). _pn_btn_handler_map: dict = {} class _PNButtonTarget(NSObject): # type: ignore[valid-type] @@ -515,9 +851,6 @@ def onTap_(self, sender: object) -> None: if self._callback is not None: self._callback() - # Strong refs to retained UIButton wrappers so the ObjCInstance - # (and its prevent-deallocation retain) stays alive for the - # lifetime of the app. _pn_retained_views: list = [] class IOSButtonHandler(ViewHandler): @@ -528,10 +861,13 @@ def create(self, props: Dict[str, Any]) -> Any: _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: @@ -563,10 +899,13 @@ def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) sv.setAxis_(1) # vertical self._apply(sv, props) + _apply_ios_layout(sv, props) return sv 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, sv: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -600,10 +939,13 @@ def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) sv.setAxis_(0) # horizontal self._apply(sv, props) + _apply_ios_layout(sv, props) return sv 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, sv: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -630,6 +972,7 @@ 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: @@ -650,16 +993,33 @@ def add_child(self, parent: Any, child: Any) -> None: def remove_child(self, parent: Any, child: Any) -> None: child.removeFromSuperview() - # ---- TextInput (UITextField) ---------------------------------------- + # ---- TextInput (UITextField with on_change) ------------------------- + _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 + class IOSTextInputHandler(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: @@ -674,20 +1034,72 @@ def _apply(self, tf: Any, props: Dict[str, Any]) -> 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) - # ---- Image ---------------------------------------------------------- + # ---- Image (with URL loading) --------------------------------------- class IOSImageHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: iv = ObjCClass("UIImageView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - iv.setBackgroundColor_(_uicolor(props["background_color"])) + self._apply(iv, props) + _apply_ios_layout(iv, props) return iv 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"])) + 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 + + # ---- Switch (with on_change) ---------------------------------------- + _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 - # ---- Switch --------------------------------------------------------- class IOSSwitchHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sw = ObjCClass("UISwitch").alloc().init() @@ -700,6 +1112,15 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: 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) # ---- ProgressBar (UIProgressView) ----------------------------------- class IOSProgressBarHandler(ViewHandler): @@ -707,6 +1128,7 @@ 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: @@ -737,6 +1159,7 @@ def create(self, props: Dict[str, Any]) -> Any: 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: @@ -760,6 +1183,112 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: size = float(changed["size"]) native_view.setFrame_(((0, 0), (size, size))) + # ---- View (generic UIView) ----------------------------------------- + class IOSViewHandler(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"])) + _apply_ios_layout(v, props) + 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"])) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + # ---- SafeAreaView --------------------------------------------------- + class IOSSafeAreaViewHandler(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() + + # ---- Modal ---------------------------------------------------------- + class IOSModalHandler(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 + + # ---- Slider (UISlider) ---------------------------------------------- + _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 + + class IOSSliderHandler(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) + + # ---- Pressable (UIView with tap gesture) ---------------------------- + class IOSPressableHandler(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() + registry.register("Text", IOSTextHandler()) registry.register("Button", IOSButtonHandler()) registry.register("Column", IOSColumnHandler()) @@ -772,6 +1301,11 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: registry.register("ActivityIndicator", IOSActivityIndicatorHandler()) registry.register("WebView", IOSWebViewHandler()) registry.register("Spacer", IOSSpacerHandler()) + registry.register("View", IOSViewHandler()) + registry.register("SafeAreaView", IOSSafeAreaViewHandler()) + registry.register("Modal", IOSModalHandler()) + registry.register("Slider", IOSSliderHandler()) + registry.register("Pressable", IOSPressableHandler()) # ====================================================================== diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index 421bed4..942db1d 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -122,6 +122,7 @@ def _on_create(page: Any) -> None: from .reconciler import Reconciler page._reconciler = Reconciler(get_registry()) + page._reconciler._page_re_render = lambda: _re_render(page) element = page.render() page._root_native_view = page._reconciler.mount(element) page._attach_root(page._root_native_view) diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index b7aa5bc..8f2c5ab 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -3,6 +3,16 @@ 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. +- **Key-based child reconciliation** for stable identity across + re-renders. """ from typing import Any, List, Optional @@ -13,12 +23,14 @@ class VNode: """A mounted element paired with its native view and child VNodes.""" - __slots__ = ("element", "native_view", "children") + __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: @@ -35,6 +47,7 @@ class Reconciler: def __init__(self, backend: Any) -> None: self.backend = backend self._tree: Optional[VNode] = None + self._page_re_render: Optional[Any] = None # ------------------------------------------------------------------ # Public API @@ -62,6 +75,37 @@ def reconcile(self, new_element: Element) -> Any: # ------------------------------------------------------------------ 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) + + # 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: @@ -71,11 +115,58 @@ def _create_tree(self, element: Element) -> VNode: return VNode(element, native_view, children) def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: - if old.element.type != new_el.type: + 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 + + # 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) @@ -86,44 +177,86 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: 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 != "__Provider__" + + 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] = [] - max_len = max(len(old_children), len(new_children)) - - for i in range(max_len): - if i >= len(new_children): - self.backend.remove_child(parent.native_view, old_children[i].native_view, parent.element.type) - self._destroy_tree(old_children[i]) - elif i >= len(old_children): - node = self._create_tree(new_children[i]) - self.backend.add_child(parent.native_view, node.native_view, parent.element.type) + 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: - if old_children[i].element.type != new_children[i].type: - self.backend.remove_child(parent.native_view, old_children[i].native_view, parent.element.type) - self._destroy_tree(old_children[i]) - node = self._create_tree(new_children[i]) - self.backend.insert_child(parent.native_view, node.native_view, parent.element.type, i) - new_child_nodes.append(node) - else: - updated = self._reconcile_node(old_children[i], new_children[i]) - new_child_nodes.append(updated) + updated = self._reconcile_node(matched, new_el) + 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) 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/style.py b/src/pythonnative/style.py new file mode 100644 index 0000000..c8df328 --- /dev/null +++ b/src/pythonnative/style.py @@ -0,0 +1,115 @@ +"""StyleSheet and theming support. + +Provides a :class:`StyleSheet` helper for creating and composing +reusable style dictionaries, plus a built-in theme context. + +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", **styles["title"]) + pn.Column(..., **styles["container"]) +""" + +from typing import Any, Dict + +from .hooks import Context, create_context + +# ====================================================================== +# StyleSheet +# ====================================================================== + +_StyleDict = Dict[str, Any] + + +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/tests/test_components.py b/tests/test_components.py index 8ff7393..353adc3 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -4,14 +4,20 @@ ActivityIndicator, Button, Column, + FlatList, Image, + Modal, + Pressable, ProgressBar, Row, + SafeAreaView, ScrollView, + Slider, Spacer, Switch, Text, TextInput, + View, WebView, ) @@ -42,6 +48,15 @@ def test_text_none_props_excluded() -> None: assert "color" not in el.props +def test_text_layout_props() -> None: + el = Text("Hi", 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" + + # --------------------------------------------------------------------------- # Button # --------------------------------------------------------------------------- @@ -93,6 +108,12 @@ def test_column_no_spacing_omitted() -> None: assert "spacing" not in el.props +def test_column_layout_props() -> None: + el = Column(flex=2, margin={"horizontal": 8}) + assert el.props["flex"] == 2 + assert el.props["margin"] == {"horizontal": 8} + + # --------------------------------------------------------------------------- # ScrollView # --------------------------------------------------------------------------- @@ -192,3 +213,88 @@ def test_key_propagation() -> None: def test_column_key() -> None: el = Column(key="col-1") assert el.key == "col-1" + + +# --------------------------------------------------------------------------- +# New components +# --------------------------------------------------------------------------- + + +def test_view_container() -> None: + child = Text("inside") + el = View(child, 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 + + +def test_safe_area_view() -> None: + el = SafeAreaView(Text("safe"), 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 diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..ca7520a --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,433 @@ +"""Unit tests for function components and hooks.""" + +from typing import Any, Dict, List + +from pythonnative.element import Element +from pythonnative.hooks import ( + HookState, + Provider, + _set_hook_state, + component, + create_context, + use_callback, + use_context, + use_effect, + use_memo, + 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_effect +# ====================================================================== + + +def test_use_effect_runs_on_mount() -> None: + calls: list = [] + ctx = HookState() + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("mounted"), []) + assert calls == ["mounted"] + finally: + _set_hook_state(None) + + +def test_use_effect_cleanup_on_rerun() -> None: + cleanups: list = [] + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(lambda: cleanups.append, None) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + use_effect(lambda: cleanups.append, None) + finally: + _set_hook_state(None) + + +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.reset_index() + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("run"), [1, 2]) + finally: + _set_hook_state(None) + + assert calls == ["run"] + + +# ====================================================================== +# 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" diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 735592c..a97ecc0 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -160,8 +160,6 @@ def test_reconcile_add_child() -> None: ) rec.reconcile(el2) - add_ops = [op for op in backend.ops if op[0] == "add_child"] - assert len(add_ops) == 1 assert len(root.children) == 2 @@ -180,8 +178,6 @@ def test_reconcile_remove_child() -> None: el2 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) rec.reconcile(el2) - remove_ops = [op for op in backend.ops if op[0] == "remove_child"] - assert len(remove_ops) == 1 assert len(root.children) == 1 @@ -195,10 +191,6 @@ def test_reconcile_replace_child_type() -> None: el2 = Element("Column", {}, [Element("Button", {"title": "b"}, [])]) rec.reconcile(el2) - remove_ops = [op for op in backend.ops if op[0] == "remove_child"] - insert_ops = [op for op in backend.ops if op[0] == "insert_child"] - assert len(remove_ops) == 1 - assert len(insert_ops) == 1 assert root.children[0].type_name == "Button" @@ -278,3 +270,102 @@ def test_multiple_reconcile_cycles() -> None: 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"), + ], + ) + 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 + + +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" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0dad9fc..0e57dbb 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -18,15 +18,34 @@ def test_public_api_names() -> None: "Button", "Column", "Element", + "FlatList", "Image", + "Modal", "Page", + "Pressable", "ProgressBar", "Row", + "SafeAreaView", "ScrollView", + "Slider", "Spacer", "Switch", "Text", "TextInput", + "View", "WebView", + # Hooks + "component", + "create_context", + "use_callback", + "use_context", + "use_effect", + "use_memo", + "use_ref", + "use_state", + "Provider", + # 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..9837876 --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,58 @@ +"""Unit tests for StyleSheet and theming.""" + +from pythonnative.style import ( + DEFAULT_DARK_THEME, + DEFAULT_LIGHT_THEME, + StyleSheet, + ThemeContext, +) + + +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"] From 7fbf9c07988d4c543253dec8ba28da42c38cc3a9 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:56:19 -0700 Subject: [PATCH 10/11] ci(workflows): add package build step to verify sdist and wheel before release --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03d9985..a177889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,11 @@ jobs: run: | pip install . + - name: Build package + run: | + pip install build + python -m build + - name: Run tests run: | pytest -q From 7a5923c64d8990635a49dec46f62132719bf7560 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 3 Apr 2026 07:58:14 +0000 Subject: [PATCH 11/11] chore(release): v0.6.0 --- CHANGELOG.md | 45 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/pythonnative/__init__.py | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a714b..d885a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,51 @@ # CHANGELOG +## 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 diff --git a/pyproject.toml b/pyproject.toml index 1a00c42..6979cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.5.0" +version = "0.6.0" description = "Cross-platform native UI toolkit for Android and iOS" authors = [ { name = "Owen Carey" } diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 923a403..f3e4be4 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -26,7 +26,7 @@ def render(self): ) """ -__version__ = "0.5.0" +__version__ = "0.6.0" from .components import ( ActivityIndicator,