diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..0f8db75 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,78 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + e2e-android: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install PythonNative + run: pip install -e . + + - name: Install Maestro + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Build, install, and run E2E tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + arch: x86_64 + script: | + export PATH="$HOME/.maestro/bin:$PATH" + cd examples/hello-world + pn run android + sleep 5 + cd ../.. + maestro test tests/e2e/android.yaml + + e2e-ios: + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PythonNative + run: pip install -e . + + - name: Install Maestro and idb + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + brew tap facebook/fb && brew install idb-companion + + - name: Build and launch iOS app + working-directory: examples/hello-world + run: pn run ios + + - name: Run E2E tests + run: maestro --platform ios test tests/e2e/ios.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 430eac1..23a714b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # CHANGELOG +## v0.5.0 (2026-03-31) + +### Continuous Integration + +- **workflows**: Add Maestro E2E tests for Android and iOS + ([`cfe247e`](https://github.com/pythonnative/pythonnative/commit/cfe247edf99da8ff870e2d4118ef74b2df5521c1)) + +### Features + +- **core**: Replace imperative widget API with declarative component model and reconciler + ([`b6b7721`](https://github.com/pythonnative/pythonnative/commit/b6b77216305202ea0c5197b29e725e14cbe99b5e)) + + ## v0.4.0 (2026-03-18) ### Bug Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe5b6ab..78e1a10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -256,9 +256,38 @@ release/v0.2.0 hotfix/cli-regression ``` +### E2E tests (Maestro) + +End-to-end tests use [Maestro](https://maestro.dev/) to drive the hello-world example on real emulators and simulators. + +```bash +# Install Maestro (one-time) +curl -Ls "https://get.maestro.mobile.dev" | bash + +# For iOS, also install idb-companion +brew tap facebook/fb && brew install idb-companion +``` + +Build and launch the app first, then run the tests: + +```bash +cd examples/hello-world + +# Android (emulator must be running) +pn run android +maestro test ../../tests/e2e/android.yaml + +# iOS (simulator must be running; --platform ios needed when an Android emulator is also connected) +pn run ios +maestro --platform ios test ../../tests/e2e/ios.yaml +``` + +Test flows live in `tests/e2e/flows/` and cover main page rendering, counter interaction, and multi-page navigation. The `e2e.yml` workflow runs these automatically on pushes to `main` and PRs. + ### CI - **CI** (`ci.yml`): runs formatter, linter, type checker, and tests on every push and PR. +- **E2E** (`e2e.yml`): builds the hello-world example on Android (Linux emulator) and iOS (macOS simulator), then runs Maestro flows. Triggers on pushes to `main`, PRs, and manual dispatch. - **PR Lint** (`pr-lint.yml`): validates the PR title against Conventional Commits format (protects squash merges) and checks individual commit messages via commitlint (protects rebase merges). Recommended: add the **PR title** job as a required status check in branch-protection settings. - **Release** (`release.yml`): runs on merge to `main`; computes version, generates changelog, tags, creates GitHub Release, and (when `DRAFT_RELEASE` is `"false"`) publishes to PyPI. - **Docs** (`docs.yml`): deploys documentation to GitHub Pages on push to `main`. diff --git a/README.md b/README.md index 28f1672..d5c0706 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,17 @@ ## Overview -PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a Pythonic API for native UI components, lifecycle events, and device capabilities, powered by Chaquopy on Android and rubicon-objc on iOS. Write your app once in Python and run it on both platforms with genuinely native interfaces. +PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a **declarative, React-like component model** with automatic reconciliation, powered by Chaquopy on Android and rubicon-objc on iOS. Describe your UI as a tree of elements, manage state with `set_state()`, and let PythonNative handle creating and updating native views. ## Features -- **Cross-platform native UI:** Build Android and iOS apps from a single Python codebase with truly native rendering. +- **Declarative UI:** Describe *what* your UI should look like with element functions (`Text`, `Button`, `Column`, `Row`, etc.). PythonNative creates and updates native views automatically. +- **Reactive state:** Call `self.set_state(key=value)` and the framework re-renders only what changed — no manual view mutation. +- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation. - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. -- **Unified component API:** Components like `Page`, `StackView`, `Label`, `Button`, and `WebView` share a consistent interface across platforms. -- **CLI scaffolding:** `pn init` creates a ready-to-run project structure; `pn run android` and `pn run ios` build and launch your app. -- **Page lifecycle:** Hooks for `on_create`, `on_start`, `on_resume`, `on_pause`, `on_stop`, and `on_destroy`, with state save and restore. +- **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app. - **Navigation:** Push and pop screens with argument passing for multi-page apps. -- **Rich component set:** Core views (Label, Button, TextField, ImageView, WebView, Switch, DatePicker, and more) plus Material Design variants. -- **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. +- **Bundled templates:** Android Gradle and iOS Xcode templates are included — scaffolding requires no network access. ## Quick Start @@ -56,17 +55,36 @@ import pythonnative as pn class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button tapped")) - stack.add_view(button) - self.set_root_view(stack) + self.state = {"count": 0} + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button( + "Tap me", + on_click=lambda: self.set_state(count=self.state["count"] + 1), + ), + spacing=12, + padding=16, + ) ``` +### 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. diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index 2480b13..c6dd7ed 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -1,52 +1,100 @@ -## Component Property Reference (v0.4.0) +# Component Property Reference -This page summarizes common properties and fluent setters added in v0.4.0. Unless noted, methods return `self` for chaining. +All style and behaviour properties are passed as keyword arguments to element functions. -### View (base) +## Text -- `set_background_color(color)` - - Accepts ARGB int or `#RRGGBB` / `#AARRGGBB` string. +```python +pn.Text(text, font_size=None, color=None, bold=False, text_align=None, + background_color=None, max_lines=None) +``` -- `set_padding(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` - - Android: applies padding in dp. - - iOS: currently a no-op for most views. +- `text` — display string +- `font_size` — size in sp (Android) / pt (iOS) +- `color` — text colour (`#RRGGBB` or `#AARRGGBB`) +- `bold` — bold weight +- `text_align` — `"left"`, `"center"`, or `"right"` +- `background_color` — view background +- `max_lines` — limit visible lines -- `set_margin(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` - - Android: applied when added to `StackView` (LinearLayout) as `LayoutParams` margins (dp). - - iOS: not currently applied. +## Button -- `wrap_in_scroll()` → `ScrollView` - - Returns a `ScrollView` containing this view. +```python +pn.Button(title, on_click=None, color=None, background_color=None, + font_size=None, enabled=True) +``` -### ScrollView +- `title` — button label +- `on_click` — callback `() -> None` +- `color` — title text colour +- `background_color` — button background +- `enabled` — interactive state -- `ScrollView.wrap(view)` → `ScrollView` - - Class helper to wrap a single child. +## Column / Row -### StackView +```python +pn.Column(*children, spacing=0, padding=None, alignment=None, background_color=None) +pn.Row(*children, spacing=0, padding=None, alignment=None, background_color=None) +``` -- `set_axis('vertical'|'horizontal')` -- `set_spacing(n)` - - Android: dp via divider drawable. - - iOS: `UIStackView.spacing` (points). -- `set_alignment('fill'|'center'|'leading'|'trailing'|'top'|'bottom')` - - Cross-axis alignment; top/bottom map appropriately for horizontal stacks. +- `spacing` — gap between children (dp / pt) +- `padding` — inner padding (int for all sides, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) +- `alignment` — cross-axis: `"fill"`, `"center"`, `"leading"`, `"trailing"`, `"start"`, `"end"`, `"top"`, `"bottom"` +- `background_color` — container background -### Text components +## ScrollView -Applies to `Label`, `TextField`, `TextView`: +```python +pn.ScrollView(child, background_color=None) +``` -- `set_text(text)` -- `set_text_color(color)` -- `set_text_size(size)` +## TextInput -Platform notes: -- Android: `setTextColor(int)`, `setTextSize(sp)`. -- iOS: `setTextColor(UIColor)`, `UIFont.systemFont(ofSize:)`. +```python +pn.TextInput(value="", placeholder="", on_change=None, secure=False, + font_size=None, color=None, background_color=None) +``` -### Button +- `on_change` — callback `(str) -> None` receiving new text -- `set_title(text)` -- `set_on_click(callback)` +## Image +```python +pn.Image(source="", width=None, height=None, scale_type=None, background_color=None) +``` +## Switch + +```python +pn.Switch(value=False, on_change=None) +``` + +- `on_change` — callback `(bool) -> None` + +## ProgressBar + +```python +pn.ProgressBar(value=0.0, background_color=None) +``` + +- `value` — 0.0 to 1.0 + +## ActivityIndicator + +```python +pn.ActivityIndicator(animating=True) +``` + +## WebView + +```python +pn.WebView(url="") +``` + +## Spacer + +```python +pn.Spacer(size=None) +``` + +- `size` — fixed dimension in dp / pt diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 0233735..8147bf9 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -1,11 +1,33 @@ # pythonnative package -API reference will be generated here via mkdocstrings. +## Public API -Key flags and helpers: +### Page -- `pythonnative.utils.IS_ANDROID`: platform flag with robust detection for Chaquopy/Android. -- `pythonnative.utils.get_android_context()`: returns the current Android Activity/Context when running on Android. -- `pythonnative.utils.set_android_context(ctx)`: set by `pythonnative.Page` on Android; you generally don’t call this directly. -- `pythonnative.utils.get_android_fragment_container()`: returns the current Fragment container `ViewGroup` used for page rendering. -- `pythonnative.utils.set_android_fragment_container(viewGroup)`: set by the host `PageFragment`; you generally don’t call this directly. +`pythonnative.Page` — base class for screens. Subclass it, implement `render()`, and use `set_state()` to trigger re-renders. + +### Element functions + +- `pythonnative.Text`, `Button`, `Column`, `Row`, `ScrollView`, `TextInput`, `Image`, `Switch`, `ProgressBar`, `ActivityIndicator`, `WebView`, `Spacer` + +Each returns an `Element` descriptor. See the Component Property Reference for full signatures. + +### Element + +`pythonnative.Element` — the descriptor type returned by element functions. You generally don't create these directly. + +## 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`. + +## Native view registry + +`pythonnative.native_views.NativeViewRegistry` — maps element type names to platform-specific handlers. Use `set_registry()` to inject a mock for testing. diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index c7726dc..9ab94b0 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -1,58 +1,47 @@ # Architecture -PythonNative maps Python directly to native platform APIs. Conceptually, it is closer to NativeScript's dynamic bindings than to React Native's bridge-and-module approach. +PythonNative combines **direct native bindings** with a **declarative reconciler**, giving you React-like ergonomics while calling native platform APIs synchronously from Python. ## High-level model -- Direct bindings: call native APIs synchronously from Python. - - iOS: rubicon-objc exposes Objective-C/Swift classes (e.g., UIViewController, UIButton, WKWebView) and lets you create dynamic Objective-C subclasses and selectors. - - Android: Chaquopy exposes Java classes (e.g., android.widget.Button, android.webkit.WebView) via the java bridge so you can construct and call methods directly. -- Shared Python API: components like Page, StackView, Label, Button, and WebView have a small, consistent surface. Platform-specific behavior is chosen at import time using pythonnative.utils.IS_ANDROID. -- Thin native bootstrap: the host app remains native (Android Activity or iOS UIViewController). It passes a live instance/pointer into Python, and Python drives the UI from there. +1. **Declarative element tree:** Your `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: + - **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. + +## How it works + +``` +Page.render() → Element tree → Reconciler → Native views + ↑ +Page.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. ## Comparison -- Versus React Native: RN typically exposes capabilities via native modules/TurboModules and a bridge. PythonNative does not require authoring such modules for most APIs; you can access platform classes directly from Python. -- Versus NativeScript: similar philosophy—dynamic, synchronous access to Obj-C/Java from the scripting runtime. +- **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. ## iOS flow (Rubicon-ObjC) - The iOS template (Swift + PythonKit) boots Python and instantiates your `MainPage` with the current `UIViewController` pointer. -- In Python, Rubicon wraps the pointer; you then interact with UIKit classes directly. - -```python -from rubicon.objc import ObjCClass, ObjCInstance - -UIButton = ObjCClass("UIButton") -vc = ObjCInstance(native_ptr) # passed from Swift template -button = UIButton.alloc().init() -# Configure target/action via a dynamic Objective-C subclass (see Button implementation) -``` +- `Page.on_create()` calls `render()`, the reconciler creates UIKit views, and attaches them to the controller's view. +- State changes trigger `render()` again; the reconciler patches UIKit views in-place. ## Android flow (Chaquopy) -- The Android template (Kotlin + Chaquopy) initializes Python in MainActivity and provides the current Activity/Context to Python. -- Components acquire the Context implicitly and construct real Android views. - -```python -from java import jclass -from pythonnative.utils import get_android_context - -WebViewClass = jclass("android.webkit.WebView") -context = get_android_context() -webview = WebViewClass(context) -webview.loadUrl("https://example.com") -``` - -## Key implications - -- Synchronous native calls: no JS bridge; Python calls are direct. -- Lifecycle rules remain native: Activities/ViewControllers are created by the OS. Python receives and controls them; it does not instantiate Android Activities directly. -- Small, growing surface: the shared Python API favors clarity and consistency, expanding progressively. +- The Android template (Kotlin + Chaquopy) initializes Python in `MainActivity` and passes the `Activity` to Python. +- `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. ## Navigation model overview -- See the Navigation guide for full details and comparisons with other frameworks. +- See the Navigation guide for full details. - iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. - Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph. diff --git a/docs/concepts/components.md b/docs/concepts/components.md index e9d12ca..016c21c 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -1,51 +1,118 @@ # Components -High-level overview of PythonNative components and how they map to native UI. +PythonNative uses a **declarative component model** inspired by React. You describe *what* the UI should look like, and the framework handles creating and updating native views. -## Constructor pattern +## Element functions -- All core components share a consistent, contextless constructor on both platforms. -- On Android, a `Context` is acquired implicitly from the current `Activity` set by `pn.Page`. -- On iOS, UIKit classes are allocated/initialized directly. - -Examples: +UI is built with element-creating functions. Each returns a lightweight `Element` descriptor — no native objects are created until the reconciler mounts the tree. ```python import pythonnative as pn +pn.Text("Hello", font_size=18, color="#333333") +pn.Button("Tap me", on_click=lambda: print("tapped")) +pn.Column( + pn.Text("First"), + pn.Text("Second"), + spacing=8, + padding=16, +) +``` + +### Available components + +**Layout:** + +- `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 + +**Display:** + +- `Text(text, font_size, color, bold, text_align, background_color, max_lines)` — text display +- `Image(source, width, height, scale_type)` — image display +- `WebView(url)` — embedded web content + +**Input:** + +- `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 + +**Feedback:** + +- `ProgressBar(value)` — determinate progress (0.0–1.0) +- `ActivityIndicator(animating)` — indeterminate spinner + +## Page — the root component + +Each screen is a `Page` subclass with a `render()` method that returns an element tree: + +```python class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) + self.state = {"name": "World"} + + def render(self): + return pn.Text(f"Hello, {self.state['name']}!", font_size=24) +``` + +## State and re-rendering + +Call `self.set_state(key=value)` to update state. The framework automatically calls `render()` again and applies only the differences to the native views: + +```python +class CounterPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + self.state = {"count": 0} + + def increment(self): + self.set_state(count=self.state["count"] + 1) - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello")) - stack.add_view(pn.Button("Tap me")) - stack.add_view(pn.TextField("initial")) - self.set_root_view(stack) + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button("Increment", on_click=self.increment), + spacing=12, + ) ``` -Notes: -- `pn.Page` stores the Android `Activity` so components like `pn.Button()` and `pn.Label()` can construct their native counterparts. -- If you construct views before the `Page` is created on Android, a runtime error will be raised because no `Context` is available. +## Reusable components as functions -## Core components +For reusable UI pieces, use regular Python functions that return elements: -Stabilized with contextless constructors on both platforms: +```python +def greeting_card(name, on_tap): + 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, + ) -- `Page` -- `StackView` -- `Label`, `Button` -- `ImageView` -- `TextField`, `TextView` -- `Switch` -- `ProgressView`, `ActivityIndicatorView` -- `WebView` +class MainPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + return pn.Column( + greeting_card("Alice", lambda: print("Hi Alice")), + greeting_card("Bob", lambda: print("Hi Bob")), + spacing=16, + ) +``` -APIs are intentionally small and grow progressively in later releases. Properties and setters are kept consistent where supported by both platforms. +## Platform detection -## Platform detection and Android context +Use `pythonnative.utils.IS_ANDROID` when you need platform-specific logic: + +```python +from pythonnative.utils import IS_ANDROID + +title = "Android App" if IS_ANDROID else "iOS App" +``` -- Use `pythonnative.utils.IS_ANDROID` for platform checks when needed. -- On Android, `Page` records the current `Activity` so child views can acquire a `Context` implicitly. Constructing views before `Page` initialization will raise. +On Android, `Page` records the current `Activity` so component constructors can acquire a `Context` implicitly. Constructing views before `Page` initialisation will raise. diff --git a/docs/examples.md b/docs/examples.md index 821ee4a..0f007dc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,3 +1,56 @@ # Examples -A collection of simple examples showing PythonNative components and patterns. +A collection of examples showing PythonNative's declarative component model and patterns. + +## Quick counter + +```python +import pythonnative as pn + + +class CounterPage(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, + padding=16, + ) +``` + +## Reusable components + +```python +def labeled_input(label, placeholder=""): + return pn.Column( + pn.Text(label, font_size=14, bold=True), + pn.TextInput(placeholder=placeholder), + spacing=4, + ) + + +class FormPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + return pn.ScrollView( + pn.Column( + pn.Text("Sign Up", font_size=24, bold=True), + labeled_input("Name", "Enter your name"), + labeled_input("Email", "you@example.com"), + pn.Button("Submit", on_click=lambda: print("submitted")), + spacing=12, + padding=16, + ) + ) +``` + +See `examples/hello-world/` for a full multi-page demo with navigation. diff --git a/docs/examples/hello-world.md b/docs/examples/hello-world.md index b939317..56f07ac 100644 --- a/docs/examples/hello-world.md +++ b/docs/examples/hello-world.md @@ -1,6 +1,6 @@ # Hello World -Create a simple page with a label and a button. +Create a simple page with a counter that increments on tap. ```python import pythonnative as pn @@ -9,16 +9,18 @@ import pythonnative as pn class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) + self.state = {"count": 0} - def on_create(self): - super().on_create() - stack = pn.StackView() - label = pn.Label("Hello, world!") - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Hello tapped")) - stack.add_view(label) - stack.add_view(button) - self.set_root_view(stack) + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button( + "Tap me", + on_click=lambda: self.set_state(count=self.state["count"] + 1), + ), + spacing=12, + padding=16, + ) ``` Run it: diff --git a/docs/getting-started.md b/docs/getting-started.md index f35ff23..538f28e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ This scaffolds: - `requirements.txt` - `.gitignore` -A minimal `app/main_page.py` looks like (no bootstrap needed): +A minimal `app/main_page.py` looks like: ```python import pythonnative as pn @@ -27,17 +27,26 @@ import pythonnative as pn class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button clicked")) - stack.add_view(button) - self.set_root_view(stack) + self.state = {"count": 0} + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button( + "Tap me", + on_click=lambda: self.set_state(count=self.state["count"] + 1), + ), + spacing=12, + padding=16, + ) ``` +Key ideas: + +- **`render()`** returns an element tree describing the UI. PythonNative creates and updates native views automatically. +- **`self.state`** holds your page's data. Call **`self.set_state(key=value)`** to update it — the UI re-renders automatically. +- Element functions like `pn.Text(...)`, `pn.Button(...)`, `pn.Column(...)` create lightweight descriptions, not native objects. + ## Run on a platform ```bash diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 7ae0f4c..6887360 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -1,43 +1,56 @@ # Navigation -This guide shows how to navigate between pages and handle lifecycle events. +This guide shows how to navigate between pages and pass data. ## Push / Pop -Use `push` and `pop` on your `Page` to change screens. You can pass a dotted path string or a class reference. +Use `push` and `pop` on your `Page` to change screens. Pass a dotted path string or a class reference, with optional `args`. ```python import pythonnative as pn + class MainPage(pn.Page): - def on_create(self): - stack = pn.StackView() - btn = pn.Button("Go next") - btn.set_on_click(lambda: self.push("app.second_page.SecondPage", args={"message": "Hello"})) - stack.add_view(btn) - self.set_root_view(stack) + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + return pn.Column( + pn.Text("Main Page", font_size=24), + pn.Button( + "Go next", + on_click=lambda: self.push( + "app.second_page.SecondPage", + args={"message": "Hello from Main"}, + ), + ), + spacing=12, + padding=16, + ) ``` -On the target page: +On the target page, retrieve args with `self.get_args()`: ```python class SecondPage(pn.Page): - def on_create(self): - args = self.get_args() - message = args.get("message", "Second") - stack = pn.StackView() - stack.add_view(pn.Label(message)) - back = pn.Button("Back") - back.set_on_click(lambda: self.pop()) - stack.add_view(back) - self.set_root_view(stack) + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + message = self.get_args().get("message", "Second Page") + return pn.Column( + pn.Text(message, font_size=20), + pn.Button("Back", on_click=self.pop), + spacing=12, + padding=16, + ) ``` ## Lifecycle PythonNative forwards lifecycle events from the host: -- `on_create` +- `on_create` — triggers the initial `render()` - `on_start` - `on_resume` - `on_pause` @@ -47,7 +60,7 @@ PythonNative forwards lifecycle events from the host: - `on_save_instance_state` - `on_restore_instance_state` -Android uses a single `MainActivity` hosting a `NavHostFragment` and a generic `PageFragment` per page. iOS forwards `viewWillAppear`/`viewWillDisappear` via an internal registry. +Override any of these on your `Page` subclass to respond to lifecycle changes. ## Notes @@ -60,36 +73,14 @@ Android uses a single `MainActivity` hosting a `NavHostFragment` and a generic ` - Each PythonNative page is hosted by a Swift `ViewController` instance. - Pages are pushed and popped on a root `UINavigationController`. - Lifecycle is forwarded from Swift to the registered Python page instance. -- Root view wiring: `Page.set_root_view` sizes and inserts the Python-native view into the controller’s view. - -Why this matches iOS conventions -- iOS apps commonly model screens as `UIViewController`s and use `UINavigationController` for hierarchical navigation. -- The approach integrates cleanly with add-to-app and system behaviors (e.g., state restoration). ### Android (single Activity, Fragment stack) - Single host `MainActivity` sets a `NavHostFragment` containing a navigation graph. - Each PythonNative page is represented by a generic `PageFragment` which instantiates the Python page and attaches its root view. - `push`/`pop` delegate to `NavController` (via a small `Navigator` helper). -- Arguments (`page_path`, `args_json`) live in Fragment arguments and restore across configuration changes and process death. - -Why this matches Android conventions -- Modern Android apps favor one Activity with many Fragments, using Jetpack Navigation for back stack, transitions, and deep links. -- It simplifies lifecycle, back handling, and state compared to one-Activity-per-screen. +- Arguments live in Fragment arguments and restore across configuration changes. ## Comparison to other frameworks -- React Native - - Android: single `Activity`, screens managed via `Fragment`s (e.g., `react-native-screens`). - - iOS: screens map to `UIViewController`s pushed on `UINavigationController`. -- .NET MAUI / Xamarin.Forms - - Android: single `Activity`, pages via Fragments/Navigation. - - iOS: pages map to `UIViewController`s on a `UINavigationController`. -- NativeScript - - Android: single `Activity`, pages as `Fragment`s. - - iOS: pages as `UIViewController`s on `UINavigationController`. -- Flutter (special case) - - Android: single `Activity` (`FlutterActivity`/`FlutterFragmentActivity`). - - iOS: `FlutterViewController` hosts Flutter’s internal navigator; add-to-app can push multiple `FlutterViewController`s. - -Bottom line -- iOS: one host VC class, many instances on a `UINavigationController`. -- Android: one host `Activity`, many `Fragment`s with Jetpack Navigation. +- **React Native:** Android: single `Activity`, screens managed via `Fragment`s. iOS: screens map to `UIViewController`s pushed on `UINavigationController`. +- **NativeScript:** Android: single `Activity`, pages as `Fragment`s. iOS: pages as `UIViewController`s on `UINavigationController`. +- **Flutter:** Android: single `Activity`. iOS: `FlutterViewController` hosts Flutter's navigator. diff --git a/docs/guides/styling.md b/docs/guides/styling.md index 20de1b2..000e40f 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -1,93 +1,70 @@ -## Styling +# Styling -This guide covers the lightweight styling APIs introduced in v0.4.0. +Style properties are passed as keyword arguments to element functions. This replaces the old fluent setter pattern. -The goal is to provide a small, predictable set of cross-platform styling hooks. Some features are Android-only today and will expand over time. +## Colors -### Colors - -- Use `set_background_color(color)` on any view. -- Color can be an ARGB int or a hex string like `#RRGGBB` or `#AARRGGBB`. +Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color props: ```python -stack = pn.StackView().set_background_color("#FFF5F5F5") +pn.Text("Hello", color="#FF3366") +pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") +pn.Column(pn.Text("Content"), background_color="#FFF5F5F5") ``` -### Padding and Margin - -- `set_padding(...)` is available on all views. - - Android: applies using `View.setPadding` with dp units. - - iOS: currently a no-op for most views; prefer container spacing (`StackView.set_spacing`) and layout. - -- `set_margin(...)` records margins on the child view. - - Android: applied automatically when added to a `StackView` (LinearLayout) via `LayoutParams.setMargins` (dp). - - iOS: not currently applied. +## Text styling -`set_padding`/`set_margin` accept these parameters (integers): `all`, `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`. Individual sides override group values. +`Text` and `Button` accept `font_size`, `color`, `bold`, and `text_align`: ```python -pn.Label("Name").set_margin(bottom=8) -pn.TextField().set_padding(horizontal=12, vertical=8) +pn.Text("Title", font_size=24, bold=True, text_align="center") +pn.Text("Subtitle", font_size=14, color="#666666") ``` -### Text styling +## Layout with Column and Row -Text components expose: -- `set_text(text) -> self` -- `set_text_color(color) -> self` (hex or ARGB int) -- `set_text_size(size) -> self` (sp on Android; pt on iOS via system font) - -Applies to `Label`, `TextField`, and `TextView`. +`Column` (vertical) and `Row` (horizontal) replace the old `StackView`: ```python -pn.Label("Hello").set_text_color("#FF3366").set_text_size(18) +pn.Column( + pn.Text("Username"), + pn.TextInput(placeholder="Enter username"), + pn.Text("Password"), + pn.TextInput(placeholder="Enter password", secure=True), + pn.Button("Login", on_click=handle_login), + spacing=8, + padding=16, + alignment="fill", +) ``` -### StackView layout +### Spacing -`StackView` (Android LinearLayout / iOS UIStackView) adds configuration helpers: - -- `set_axis('vertical'|'horizontal') -> self` -- `set_spacing(n) -> self` (dp on Android; points on iOS) -- `set_alignment(value) -> self` - - Cross-axis alignment. Supported values: `fill`, `center`, `leading`/`top`, `trailing`/`bottom`. - -```python -form = ( - pn.StackView() - .set_axis('vertical') - .set_spacing(8) - .set_alignment('fill') - .set_padding(all=16) -) -form.add_view(pn.Label("Username").set_margin(bottom=4)) -form.add_view(pn.TextField().set_padding(horizontal=12, vertical=8)) -``` +- `spacing=N` sets the gap between children in dp (Android) / points (iOS). -### Scroll helpers +### Padding -Wrap any view in a `ScrollView` using either approach: +- `padding=16` — all sides +- `padding={"horizontal": 12, "vertical": 8}` — per axis +- `padding={"left": 8, "top": 16, "right": 8, "bottom": 16}` — per side -```python -scroll = pn.ScrollView.wrap(form) -# or -scroll = form.wrap_in_scroll() -``` +Android: applied via `setPadding` in dp. iOS: best-effort via layout margins. -Attach the scroll view as your page root: +### Alignment -```python -self.set_root_view(scroll) -``` +Cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"`. -### Fluent setters +## ScrollView -Most setters now return `self` for chaining, e.g.: +Wrap content in a `ScrollView`: ```python -pn.Button("Tap me").set_on_click(lambda: print("hi")).set_padding(all=8) +pn.ScrollView( + pn.Column( + pn.Text("Item 1"), + pn.Text("Item 2"), + # ... many items + spacing=8, + ) +) ``` - -Note: Where platform limitations exist, the methods are no-ops and still return `self`. - - diff --git a/docs/index.md b/docs/index.md index 97b6b2b..96ac99d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,23 @@ # PythonNative -Build native Android and iOS apps with Python. PythonNative provides a Pythonic API for native UI components and a simple CLI to scaffold and run projects. +Build native Android and iOS apps with Python using a declarative, React-like component model. + +PythonNative provides a Pythonic API for native UI components, a virtual view tree with automatic reconciliation, and a simple CLI to scaffold and run projects. + +```python +import pythonnative as pn + + +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, + padding=16, + ) +``` diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 2646880..9faa74f 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -2,62 +2,30 @@ import pythonnative as pn -try: - # Optional: used for styling below; safe if rubicon isn't available - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - class MainPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - - def on_create(self) -> None: - super().on_create() - stack = pn.StackView().set_axis("vertical").set_spacing(12).set_alignment("fill").set_padding(all=16) - stack.add_view(pn.Label("Hello from PythonNative Demo!").set_text_size(18)) - button = pn.Button("Go to Second Page").set_padding(vertical=10, horizontal=14) - - def on_next() -> None: - # Visual confirmation that tap worked (iOS only) - try: - if UIColor is not None: - button.native_instance.setBackgroundColor_(UIColor.systemGreenColor()) - button.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - # Demonstrate passing args - self.push("app.second_page.SecondPage", args={"message": "Greetings from MainPage"}) - - button.set_on_click(on_next) - # Make the button visually obvious - button.set_background_color("#FF1E88E5") - stack.add_view(button) - self.set_root_view(stack.wrap_in_scroll()) - - def on_start(self) -> None: - super().on_start() - - def on_resume(self) -> None: - super().on_resume() - - def on_pause(self) -> None: - super().on_pause() - - def on_stop(self) -> None: - super().on_stop() - - def on_destroy(self) -> None: - super().on_destroy() - - def on_restart(self) -> None: - super().on_restart() - - def on_save_instance_state(self) -> None: - super().on_save_instance_state() - - def on_restore_instance_state(self) -> None: - super().on_restore_instance_state() + self.state = {"count": 0} + + def increment(self) -> None: + self.set_state(count=self.state["count"] + 1) + + def render(self) -> pn.Element: + 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.Button("Tap me", on_click=self.increment, background_color="#FF1E88E5"), + pn.Button( + "Go to Second Page", + on_click=lambda: self.push( + "app.second_page.SecondPage", + args={"message": "Greetings from MainPage"}, + ), + ), + spacing=12, + padding=16, + alignment="fill", + ) + ) diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 3515527..bd8d2f1 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -2,73 +2,23 @@ import pythonnative as pn -try: - # Optional: iOS styling support (safe if rubicon isn't available) - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - class SecondPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self) -> None: - super().on_create() - stack_view = pn.StackView() - # Read args passed from MainPage - args = self.get_args() - message = args.get("message", "Second page!") - stack_view.add_view(pn.Label(message)) - # Navigate to Third Page - to_third_btn = pn.Button("Go to Third Page") - # Style button on iOS similar to MainPage - try: - if UIColor is not None: - to_third_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - - def on_next() -> None: - # Visual confirmation that tap worked (iOS only) - try: - if UIColor is not None: - to_third_btn.native_instance.setBackgroundColor_(UIColor.systemGreenColor()) - to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - self.push("app.third_page.ThirdPage", args={"from": "Second"}) - - to_third_btn.set_on_click(on_next) - stack_view.add_view(to_third_btn) - back_btn = pn.Button("Back") - back_btn.set_on_click(lambda: self.pop()) - stack_view.add_view(back_btn) - self.set_root_view(stack_view) - - def on_start(self) -> None: - super().on_start() - - def on_resume(self) -> None: - super().on_resume() - - def on_pause(self) -> None: - super().on_pause() - - def on_stop(self) -> None: - super().on_stop() - - def on_destroy(self) -> None: - super().on_destroy() - - def on_restart(self) -> None: - super().on_restart() - - def on_save_instance_state(self) -> None: - super().on_save_instance_state() - - def on_restore_instance_state(self) -> None: - super().on_restore_instance_state() + def render(self) -> pn.Element: + message = self.get_args().get("message", "Second Page") + return pn.ScrollView( + pn.Column( + pn.Text(message, font_size=20), + pn.Button( + "Go to Third Page", + on_click=lambda: self.push("app.third_page.ThirdPage"), + ), + pn.Button("Back", on_click=self.pop), + spacing=12, + padding=16, + alignment="fill", + ) + ) diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index 97b003f..efd3d5c 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -2,31 +2,17 @@ import pythonnative as pn -try: - # Optional: iOS styling support (safe if rubicon isn't available) - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - class ThirdPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self) -> None: - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("This is the Third Page")) - back_btn = pn.Button("Back") - # Style button on iOS similar to MainPage - try: - if UIColor is not None: - back_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - back_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - back_btn.set_on_click(lambda: self.pop()) - stack.add_view(back_btn) - self.set_root_view(stack) + def render(self) -> pn.Element: + return pn.Column( + pn.Text("Third Page", font_size=24, bold=True), + pn.Text("You navigated two levels deep."), + pn.Button("Back to Second", on_click=self.pop), + spacing=12, + padding=16, + alignment="fill", + ) diff --git a/mypy.ini b/mypy.ini index 333ddb6..9657f4d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -15,5 +15,5 @@ disallow_incomplete_defs = True implicit_reexport = True disable_error_code = attr-defined,no-redef -[mypy-pythonnative.button] +[mypy-pythonnative.native_views] disable_error_code = misc diff --git a/pyproject.toml b/pyproject.toml index 95432b6..049088c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.4.0" +version = "0.5.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 a185962..053a95e 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -1,74 +1,54 @@ -from importlib import import_module -from typing import Any, Dict +"""PythonNative — declarative native UI for Android and iOS. -__version__ = "0.4.0" +Public API:: + + import pythonnative as pn + + 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, + ) +""" + +__version__ = "0.5.0" + +from .components import ( + ActivityIndicator, + Button, + Column, + Image, + ProgressBar, + Row, + ScrollView, + Spacer, + Switch, + Text, + TextInput, + WebView, +) +from .element import Element +from .page import Page __all__ = [ - "ActivityIndicatorView", + "ActivityIndicator", "Button", - "DatePicker", - "ImageView", - "Label", - "ListView", - "MaterialActivityIndicatorView", - "MaterialButton", - "MaterialDatePicker", - "MaterialProgressView", - "MaterialSearchBar", - "MaterialSwitch", - "MaterialTimePicker", - "MaterialBottomNavigationView", - "MaterialToolbar", + "Column", + "Element", + "Image", "Page", - "PickerView", - "ProgressView", + "ProgressBar", + "Row", "ScrollView", - "SearchBar", - "StackView", + "Spacer", "Switch", - "TextField", - "TextView", - "TimePicker", + "Text", + "TextInput", "WebView", ] - -_NAME_TO_MODULE: Dict[str, str] = { - "ActivityIndicatorView": ".activity_indicator_view", - "Button": ".button", - "DatePicker": ".date_picker", - "ImageView": ".image_view", - "Label": ".label", - "ListView": ".list_view", - "MaterialActivityIndicatorView": ".material_activity_indicator_view", - "MaterialButton": ".material_button", - "MaterialDatePicker": ".material_date_picker", - "MaterialProgressView": ".material_progress_view", - "MaterialSearchBar": ".material_search_bar", - "MaterialSwitch": ".material_switch", - "MaterialTimePicker": ".material_time_picker", - "MaterialBottomNavigationView": ".material_bottom_navigation_view", - "MaterialToolbar": ".material_toolbar", - "Page": ".page", - "PickerView": ".picker_view", - "ProgressView": ".progress_view", - "ScrollView": ".scroll_view", - "SearchBar": ".search_bar", - "StackView": ".stack_view", - "Switch": ".switch", - "TextField": ".text_field", - "TextView": ".text_view", - "TimePicker": ".time_picker", - "WebView": ".web_view", -} - - -def __getattr__(name: str) -> Any: - module_path = _NAME_TO_MODULE.get(name) - if not module_path: - raise AttributeError(f"module 'pythonnative' has no attribute {name!r}") - module = import_module(module_path, package=__name__) - return getattr(module, name) - - -def __dir__() -> Any: - return sorted(list(globals().keys()) + __all__) diff --git a/src/pythonnative/activity_indicator_view.py b/src/pythonnative/activity_indicator_view.py deleted file mode 100644 index 722924e..0000000 --- a/src/pythonnative/activity_indicator_view.py +++ /dev/null @@ -1,71 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ActivityIndicatorViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def start_animating(self) -> None: - pass - - @abstractmethod - def stop_animating(self) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ProgressBar - # ======================================== - - from java import jclass - - class ActivityIndicatorView(ActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ProgressBar") - # self.native_instance = self.native_class(context, None, android.R.attr.progressBarStyleLarge) - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(True) - - def start_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.VISIBLE) - return - - def stop_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.GONE) - return - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiactivityindicatorview - # ======================================== - - from rubicon.objc import ObjCClass - - class ActivityIndicatorView(ActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIActivityIndicatorView") - self.native_instance = self.native_class.alloc().initWithActivityIndicatorStyle_( - 0 - ) # 0: UIActivityIndicatorViewStyleLarge - self.native_instance.hidesWhenStopped = True - - def start_animating(self) -> None: - self.native_instance.startAnimating() - - def stop_animating(self) -> None: - self.native_instance.stopAnimating() diff --git a/src/pythonnative/button.py b/src/pythonnative/button.py deleted file mode 100644 index 9d96904..0000000 --- a/src/pythonnative/button.py +++ /dev/null @@ -1,113 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Callable, Optional - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ButtonBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_title(self, title: str) -> "ButtonBase": - pass - - @abstractmethod - def get_title(self) -> str: - pass - - @abstractmethod - def set_on_click(self, callback: Callable[[], None]) -> "ButtonBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Button - # ======================================== - - from java import dynamic_proxy, jclass - - class Button(ButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.Button") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_title(title) - - def set_title(self, title: str) -> "Button": - self.native_instance.setText(title) - return self - - def get_title(self) -> str: - return self.native_instance.getText().toString() - - def set_on_click(self, callback: Callable[[], None]) -> "Button": - class OnClickListener(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() - - listener = OnClickListener(callback) - self.native_instance.setOnClickListener(listener) - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uibutton - # ======================================== - - from rubicon.objc import SEL, ObjCClass, objc_method - - NSObject = ObjCClass("NSObject") - - # Mypy cannot understand Rubicon's dynamic subclassing; ignore the base type here. - class _PNButtonHandler(NSObject): # type: ignore[valid-type] - # Set by the Button when wiring up the target/action callback. - _callback: Optional[Callable[[], None]] = None - - @objc_method - def onTap_(self, sender: object) -> None: - try: - callback = self._callback - if callback is not None: - callback() - except Exception: - # Swallow exceptions to avoid crashing the app; logging is handled at higher levels - pass - - class Button(ButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIButton") - self.native_instance = self.native_class.alloc().init() - self.set_title(title) - - def set_title(self, title: str) -> "Button": - self.native_instance.setTitle_forState_(title, 0) - return self - - def get_title(self) -> str: - return self.native_instance.titleForState_(0) - - def set_on_click(self, callback: Callable[[], None]) -> "Button": - # Create a handler object with an Objective-C method `onTap:` and attach the Python callback - handler = _PNButtonHandler.new() - # Keep strong references to the handler and callback - self._click_handler = handler - handler._callback = callback - # UIControlEventTouchUpInside = 1 << 6 - self.native_instance.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) - return self diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 8281eae..22848c4 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -41,7 +41,6 @@ def init_project(args: argparse.Namespace) -> None: os.makedirs(app_dir, exist_ok=True) - # Minimal hello world app scaffold (no bootstrap function; host instantiates Page directly) main_page_py = os.path.join(app_dir, "main_page.py") if not os.path.exists(main_page_py) or args.force: with open(main_page_py, "w", encoding="utf-8") as f: @@ -52,20 +51,22 @@ def init_project(args: argparse.Namespace) -> None: class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = ( - pn.StackView() - .set_axis("vertical") - .set_spacing(12) - .set_alignment("fill") - .set_padding(all=16) + self.state = {"count": 0} + + def increment(self): + self.set_state(count=self.state["count"] + 1) + + def render(self): + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative!", font_size=24, bold=True), + pn.Text(f"Tapped {self.state['count']} times"), + pn.Button("Tap me", on_click=self.increment), + spacing=12, + padding=16, + alignment="fill", + ) ) - stack.add_view(pn.Label("Hello from PythonNative!").set_text_size(18)) - button = pn.Button("Tap me").set_on_click(lambda: print("Button clicked")) - stack.add_view(button) - self.set_root_view(stack.wrap_in_scroll()) """ ) diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py new file mode 100644 index 0000000..ad57eb5 --- /dev/null +++ b/src/pythonnative/components.py @@ -0,0 +1,241 @@ +"""Built-in element-creating functions for declarative UI composition. + +Each function returns an :class:`Element` describing a native UI widget. +These are pure data — no native views are created until the reconciler +mounts the element tree. + +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) +""" + +from typing import Any, Callable, Dict, Optional, Union + +from .element import Element + + +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} + + +# --------------------------------------------------------------------------- +# Leaf components +# --------------------------------------------------------------------------- + + +def Text( + text: str = "", + *, + font_size: Optional[float] = None, + color: Optional[str] = None, + bold: bool = False, + text_align: Optional[str] = None, + background_color: Optional[str] = None, + max_lines: Optional[int] = None, + key: Optional[str] = None, +) -> Element: + """Display text.""" + props = _filter_none( + text=text, + font_size=font_size, + color=color, + bold=bold or None, + text_align=text_align, + background_color=background_color, + max_lines=max_lines, + ) + return Element("Text", props, [], key=key) + + +def Button( + title: str = "", + *, + on_click: Optional[Callable[[], None]] = None, + color: Optional[str] = None, + background_color: Optional[str] = None, + font_size: Optional[float] = None, + enabled: bool = True, + key: Optional[str] = None, +) -> Element: + """Create a tappable button.""" + props: Dict[str, Any] = {"title": title} + if on_click is not None: + props["on_click"] = on_click + if color is not None: + props["color"] = color + if background_color is not None: + props["background_color"] = background_color + if font_size is not None: + props["font_size"] = font_size + if not enabled: + props["enabled"] = False + return Element("Button", props, [], key=key) + + +def TextInput( + *, + value: str = "", + placeholder: str = "", + on_change: Optional[Callable[[str], None]] = None, + secure: bool = False, + font_size: Optional[float] = None, + color: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Create a single-line text entry field.""" + props: Dict[str, Any] = {"value": value} + if placeholder: + props["placeholder"] = placeholder + if on_change is not None: + props["on_change"] = on_change + if secure: + props["secure"] = True + if font_size is not None: + props["font_size"] = font_size + if color is not None: + props["color"] = color + if background_color is not None: + props["background_color"] = background_color + return Element("TextInput", props, [], key=key) + + +def Image( + source: str = "", + *, + width: Optional[float] = None, + height: Optional[float] = None, + scale_type: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Display an image from a resource path or URL.""" + props = _filter_none( + source=source or None, + width=width, + height=height, + scale_type=scale_type, + background_color=background_color, + ) + return Element("Image", props, [], key=key) + + +def Switch( + *, + value: bool = False, + on_change: Optional[Callable[[bool], None]] = 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 + return Element("Switch", props, [], key=key) + + +def ProgressBar( + *, + value: float = 0.0, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Show determinate progress (0.0 – 1.0).""" + props = _filter_none(value=value, background_color=background_color) + return Element("ProgressBar", props, [], key=key) + + +def ActivityIndicator( + *, + animating: bool = True, + key: Optional[str] = None, +) -> Element: + """Show an indeterminate loading spinner.""" + return Element("ActivityIndicator", {"animating": animating}, [], key=key) + + +def WebView( + *, + url: str = "", + key: Optional[str] = None, +) -> Element: + """Embed web content.""" + props: Dict[str, Any] = {} + if url: + props["url"] = url + return Element("WebView", props, [], key=key) + + +def Spacer( + *, + size: Optional[float] = None, + key: Optional[str] = None, +) -> Element: + """Insert empty space with an optional fixed size.""" + props = _filter_none(size=size) + return Element("Spacer", props, [], key=key) + + +# --------------------------------------------------------------------------- +# Container components +# --------------------------------------------------------------------------- + +PaddingValue = Union[int, float, Dict[str, Union[int, float]]] + + +def Column( + *children: Element, + spacing: float = 0, + padding: Optional[PaddingValue] = None, + alignment: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Arrange children vertically.""" + props = _filter_none( + spacing=spacing or None, + padding=padding, + alignment=alignment, + background_color=background_color, + ) + return Element("Column", props, list(children), key=key) + + +def Row( + *children: Element, + spacing: float = 0, + padding: Optional[PaddingValue] = None, + alignment: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Arrange children horizontally.""" + props = _filter_none( + spacing=spacing or None, + padding=padding, + alignment=alignment, + background_color=background_color, + ) + return Element("Row", props, list(children), key=key) + + +def ScrollView( + child: Optional[Element] = None, + *, + background_color: 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) + return Element("ScrollView", props, children, key=key) diff --git a/src/pythonnative/date_picker.py b/src/pythonnative/date_picker.py deleted file mode 100644 index f357e03..0000000 --- a/src/pythonnative/date_picker.py +++ /dev/null @@ -1,76 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class DatePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_date(self, year: int, month: int, day: int) -> "DatePickerBase": - pass - - @abstractmethod - def get_date(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/DatePicker - # ======================================== - - from typing import Any - - from java import jclass - - class DatePicker(DatePickerBase, ViewBase): - def __init__(self, context: Any, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.DatePicker") - self.native_instance = self.native_class(context) - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> "DatePicker": - self.native_instance.updateDate(year, month, day) - return self - - def get_date(self) -> tuple: - year = self.native_instance.getYear() - month = self.native_instance.getMonth() - day = self.native_instance.getDayOfMonth() - return year, month, day - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import datetime - - from rubicon.objc import ObjCClass - - class DatePicker(DatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> "DatePicker": - date = datetime(year, month, day) - self.native_instance.setDate_(date) - return self - - def get_date(self) -> tuple: - date = self.native_instance.date() - return date.year, date.month, date.day diff --git a/src/pythonnative/element.py b/src/pythonnative/element.py new file mode 100644 index 0000000..8930386 --- /dev/null +++ b/src/pythonnative/element.py @@ -0,0 +1,47 @@ +"""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. +""" + +from typing import Any, Dict, List, Optional + + +class Element: + """Immutable description of a single UI node.""" + + __slots__ = ("type", "props", "children", "key") + + def __init__( + self, + type_name: str, + props: Dict[str, Any], + children: List["Element"], + key: Optional[str] = None, + ) -> None: + self.type = type_name + self.props = props + self.children = children + self.key = key + + def __repr__(self) -> str: + return f"Element({self.type!r}, props={set(self.props)}, children={len(self.children)})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Element): + return NotImplemented + return ( + self.type == other.type + and self.props == other.props + and self.children == other.children + and self.key == other.key + ) + + def __ne__(self, other: object) -> bool: + result = self.__eq__(other) + if result is NotImplemented: + return result + return not result diff --git a/src/pythonnative/image_view.py b/src/pythonnative/image_view.py deleted file mode 100644 index c3b3d1a..0000000 --- a/src/pythonnative/image_view.py +++ /dev/null @@ -1,78 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ImageViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_image(self, image: str) -> "ImageViewBase": - pass - - @abstractmethod - def get_image(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ImageView - # ======================================== - - from android.graphics import BitmapFactory - from java import jclass - - class ImageView(ImageViewBase, ViewBase): - def __init__(self, image: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.ImageView") - context = get_android_context() - self.native_instance = self.native_class(context) - if image: - self.set_image(image) - - def set_image(self, image: str) -> "ImageView": - bitmap = BitmapFactory.decodeFile(image) - self.native_instance.setImageBitmap(bitmap) - return self - - def get_image(self) -> str: - # Please note that this is a simplistic representation, getting image from ImageView - # in Android would require converting Drawable to Bitmap and then to File - return "Image file path in Android" - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiimageview - # ======================================== - - from rubicon.objc import ObjCClass - from rubicon.objc.api import NSString, UIImage - - class ImageView(ImageViewBase, ViewBase): - def __init__(self, image: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIImageView") - self.native_instance = self.native_class.alloc().init() - if image: - self.set_image(image) - - def set_image(self, image: str) -> "ImageView": - ns_str = NSString.alloc().initWithUTF8String_(image) - ui_image = UIImage.imageNamed_(ns_str) - self.native_instance.setImage_(ui_image) - return self - - def get_image(self) -> str: - # Similar to Android, getting the image from UIImageView isn't straightforward. - return "Image file name in iOS" diff --git a/src/pythonnative/label.py b/src/pythonnative/label.py deleted file mode 100644 index d998b7d..0000000 --- a/src/pythonnative/label.py +++ /dev/null @@ -1,133 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class LabelBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> "LabelBase": - pass - - @abstractmethod - def get_text(self) -> str: - pass - - @abstractmethod - def set_text_color(self, color: Any) -> "LabelBase": - pass - - @abstractmethod - def set_text_size(self, size: float) -> "LabelBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/TextView - # ======================================== - - from java import jclass - - class Label(LabelBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.TextView") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_text(text) - - def set_text(self, text: str) -> "Label": - self.native_instance.setText(text) - return self - - def get_text(self) -> str: - return self.native_instance.getText().toString() - - def set_text_color(self, color: Any) -> "Label": - # Accept int ARGB or hex string - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - self.native_instance.setTextColor(color_int) - except Exception: - pass - return self - - def set_text_size(self, size_sp: float) -> "Label": - try: - self.native_instance.setTextSize(float(size_sp)) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uilabel - # ======================================== - - from rubicon.objc import ObjCClass - - class Label(LabelBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UILabel") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> "Label": - self.native_instance.setText_(text) - return self - - def get_text(self) -> str: - return self.native_instance.text() - - def set_text_color(self, color: Any) -> "Label": - # Accept int ARGB or hex string - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - self.native_instance.setTextColor_(color_obj) - except Exception: - pass - return self - - def set_text_size(self, size: float) -> "Label": - try: - UIFont = ObjCClass("UIFont") - font = UIFont.systemFontOfSize_(float(size)) - self.native_instance.setFont_(font) - except Exception: - pass - return self diff --git a/src/pythonnative/list_view.py b/src/pythonnative/list_view.py deleted file mode 100644 index c4433e4..0000000 --- a/src/pythonnative/list_view.py +++ /dev/null @@ -1,76 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ListViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_data(self, data: list) -> "ListViewBase": - pass - - @abstractmethod - def get_data(self) -> list: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ListView - # ======================================== - - from java import jclass - - class ListView(ListViewBase, ViewBase): - def __init__(self, context: Any, data: list = []) -> None: - super().__init__() - self.context = context - self.native_class = jclass("android.widget.ListView") - self.native_instance = self.native_class(context) - self.set_data(data) - - def set_data(self, data: list) -> "ListView": - adapter = jclass("android.widget.ArrayAdapter")( - self.context, jclass("android.R$layout").simple_list_item_1, data - ) - self.native_instance.setAdapter(adapter) - return self - - def get_data(self) -> list: - adapter = self.native_instance.getAdapter() - return [adapter.getItem(i) for i in range(adapter.getCount())] - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitableview - # ======================================== - - from rubicon.objc import ObjCClass - - class ListView(ListViewBase, ViewBase): - def __init__(self, data: list = []) -> None: - super().__init__() - self.native_class = ObjCClass("UITableView") - self.native_instance = self.native_class.alloc().init() - self.set_data(data) - - def set_data(self, data: list) -> "ListView": - # Note: This is a simplified representation. Normally, you would need to create a UITableViewDataSource. - self.native_instance.reloadData() - return self - - def get_data(self) -> list: - # Note: This is a simplified representation. - # Normally, you would need to get data from the UITableViewDataSource. - return [] diff --git a/src/pythonnative/material_activity_indicator_view.py b/src/pythonnative/material_activity_indicator_view.py deleted file mode 100644 index b619960..0000000 --- a/src/pythonnative/material_activity_indicator_view.py +++ /dev/null @@ -1,71 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialActivityIndicatorViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def start_animating(self) -> None: - pass - - @abstractmethod - def stop_animating(self) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/progressindicator/CircularProgressIndicator - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self, context: Any) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.progressindicator.CircularProgressIndicator") - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(True) - - def start_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.VISIBLE) - return - - def stop_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.GONE) - return - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiactivityindicatorview - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIActivityIndicatorView") - self.native_instance = self.native_class.alloc().initWithActivityIndicatorStyle_( - 0 - ) # 0: UIActivityIndicatorViewStyleLarge - self.native_instance.hidesWhenStopped = True - - def start_animating(self) -> None: - self.native_instance.startAnimating() - - def stop_animating(self) -> None: - self.native_instance.stopAnimating() diff --git a/src/pythonnative/material_button.py b/src/pythonnative/material_button.py deleted file mode 100644 index 5816e30..0000000 --- a/src/pythonnative/material_button.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialButtonBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_title(self, title: str) -> "MaterialButtonBase": - pass - - @abstractmethod - def get_title(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/button/MaterialButton - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, context: Any, title: str = "") -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.button.MaterialButton") - self.native_instance = self.native_class(context) - self.set_title(title) - - def set_title(self, title: str) -> "MaterialButton": - self.native_instance.setText(title) - return self - - def get_title(self) -> str: - return self.native_instance.getText().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uibutton - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIButton") # Apple does not have a direct equivalent for MaterialButton - self.native_instance = self.native_class.alloc().init() - self.set_title(title) - - def set_title(self, title: str) -> "MaterialButton": - self.native_instance.setTitle_forState_(title, 0) - return self - - def get_title(self) -> str: - return self.native_instance.titleForState_(0) diff --git a/src/pythonnative/material_date_picker.py b/src/pythonnative/material_date_picker.py deleted file mode 100644 index f39ba3e..0000000 --- a/src/pythonnative/material_date_picker.py +++ /dev/null @@ -1,87 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialDatePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_date(self, year: int, month: int, day: int) -> "MaterialDatePickerBase": - pass - - @abstractmethod - def get_date(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/datepicker/MaterialDatePicker - # ======================================== - - from java import jclass - - class MaterialDatePicker(MaterialDatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.datepicker.MaterialDatePicker") - self.builder = self.native_class.Builder.datePicker() - self.set_date(year, month, day) - self.native_instance = self.builder.build() - - def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": - # MaterialDatePicker uses milliseconds since epoch to set date - from java.util import Calendar - - cal = Calendar.getInstance() - cal.set(year, month, day) - milliseconds = cal.getTimeInMillis() - self.builder.setSelection(milliseconds) - return self - - def get_date(self) -> tuple: - # Convert selection (milliseconds since epoch) back to a date - from java.util import Calendar - - cal = Calendar.getInstance() - cal.setTimeInMillis(self.native_instance.getSelection()) - return ( - cal.get(Calendar.YEAR), - cal.get(Calendar.MONTH), - cal.get(Calendar.DAY_OF_MONTH), - ) - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import datetime - - from rubicon.objc import ObjCClass - - class MaterialDatePicker(MaterialDatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": - date = datetime(year, month, day) - self.native_instance.setDate_(date) - return self - - def get_date(self) -> tuple: - date = self.native_instance.date() - return date.year, date.month, date.day diff --git a/src/pythonnative/material_progress_view.py b/src/pythonnative/material_progress_view.py deleted file mode 100644 index 2b76275..0000000 --- a/src/pythonnative/material_progress_view.py +++ /dev/null @@ -1,70 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialProgressViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_progress(self, progress: float) -> "MaterialProgressViewBase": - pass - - @abstractmethod - def get_progress(self) -> float: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/progressindicator/LinearProgressIndicator - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self, context: Any) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.progressindicator.LinearProgressIndicator") - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(False) - - def set_progress(self, progress: float) -> "MaterialProgressView": - self.native_instance.setProgress(int(progress * 100)) - return self - - def get_progress(self) -> float: - return self.native_instance.getProgress() / 100.0 - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiprogressview - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIProgressView") - self.native_instance = self.native_class.alloc().initWithProgressViewStyle_( - 0 - ) # 0: UIProgressViewStyleDefault - - def set_progress(self, progress: float) -> "MaterialProgressView": - self.native_instance.setProgress_animated_(progress, False) - return self - - def get_progress(self) -> float: - return self.native_instance.progress() diff --git a/src/pythonnative/material_search_bar.py b/src/pythonnative/material_search_bar.py deleted file mode 100644 index 950161c..0000000 --- a/src/pythonnative/material_search_bar.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialSearchBarBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_query(self, query: str) -> "MaterialSearchBarBase": - pass - - @abstractmethod - def get_query(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/search/SearchBar - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, context: Any, query: str = "") -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.search.SearchBar") - self.native_instance = self.native_class(context) - self.set_query(query) - - def set_query(self, query: str) -> "MaterialSearchBar": - self.native_instance.setQuery(query, False) - return self - - def get_query(self) -> str: - return self.native_instance.getQuery().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uisearchbar - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, query: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UISearchBar") - self.native_instance = self.native_class.alloc().init() - self.set_query(query) - - def set_query(self, query: str) -> "MaterialSearchBar": - self.native_instance.set_searchText_(query) - return self - - def get_query(self) -> str: - return self.native_instance.searchText() diff --git a/src/pythonnative/material_switch.py b/src/pythonnative/material_switch.py deleted file mode 100644 index 20e9bab..0000000 --- a/src/pythonnative/material_switch.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialSwitchBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_on(self, value: bool) -> "MaterialSwitchBase": - pass - - @abstractmethod - def is_on(self) -> bool: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/materialswitch/MaterialSwitch - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, context: Any, value: bool = False) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.switch.MaterialSwitch") - self.native_instance = self.native_class(context) - self.set_on(value) - - def set_on(self, value: bool) -> "MaterialSwitch": - self.native_instance.setChecked(value) - return self - - def is_on(self) -> bool: - return self.native_instance.isChecked() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiswitch - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = ObjCClass("UISwitch") - self.native_instance = self.native_class.alloc().init() - self.set_on(value) - - def set_on(self, value: bool) -> "MaterialSwitch": - self.native_instance.setOn_animated_(value, False) - return self - - def is_on(self) -> bool: - return self.native_instance.isOn() diff --git a/src/pythonnative/material_time_picker.py b/src/pythonnative/material_time_picker.py deleted file mode 100644 index 5829ca7..0000000 --- a/src/pythonnative/material_time_picker.py +++ /dev/null @@ -1,76 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialTimePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_time(self, hour: int, minute: int) -> "MaterialTimePickerBase": - pass - - @abstractmethod - def get_time(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/timepicker/MaterialTimePicker - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.timepicker.MaterialTimePicker") - self.native_instance = self.native_class(context) - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": - self.native_instance.setTime(hour, minute) - return self - - def get_time(self) -> tuple: - hour = self.native_instance.getHour() - minute = self.native_instance.getMinute() - return hour, minute - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import time - - from rubicon.objc import ObjCClass - - class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.native_instance.setDatePickerMode_(1) # Setting mode to Time - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": - t = time(hour, minute) - self.native_instance.setTime_(t) - return self - - def get_time(self) -> tuple: - t = self.native_instance.time() - return t.hour, t.minute diff --git a/src/pythonnative/native_views.py b/src/pythonnative/native_views.py new file mode 100644 index 0000000..2efab0e --- /dev/null +++ b/src/pythonnative/native_views.py @@ -0,0 +1,800 @@ +"""Platform-specific native view creation and update logic. + +This module replaces the old per-widget files. All platform-branching +lives here, guarded behind lazy imports so the module can be imported +on desktop for testing. +""" + +from typing import Any, Callable, Dict, Optional, Union + +from .utils import IS_ANDROID + +# ====================================================================== +# Abstract handler protocol +# ====================================================================== + + +class ViewHandler: + """Protocol for creating, updating, and managing children of a native view type.""" + + def create(self, props: Dict[str, Any]) -> Any: + raise NotImplementedError + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + raise NotImplementedError + + def add_child(self, parent: Any, child: Any) -> None: + pass + + def remove_child(self, parent: Any, child: Any) -> None: + pass + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + self.add_child(parent, child) + + +# ====================================================================== +# Registry +# ====================================================================== + + +class NativeViewRegistry: + """Maps element type names to platform-specific :class:`ViewHandler` instances.""" + + def __init__(self) -> None: + self._handlers: Dict[str, ViewHandler] = {} + + def register(self, type_name: str, handler: ViewHandler) -> None: + self._handlers[type_name] = handler + + def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: + handler = self._handlers.get(type_name) + if handler is None: + raise ValueError(f"Unknown element type: {type_name!r}") + return handler.create(props) + + def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: + handler = self._handlers.get(type_name) + if handler is not None: + handler.update(native_view, changed_props) + + def add_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.add_child(parent, child) + + def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.remove_child(parent, child) + + def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.insert_child(parent, child, index) + + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def parse_color_int(color: Union[str, int]) -> int: + """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. + + Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values + with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. + """ + if isinstance(color, int): + val = color + else: + c = color.strip().lstrip("#") + if len(c) == 6: + c = "FF" + c + val = int(c, 16) + if val > 0x7FFFFFFF: + val -= 0x100000000 + return val + + +def _resolve_padding( + padding: Any, +) -> tuple: + """Normalise various padding representations to ``(left, top, right, bottom)``.""" + if padding is None: + return (0, 0, 0, 0) + if isinstance(padding, (int, float)): + v = int(padding) + return (v, v, v, v) + if isinstance(padding, dict): + h = int(padding.get("horizontal", 0)) + v = int(padding.get("vertical", 0)) + left = int(padding.get("left", h)) + right = int(padding.get("right", h)) + top = int(padding.get("top", v)) + bottom = int(padding.get("bottom", v)) + a = int(padding.get("all", 0)) + if a: + left = left or a + right = right or a + top = top or a + bottom = bottom or a + return (left, top, right, bottom) + return (0, 0, 0, 0) + + +# ====================================================================== +# Platform handler registration (lazy imports inside functions) +# ====================================================================== + + +def _register_android_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 + from java import dynamic_proxy, jclass + + from .utils import get_android_context + + def _ctx() -> Any: + return get_android_context() + + def _density() -> float: + return float(_ctx().getResources().getDisplayMetrics().density) + + def _dp(value: float) -> int: + return int(value * _density()) + + # ---- Text ----------------------------------------------------------- + class AndroidTextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tv = jclass("android.widget.TextView")(_ctx()) + self._apply(tv, props) + return tv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, tv: Any, props: Dict[str, Any]) -> None: + if "text" in props: + tv.setText(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + tv.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + tv.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tv.setBackgroundColor(parse_color_int(props["background_color"])) + if "bold" in props and props["bold"]: + tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 + if "max_lines" in props and props["max_lines"] is not None: + tv.setMaxLines(int(props["max_lines"])) + if "text_align" in props: + Gravity = jclass("android.view.Gravity") + mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} + tv.setGravity(mapping.get(props["text_align"], Gravity.START)) + + # ---- Button --------------------------------------------------------- + class AndroidButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = jclass("android.widget.Button")(_ctx()) + self._apply(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setText(str(props["title"])) + if "font_size" in props and props["font_size"] is not None: + btn.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + btn.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor(parse_color_int(props["background_color"])) + if "enabled" in props: + btn.setEnabled(bool(props["enabled"])) + if "on_click" in props: + cb = props["on_click"] + if cb is not None: + + class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(ClickProxy(cb)) + else: + btn.setOnClickListener(None) + + # ---- Column (vertical LinearLayout) --------------------------------- + class AndroidColumnHandler(ViewHandler): + 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) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, ll: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + px = _dp(float(props["spacing"])) + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + d = GradientDrawable() + d.setColor(0x00000000) + d.setSize(1, px) + ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) + ll.setDividerDrawable(d) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + if "alignment" in props and props["alignment"]: + Gravity = jclass("android.view.Gravity") + mapping = { + "fill": Gravity.FILL_HORIZONTAL, + "center": Gravity.CENTER_HORIZONTAL, + "leading": Gravity.START, + "start": Gravity.START, + "trailing": Gravity.END, + "end": Gravity.END, + } + ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_HORIZONTAL)) + if "background_color" in props and props["background_color"] is not None: + ll.setBackgroundColor(parse_color_int(props["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) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + # ---- Row (horizontal LinearLayout) ---------------------------------- + class AndroidRowHandler(ViewHandler): + 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) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, ll: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + px = _dp(float(props["spacing"])) + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + d = GradientDrawable() + d.setColor(0x00000000) + d.setSize(px, 1) + ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) + ll.setDividerDrawable(d) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + if "alignment" in props and props["alignment"]: + Gravity = jclass("android.view.Gravity") + mapping = { + "fill": Gravity.FILL_VERTICAL, + "center": Gravity.CENTER_VERTICAL, + "top": Gravity.TOP, + "bottom": Gravity.BOTTOM, + } + ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_VERTICAL)) + if "background_color" in props and props["background_color"] is not None: + ll.setBackgroundColor(parse_color_int(props["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) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + # ---- ScrollView ----------------------------------------------------- + class AndroidScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = jclass("android.widget.ScrollView")(_ctx()) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor(parse_color_int(props["background_color"])) + 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"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + # ---- TextInput (EditText) ------------------------------------------- + class AndroidTextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + et = jclass("android.widget.EditText")(_ctx()) + self._apply(et, props) + return et + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, et: Any, props: Dict[str, Any]) -> None: + if "value" in props: + et.setText(str(props["value"])) + if "placeholder" in props: + et.setHint(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + et.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + et.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + et.setBackgroundColor(parse_color_int(props["background_color"])) + if "secure" in props and props["secure"]: + InputType = jclass("android.text.InputType") + et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + + # ---- Image ---------------------------------------------------------- + class AndroidImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = jclass("android.widget.ImageView")(_ctx()) + self._apply(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(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 --------------------------------------------------------- + class AndroidSwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = jclass("android.widget.Switch")(_ctx()) + self._apply(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setChecked(bool(props["value"])) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): + def __init__(self, callback: Callable[[bool], None]) -> None: + super().__init__() + self.callback = callback + + def onCheckedChanged(self, button: Any, checked: bool) -> None: + self.callback(checked) + + sw.setOnCheckedChangeListener(CheckedProxy(cb)) + + # ---- ProgressBar ---------------------------------------------------- + class AndroidProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + style = jclass("android.R$attr").progressBarStyleHorizontal + pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) + pb.setMax(1000) + self._apply(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, pb: Any, props: Dict[str, Any]) -> None: + if "value" in props: + pb.setProgress(int(float(props["value"]) * 1000)) + + # ---- ActivityIndicator (circular ProgressBar) ----------------------- + class AndroidActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pb = jclass("android.widget.ProgressBar")(_ctx()) + if not props.get("animating", True): + pb.setVisibility(jclass("android.view.View").GONE) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + View = jclass("android.view.View") + if "animating" in changed: + native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) + + # ---- WebView -------------------------------------------------------- + class AndroidWebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = jclass("android.webkit.WebView")(_ctx()) + if "url" in props and props["url"]: + wv.loadUrl(str(props["url"])) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + native_view.loadUrl(str(changed["url"])) + + # ---- Spacer --------------------------------------------------------- + class AndroidSpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = jclass("android.view.View")(_ctx()) + if "size" in props and props["size"] is not None: + px = _dp(float(props["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + v.setLayoutParams(lp) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + px = _dp(float(changed["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + native_view.setLayoutParams(lp) + + registry.register("Text", AndroidTextHandler()) + registry.register("Button", AndroidButtonHandler()) + registry.register("Column", AndroidColumnHandler()) + registry.register("Row", AndroidRowHandler()) + registry.register("ScrollView", AndroidScrollViewHandler()) + registry.register("TextInput", AndroidTextInputHandler()) + registry.register("Image", AndroidImageHandler()) + registry.register("Switch", AndroidSwitchHandler()) + registry.register("ProgressBar", AndroidProgressBarHandler()) + registry.register("ActivityIndicator", AndroidActivityIndicatorHandler()) + registry.register("WebView", AndroidWebViewHandler()) + registry.register("Spacer", AndroidSpacerHandler()) + + +def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 + from rubicon.objc import SEL, ObjCClass, objc_method + + NSObject = ObjCClass("NSObject") + UIColor = ObjCClass("UIColor") + UIFont = ObjCClass("UIFont") + + def _uicolor(color: Any) -> Any: + argb = parse_color_int(color) + if argb < 0: + argb += 0x100000000 + a = ((argb >> 24) & 0xFF) / 255.0 + r = ((argb >> 16) & 0xFF) / 255.0 + g = ((argb >> 8) & 0xFF) / 255.0 + b = (argb & 0xFF) / 255.0 + return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + + # ---- Text ----------------------------------------------------------- + class IOSTextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + label = ObjCClass("UILabel").alloc().init() + self._apply(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + if "text" in props: + label.setText_(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + if props.get("bold"): + label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) + else: + label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + elif "bold" in props and props["bold"]: + size = label.font().pointSize() if label.font() else 17.0 + label.setFont_(UIFont.boldSystemFontOfSize_(size)) + if "color" in props and props["color"] is not None: + label.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + label.setBackgroundColor_(_uicolor(props["background_color"])) + if "max_lines" in props and props["max_lines"] is not None: + label.setNumberOfLines_(int(props["max_lines"])) + if "text_align" in props: + mapping = {"left": 0, "center": 1, "right": 2} + label.setTextAlignment_(mapping.get(props["text_align"], 0)) + + # ---- 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] + _callback: Optional[Callable[[], None]] = None + + @objc_method + 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): + def create(self, props: Dict[str, Any]) -> Any: + btn = ObjCClass("UIButton").alloc().init() + btn.retain() + _pn_retained_views.append(btn) + _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) + btn.setTitleColor_forState_(_ios_blue, 0) + self._apply(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setTitle_forState_(str(props["title"]), 0) + if "font_size" in props and props["font_size"] is not None: + btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor_(_uicolor(props["background_color"])) + if "color" not in props: + _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) + btn.setTitleColor_forState_(_white, 0) + if "color" in props and props["color"] is not None: + btn.setTitleColor_forState_(_uicolor(props["color"]), 0) + if "enabled" in props: + btn.setEnabled_(bool(props["enabled"])) + if "on_click" in props: + existing = _pn_btn_handler_map.get(id(btn)) + if existing is not None: + existing._callback = props["on_click"] + else: + handler = _PNButtonTarget.new() + handler._callback = props["on_click"] + _pn_btn_handler_map[id(btn)] = handler + btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) + + # ---- Column (vertical UIStackView) ---------------------------------- + class IOSColumnHandler(ViewHandler): + 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) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sv: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + sv.setSpacing_(float(props["spacing"])) + if "alignment" in props and props["alignment"]: + mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4} + sv.setAlignment_(mapping.get(props["alignment"], 0)) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + sv.setLayoutMarginsRelativeArrangement_(True) + try: + sv.setDirectionalLayoutMargins_((top, left, bottom, right)) + except Exception: + sv.setLayoutMargins_((top, left, bottom, right)) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + # ---- Row (horizontal UIStackView) ----------------------------------- + class IOSRowHandler(ViewHandler): + 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) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sv: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + sv.setSpacing_(float(props["spacing"])) + if "alignment" in props and props["alignment"]: + mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4} + sv.setAlignment_(mapping.get(props["alignment"], 0)) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + # ---- ScrollView ----------------------------------------------------- + class IOSScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIScrollView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + child.setTranslatesAutoresizingMaskIntoConstraints_(False) + parent.addSubview_(child) + content_guide = parent.contentLayoutGuide + frame_guide = parent.frameLayoutGuide + child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) + child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) + child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) + child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) + child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + # ---- TextInput (UITextField) ---------------------------------------- + class IOSTextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tf = ObjCClass("UITextField").alloc().init() + tf.setBorderStyle_(2) # RoundedRect + self._apply(tf, props) + return tf + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, tf: Any, props: Dict[str, Any]) -> None: + if "value" in props: + tf.setText_(str(props["value"])) + if "placeholder" in props: + tf.setPlaceholder_(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "color" in props and props["color"] is not None: + tf.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tf.setBackgroundColor_(_uicolor(props["background_color"])) + if "secure" in props and props["secure"]: + tf.setSecureTextEntry_(True) + + # ---- Image ---------------------------------------------------------- + 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"])) + 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"])) + + # ---- Switch --------------------------------------------------------- + class IOSSwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = ObjCClass("UISwitch").alloc().init() + self._apply(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setOn_animated_(bool(props["value"]), False) + + # ---- ProgressBar (UIProgressView) ----------------------------------- + class IOSProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pv = ObjCClass("UIProgressView").alloc().init() + if "value" in props: + pv.setProgress_(float(props["value"])) + return pv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "value" in changed: + native_view.setProgress_(float(changed["value"])) + + # ---- ActivityIndicator ---------------------------------------------- + class IOSActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ai = ObjCClass("UIActivityIndicatorView").alloc().init() + if props.get("animating", True): + ai.startAnimating() + return ai + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "animating" in changed: + if changed["animating"]: + native_view.startAnimating() + else: + native_view.stopAnimating() + + # ---- WebView (WKWebView) -------------------------------------------- + class IOSWebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = ObjCClass("WKWebView").alloc().init() + if "url" in props and props["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(props["url"])) + wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(changed["url"])) + native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + + # ---- Spacer --------------------------------------------------------- + class IOSSpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "size" in props and props["size"] is not None: + size = float(props["size"]) + v.setFrame_(((0, 0), (size, size))) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + size = float(changed["size"]) + native_view.setFrame_(((0, 0), (size, size))) + + registry.register("Text", IOSTextHandler()) + registry.register("Button", IOSButtonHandler()) + registry.register("Column", IOSColumnHandler()) + registry.register("Row", IOSRowHandler()) + registry.register("ScrollView", IOSScrollViewHandler()) + registry.register("TextInput", IOSTextInputHandler()) + registry.register("Image", IOSImageHandler()) + registry.register("Switch", IOSSwitchHandler()) + registry.register("ProgressBar", IOSProgressBarHandler()) + registry.register("ActivityIndicator", IOSActivityIndicatorHandler()) + registry.register("WebView", IOSWebViewHandler()) + registry.register("Spacer", IOSSpacerHandler()) + + +# ====================================================================== +# Factory +# ====================================================================== + +_registry: Optional[NativeViewRegistry] = None + + +def get_registry() -> NativeViewRegistry: + """Return the singleton registry, lazily creating platform handlers.""" + global _registry + if _registry is not None: + return _registry + _registry = NativeViewRegistry() + if IS_ANDROID: + _register_android_handlers(_registry) + else: + _register_ios_handlers(_registry) + return _registry + + +def set_registry(registry: NativeViewRegistry) -> None: + """Inject a custom or mock registry (primarily for testing).""" + global _registry + _registry = registry diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index 77e283c..421bed4 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -1,32 +1,29 @@ -""" -Your current approach, which involves creating an Android Activity in Kotlin -and then passing it to Python, is necessary due to the restrictions inherent -in Android's lifecycle. You are correctly following the Android way of managing -Activities. In Android, the system is in control of when and how Activities are -created and destroyed. It is not possible to directly create an instance of an -Activity from Python because that would bypass Android's lifecycle management, -leading to unpredictable results. - -Your Button example works because Button is a View, not an Activity. View -instances in Android can be created and managed directly by your code. This is -why you are able to create an instance of Button from Python. - -Remember that Activities in Android are not just containers for your UI like a -ViewGroup, they are also the main entry points into your app and are closely -tied to the app's lifecycle. Therefore, Android needs to maintain tight control -over them. Activities aren't something you instantiate whenever you need them; -they are created in response to a specific intent and their lifecycle is -managed by Android. - -So, to answer your question: Yes, you need to follow this approach for -Activities in Android. You cannot instantiate an Activity from Python like you -do for Views. - -On the other hand, for iOS, you can instantiate a UIViewController directly -from Python. The example code you provided for this is correct. - -Just ensure that your PythonNative UI framework is aware of these platform -differences and handles them appropriately. +"""Page — the root component that bridges native lifecycle and declarative UI. + +A ``Page`` subclass is the entry point for each screen. It owns a +:class:`~pythonnative.reconciler.Reconciler` and automatically mounts / +re-renders the element tree returned by :meth:`render` whenever state +changes. + +Usage:: + + import pythonnative as pn + + class MainPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + self.state = {"count": 0} + + def increment(self): + self.set_state(count=self.state["count"] + 1) + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button("Increment", on_click=self.increment), + spacing=12, + padding=16, + ) """ import json @@ -34,55 +31,50 @@ from typing import Any, Optional, Union from .utils import IS_ANDROID, set_android_context -from .view import ViewBase -# ======================================== -# Base class -# ======================================== +# ====================================================================== +# Base class (platform-independent) +# ====================================================================== class PageBase(ABC): + """Abstract base defining the Page interface.""" + @abstractmethod def __init__(self) -> None: super().__init__() @abstractmethod - def set_root_view(self, view: Any) -> None: - pass + def render(self) -> Any: + """Return an Element tree describing this page's UI.""" + + def set_state(self, **updates: Any) -> None: + """Merge *updates* into ``self.state`` and trigger a re-render.""" - @abstractmethod def on_create(self) -> None: - pass + """Called when the page is first created. Triggers initial render.""" - @abstractmethod def on_start(self) -> None: pass - @abstractmethod def on_resume(self) -> None: pass - @abstractmethod def on_pause(self) -> None: pass - @abstractmethod def on_stop(self) -> None: pass - @abstractmethod def on_destroy(self) -> None: pass - @abstractmethod def on_restart(self) -> None: pass - @abstractmethod def on_save_instance_state(self) -> None: pass - @abstractmethod def on_restore_instance_state(self) -> None: pass @@ -99,141 +91,182 @@ def pop(self) -> None: pass def get_args(self) -> dict: - """Return arguments provided to this Page (empty dict if none).""" - # Concrete classes should set self._args; default empty + """Return navigation arguments (empty dict if none).""" return getattr(self, "_args", {}) - # Back-compat: navigate_to delegates to push def navigate_to(self, page: Any) -> None: self.push(page) - pass -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/app/Activity - # ======================================== +# ====================================================================== +# Shared declarative rendering helpers +# ====================================================================== + + +def _init_page_common(page: Any) -> None: + """Common initialisation shared by both platform Page classes.""" + page.state = {} + page._args = {} + page._reconciler = None + page._root_native_view = None + + +def _set_state(page: Any, **updates: Any) -> None: + page.state.update(updates) + if page._reconciler is not None: + _re_render(page) + + +def _on_create(page: Any) -> None: + from .native_views import get_registry + from .reconciler import Reconciler + + page._reconciler = Reconciler(get_registry()) + element = page.render() + page._root_native_view = page._reconciler.mount(element) + page._attach_root(page._root_native_view) + + +def _re_render(page: Any) -> None: + element = page.render() + new_root = page._reconciler.reconcile(element) + if new_root is not page._root_native_view: + page._detach_root(page._root_native_view) + page._root_native_view = new_root + page._attach_root(new_root) + +def _resolve_page_path(page_ref: Union[str, Any]) -> str: + if isinstance(page_ref, str): + return page_ref + module = getattr(page_ref, "__module__", None) + name = getattr(page_ref, "__name__", None) + if module and name: + return f"{module}.{name}" + cls = page_ref.__class__ + return f"{cls.__module__}.{cls.__name__}" + + +def _set_args(page: Any, args: Optional[dict]) -> None: + if isinstance(args, str): + try: + page._args = json.loads(args) or {} + except Exception: + page._args = {} + return + page._args = args or {} + + +# ====================================================================== +# Platform implementations +# ====================================================================== + +if IS_ANDROID: from java import jclass - class Page(PageBase, ViewBase): + class Page(PageBase): + """Android Page backed by an Activity and Fragment navigation.""" + def __init__(self, native_instance: Any) -> None: super().__init__() self.native_class = jclass("android.app.Activity") self.native_instance = native_instance - # self.native_instance = self.native_class() - # Stash the Activity so child views can implicitly acquire a Context set_android_context(native_instance) - self._args: dict = {} + _init_page_common(self) - def set_root_view(self, view: Any) -> None: - # In fragment-based navigation, attach child view to the current fragment container. - try: - from .utils import get_android_fragment_container + def render(self) -> Any: + raise NotImplementedError("Page subclass must implement render()") - container = get_android_fragment_container() - # Remove previous children if any, then add the new root - try: - container.removeAllViews() - except Exception: - pass - container.addView(view.native_instance) - except Exception: - # Fallback to setting content view directly on the Activity - self.native_instance.setContentView(view.native_instance) + def set_state(self, **updates: Any) -> None: + _set_state(self, **updates) def on_create(self) -> None: - print("Android on_create() called") + _on_create(self) def on_start(self) -> None: - print("Android on_start() called") + pass def on_resume(self) -> None: - print("Android on_resume() called") + pass def on_pause(self) -> None: - print("Android on_pause() called") + pass def on_stop(self) -> None: - print("Android on_stop() called") + pass def on_destroy(self) -> None: - print("Android on_destroy() called") + pass def on_restart(self) -> None: - print("Android on_restart() called") + pass def on_save_instance_state(self) -> None: - print("Android on_save_instance_state() called") + pass def on_restore_instance_state(self) -> None: - print("Android on_restore_instance_state() called") + pass def set_args(self, args: Optional[dict]) -> None: - # Accept dict or JSON string for convenience when crossing language boundaries - if isinstance(args, str): - try: - self._args = json.loads(args) or {} - return - except Exception: - self._args = {} - return - self._args = args or {} - - def _resolve_page_path(self, page: Union[str, Any]) -> str: - if isinstance(page, str): - return page - # If a class or instance is passed, derive dotted path - try: - module = getattr(page, "__module__", None) - name = getattr(page, "__name__", None) - if module and name: - return f"{module}.{name}" - # Instance: use its class - cls = page.__class__ - return f"{cls.__module__}.{cls.__name__}" - except Exception: - raise ValueError("Unsupported page reference; expected dotted string or class/instance") + _set_args(self, args) def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - # Delegate to Navigator.push to navigate to PageFragment with arguments - page_path = self._resolve_page_path(page) - try: - Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") - args_json = json.dumps(args) if args else None - Navigator.push(self.native_instance, page_path, args_json) - except Exception: - # As a last resort, do nothing rather than crash - pass + page_path = _resolve_page_path(page) + Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") + args_json = json.dumps(args) if args else None + Navigator.push(self.native_instance, page_path, args_json) def pop(self) -> None: - # Delegate to Navigator.pop for back-stack pop try: Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") Navigator.pop(self.native_instance) except Exception: + self.native_instance.finish() + + def _attach_root(self, native_view: Any) -> None: + try: + from .utils import get_android_fragment_container + + container = get_android_fragment_container() try: - self.native_instance.finish() + container.removeAllViews() except Exception: pass + LayoutParams = jclass("android.view.ViewGroup$LayoutParams") + lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + container.addView(native_view, lp) + except Exception: + self.native_instance.setContentView(native_view) + + def _detach_root(self, native_view: Any) -> None: + try: + from .utils import get_android_fragment_container + + container = get_android_fragment_container() + container.removeAllViews() + except Exception: + pass else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiviewcontroller - # ======================================== + from typing import Dict as _Dict + + _rubicon_available = False + try: + from rubicon.objc import ObjCClass, ObjCInstance - from typing import Dict + _rubicon_available = True - from rubicon.objc import ObjCClass, ObjCInstance + import gc as _gc - # Global registry mapping native UIViewController pointer address to Page instances. - _IOS_PAGE_REGISTRY: Dict[int, Any] = {} + _gc.disable() + except ImportError: + pass + + _IOS_PAGE_REGISTRY: _Dict[int, Any] = {} def _ios_register_page(vc_instance: Any, page_obj: Any) -> None: try: - ptr = int(vc_instance.ptr) # rubicon ObjCInstance -> c_void_p convertible to int + ptr = int(vc_instance.ptr) _IOS_PAGE_REGISTRY[ptr] = page_obj except Exception: pass @@ -246,151 +279,190 @@ def _ios_unregister_page(vc_instance: Any) -> None: pass def forward_lifecycle(native_addr: int, event: str) -> None: - """Forward a lifecycle event from Swift ViewController to the registered Page. - - :param native_addr: Integer pointer address of the UIViewController - :param event: One of 'on_start', 'on_resume', 'on_pause', 'on_stop', 'on_destroy', - 'on_save_instance_state', 'on_restore_instance_state'. - """ + """Forward a lifecycle event from Swift ViewController to the registered Page.""" page = _IOS_PAGE_REGISTRY.get(int(native_addr)) - if not page: + if page is None: return - try: - handler = getattr(page, event, None) - if handler: - handler() - except Exception: - # Avoid surfacing exceptions across the Swift/Python boundary in lifecycle - pass + handler = getattr(page, event, None) + if handler: + handler() - class Page(PageBase, ViewBase): - def __init__(self, native_instance: Any) -> None: - super().__init__() - self.native_class = ObjCClass("UIViewController") - # If Swift passed us an integer pointer, wrap it as an ObjCInstance. - if isinstance(native_instance, int): - try: - native_instance = ObjCInstance(native_instance) - except Exception: - native_instance = None - self.native_instance = native_instance - # self.native_instance = self.native_class.alloc().init() - self._args: dict = {} - # Register for lifecycle forwarding - if self.native_instance is not None: - _ios_register_page(self.native_instance, self) - - def set_root_view(self, view: Any) -> None: - # UIViewController.view is a property; access without calling. - root_view = self.native_instance.view - # Size the root child to fill the controller's view and enable autoresizing - try: - bounds = root_view.bounds - view.native_instance.setFrame_(bounds) - # UIViewAutoresizingFlexibleWidth (2) | UIViewAutoresizingFlexibleHeight (16) - view.native_instance.setAutoresizingMask_(2 | 16) - except Exception: - pass - root_view.addSubview_(view.native_instance) + if _rubicon_available: - def on_create(self) -> None: - print("iOS on_create() called") + class Page(PageBase): + """iOS Page backed by a UIViewController.""" - def on_start(self) -> None: - print("iOS on_start() called") + def __init__(self, native_instance: Any) -> None: + super().__init__() + self.native_class = ObjCClass("UIViewController") + if isinstance(native_instance, int): + try: + native_instance = ObjCInstance(native_instance) + except Exception: + native_instance = None + self.native_instance = native_instance + _init_page_common(self) + if self.native_instance is not None: + _ios_register_page(self.native_instance, self) - def on_resume(self) -> None: - print("iOS on_resume() called") + def render(self) -> Any: + raise NotImplementedError("Page subclass must implement render()") - def on_pause(self) -> None: - print("iOS on_pause() called") + def set_state(self, **updates: Any) -> None: + _set_state(self, **updates) - def on_stop(self) -> None: - print("iOS on_stop() called") + def on_create(self) -> None: + _on_create(self) - def on_destroy(self) -> None: - print("iOS on_destroy() called") - if self.native_instance is not None: - _ios_unregister_page(self.native_instance) + def on_start(self) -> None: + pass - def on_restart(self) -> None: - print("iOS on_restart() called") + def on_resume(self) -> None: + pass - def on_save_instance_state(self) -> None: - print("iOS on_save_instance_state() called") + def on_pause(self) -> None: + pass - def on_restore_instance_state(self) -> None: - print("iOS on_restore_instance_state() called") + def on_stop(self) -> None: + pass - def set_args(self, args: Optional[dict]) -> None: - if isinstance(args, str): - try: - self._args = json.loads(args) or {} - return - except Exception: - self._args = {} - return - self._args = args or {} + def on_destroy(self) -> None: + if self.native_instance is not None: + _ios_unregister_page(self.native_instance) - def _resolve_page_path(self, page: Union[str, Any]) -> str: - if isinstance(page, str): - return page - try: - module = getattr(page, "__module__", None) - name = getattr(page, "__name__", None) - if module and name: - return f"{module}.{name}" - cls = page.__class__ - return f"{cls.__module__}.{cls.__name__}" - except Exception: - raise ValueError("Unsupported page reference; expected dotted string or class/instance") + def on_restart(self) -> None: + pass - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - page_path = self._resolve_page_path(page) - # Resolve the Swift ViewController class. Swift classes are namespaced by - # the module name (CFBundleName). Try plain name first, then Module.Name. - ViewController = None - try: - ViewController = ObjCClass("ViewController") - except Exception: + def on_save_instance_state(self) -> None: + pass + + def on_restore_instance_state(self) -> None: + pass + + def set_args(self, args: Optional[dict]) -> None: + _set_args(self, args) + + def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: + page_path = _resolve_page_path(page) + ViewController = None try: - NSBundle = ObjCClass("NSBundle") - bundle = NSBundle.mainBundle - module_name = None + ViewController = ObjCClass("ViewController") + except Exception: try: - # Prefer CFBundleName; fallback to CFBundleExecutable + NSBundle = ObjCClass("NSBundle") + bundle = NSBundle.mainBundle module_name = bundle.objectForInfoDictionaryKey_("CFBundleName") if module_name is None: module_name = bundle.objectForInfoDictionaryKey_("CFBundleExecutable") + if module_name: + ViewController = ObjCClass(f"{module_name}.ViewController") except Exception: - module_name = None - if module_name: - ViewController = ObjCClass(f"{module_name}.ViewController") + pass + + if ViewController is None: + raise NameError("ViewController class not found; ensure Swift class is ObjC-visible") + + next_vc = ViewController.alloc().init() + try: + next_vc.setValue_forKey_(page_path, "requestedPagePath") + if args: + next_vc.setValue_forKey_(json.dumps(args), "requestedPageArgsJSON") + except Exception: + pass + nav = getattr(self.native_instance, "navigationController", None) + if nav is None: + raise RuntimeError( + "No UINavigationController available; ensure template embeds root in navigation controller" + ) + nav.pushViewController_animated_(next_vc, True) + + def pop(self) -> None: + nav = getattr(self.native_instance, "navigationController", None) + if nav is not None: + nav.popViewControllerAnimated_(True) + + def _attach_root(self, native_view: Any) -> None: + root_view = self.native_instance.view + native_view.setTranslatesAutoresizingMaskIntoConstraints_(False) + root_view.addSubview_(native_view) + try: + safe = root_view.safeAreaLayoutGuide + native_view.topAnchor.constraintEqualToAnchor_(safe.topAnchor).setActive_(True) + native_view.bottomAnchor.constraintEqualToAnchor_(safe.bottomAnchor).setActive_(True) + native_view.leadingAnchor.constraintEqualToAnchor_(safe.leadingAnchor).setActive_(True) + native_view.trailingAnchor.constraintEqualToAnchor_(safe.trailingAnchor).setActive_(True) except Exception: - ViewController = None + native_view.setTranslatesAutoresizingMaskIntoConstraints_(True) + try: + native_view.setFrame_(root_view.bounds) + native_view.setAutoresizingMask_(2 | 16) + except Exception: + pass - if ViewController is None: - raise NameError("ViewController class not found; ensure Swift class is ObjC-visible") + def _detach_root(self, native_view: Any) -> None: + try: + native_view.removeFromSuperview() + except Exception: + pass - next_vc = ViewController.alloc().init() - try: - # Use KVC to pass metadata to Swift - next_vc.setValue_forKey_(page_path, "requestedPagePath") - if args: - next_vc.setValue_forKey_(json.dumps(args), "requestedPageArgsJSON") - except Exception: + else: + + class Page(PageBase): + """Desktop stub — no native runtime available. + + Fully functional for testing with a mock backend via + ``native_views.set_registry()``. + """ + + def __init__(self, native_instance: Any = None) -> None: + super().__init__() + self.native_instance = native_instance + _init_page_common(self) + + def render(self) -> Any: + raise NotImplementedError("Page subclass must implement render()") + + def set_state(self, **updates: Any) -> None: + _set_state(self, **updates) + + def on_create(self) -> None: + _on_create(self) + + def on_start(self) -> None: pass - # On iOS, `navigationController` is exposed as a property; treat it as such. - nav = getattr(self.native_instance, "navigationController", None) - if nav is None: - # If no navigation controller, this push will be a no-op; rely on template to embed one. - raise RuntimeError( - "No UINavigationController available; ensure template embeds root in navigation controller" - ) - # Method name maps from pushViewController:animated: - nav.pushViewController_animated_(next_vc, True) - def pop(self) -> None: - nav = getattr(self.native_instance, "navigationController", None) - if nav is not None: - nav.popViewControllerAnimated_(True) + def on_resume(self) -> None: + pass + + def on_pause(self) -> None: + pass + + def on_stop(self) -> None: + pass + + def on_destroy(self) -> None: + pass + + def on_restart(self) -> None: + pass + + def on_save_instance_state(self) -> None: + pass + + def on_restore_instance_state(self) -> None: + pass + + def set_args(self, args: Optional[dict]) -> None: + _set_args(self, args) + + def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: + raise RuntimeError("push() requires a native runtime (iOS or Android)") + + def pop(self) -> None: + raise RuntimeError("pop() requires a native runtime (iOS or Android)") + + def _attach_root(self, native_view: Any) -> None: + pass + + def _detach_root(self, native_view: Any) -> None: + pass diff --git a/src/pythonnative/picker_view.py b/src/pythonnative/picker_view.py deleted file mode 100644 index 7f1ae15..0000000 --- a/src/pythonnative/picker_view.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class PickerViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_selected(self, index: int) -> "PickerViewBase": - pass - - @abstractmethod - def get_selected(self) -> int: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Spinner - # ======================================== - - from typing import Any - - from java import jclass - - class PickerView(PickerViewBase, ViewBase): - def __init__(self, context: Any, index: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.Spinner") - self.native_instance = self.native_class(context) - self.set_selected(index) - - def set_selected(self, index: int) -> "PickerView": - self.native_instance.setSelection(index) - return self - - def get_selected(self) -> int: - return self.native_instance.getSelectedItemPosition() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uipickerview - # ======================================== - - from rubicon.objc import ObjCClass - - class PickerView(PickerViewBase, ViewBase): - def __init__(self, index: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIPickerView") - self.native_instance = self.native_class.alloc().init() - self.set_selected(index) - - def set_selected(self, index: int) -> "PickerView": - self.native_instance.selectRow_inComponent_animated_(index, 0, False) - return self - - def get_selected(self) -> int: - return self.native_instance.selectedRowInComponent_(0) diff --git a/src/pythonnative/progress_view.py b/src/pythonnative/progress_view.py deleted file mode 100644 index 5587170..0000000 --- a/src/pythonnative/progress_view.py +++ /dev/null @@ -1,70 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ProgressViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_progress(self, progress: float) -> "ProgressViewBase": - pass - - @abstractmethod - def get_progress(self) -> float: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ProgressBar - # ======================================== - - from java import jclass - - class ProgressView(ProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ProgressBar") - # self.native_instance = self.native_class(context, None, android.R.attr.progressBarStyleHorizontal) - context = get_android_context() - self.native_instance = self.native_class(context, None, jclass("android.R$attr").progressBarStyleHorizontal) - self.native_instance.setIndeterminate(False) - - def set_progress(self, progress: float) -> "ProgressView": - self.native_instance.setProgress(int(progress * 100)) - return self - - def get_progress(self) -> float: - return self.native_instance.getProgress() / 100.0 - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiprogressview - # ======================================== - - from rubicon.objc import ObjCClass - - class ProgressView(ProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIProgressView") - self.native_instance = self.native_class.alloc().initWithProgressViewStyle_( - 0 - ) # 0: UIProgressViewStyleDefault - - def set_progress(self, progress: float) -> "ProgressView": - self.native_instance.setProgress_animated_(progress, False) - return self - - def get_progress(self) -> float: - return self.native_instance.progress() diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py new file mode 100644 index 0000000..b7aa5bc --- /dev/null +++ b/src/pythonnative/reconciler.py @@ -0,0 +1,129 @@ +"""Virtual-tree reconciler. + +Maintains a tree of :class:`VNode` objects (each wrapping a native view) +and diffs incoming :class:`Element` trees to apply the minimal set of +native mutations. +""" + +from typing import Any, List, Optional + +from .element import Element + + +class VNode: + """A mounted element paired with its native view and child VNodes.""" + + __slots__ = ("element", "native_view", "children") + + def __init__(self, element: Element, native_view: Any, children: List["VNode"]) -> None: + self.element = element + self.native_view = native_view + self.children = children + + +class Reconciler: + """Create, diff, and patch native view trees from Element descriptors. + + Parameters + ---------- + backend: + An object implementing the :class:`NativeViewRegistry` protocol + (``create_view``, ``update_view``, ``add_child``, ``remove_child``, + ``insert_child``). + """ + + def __init__(self, backend: Any) -> None: + self.backend = backend + self._tree: Optional[VNode] = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def mount(self, element: Element) -> Any: + """Build native views from *element* and return the root native view.""" + self._tree = self._create_tree(element) + return self._tree.native_view + + def reconcile(self, new_element: Element) -> Any: + """Diff *new_element* against the current tree and patch native views. + + Returns the (possibly replaced) root native view. + """ + if self._tree is None: + self._tree = self._create_tree(new_element) + return self._tree.native_view + + self._tree = self._reconcile_node(self._tree, new_element) + return self._tree.native_view + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _create_tree(self, element: Element) -> VNode: + native_view = self.backend.create_view(element.type, element.props) + children: List[VNode] = [] + for child_el in element.children: + child_node = self._create_tree(child_el) + self.backend.add_child(native_view, child_node.native_view, element.type) + children.append(child_node) + return VNode(element, native_view, children) + + def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: + if old.element.type != new_el.type: + new_node = self._create_tree(new_el) + self._destroy_tree(old) + return new_node + + changed = self._diff_props(old.element.props, new_el.props) + if changed: + self.backend.update_view(old.native_view, old.element.type, changed) + + self._reconcile_children(old, new_el.children) + old.element = new_el + return old + + def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None: + old_children = parent.children + 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) + 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) + + parent.children = new_child_nodes + + def _destroy_tree(self, node: VNode) -> None: + for child in node.children: + self._destroy_tree(child) + node.children = [] + + @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(): + old_val = old.get(key) + if callable(new_val) or old_val != new_val: + changed[key] = new_val + for key in old: + if key not in new: + changed[key] = None + return changed diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py deleted file mode 100644 index b532cfe..0000000 --- a/src/pythonnative/scroll_view.py +++ /dev/null @@ -1,101 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, List - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ScrollViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - self.views: List[Any] = [] - - @abstractmethod - def add_view(self, view: Any) -> None: - pass - - @staticmethod - @abstractmethod - def wrap(view: Any) -> "ScrollViewBase": - """Return a new ScrollView containing the provided view as its only child.""" - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ScrollView - # ======================================== - - from java import jclass - - class ScrollView(ScrollViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ScrollView") - context = get_android_context() - self.native_instance = self.native_class(context) - - def add_view(self, view: Any) -> None: - self.views.append(view) - # In Android, ScrollView can host only one direct child - if len(self.views) == 1: - self.native_instance.addView(view.native_instance) - else: - raise Exception("ScrollView can host only one direct child") - - @staticmethod - def wrap(view: Any) -> "ScrollView": - """Return a new ScrollView containing the provided view as its only child.""" - sv = ScrollView() - sv.add_view(view) - return sv - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiscrollview - # ======================================== - - from rubicon.objc import ObjCClass - - class ScrollView(ScrollViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIScrollView") - self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - - def add_view(self, view: Any) -> None: - self.views.append(view) - # Add as subview and size child to fill scroll view by default so content is visible - try: - self.native_instance.addSubview_(view.native_instance) - except Exception: - pass - # Default layout: if the child has no size yet, size it to fill the scroll view - # and enable flexible width/height. If the child is already sized explicitly, - # leave it unchanged. - try: - frame = getattr(view.native_instance, "frame") - size = getattr(frame, "size", None) - width = getattr(size, "width", 0) if size is not None else 0 - height = getattr(size, "height", 0) if size is not None else 0 - if width <= 0 or height <= 0: - bounds = self.native_instance.bounds - view.native_instance.setFrame_(bounds) - # UIViewAutoresizingFlexibleWidth (2) | UIViewAutoresizingFlexibleHeight (16) - view.native_instance.setAutoresizingMask_(2 | 16) - except Exception: - pass - - @staticmethod - def wrap(view: Any) -> "ScrollView": - """Return a new ScrollView containing the provided view as its only child.""" - sv = ScrollView() - sv.add_view(view) - return sv diff --git a/src/pythonnative/search_bar.py b/src/pythonnative/search_bar.py deleted file mode 100644 index ae33da1..0000000 --- a/src/pythonnative/search_bar.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class SearchBarBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_query(self, query: str) -> "SearchBarBase": - pass - - @abstractmethod - def get_query(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/SearchView - # ======================================== - - from typing import Any - - from java import jclass - - class SearchBar(SearchBarBase, ViewBase): - def __init__(self, context: Any, query: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.SearchView") - self.native_instance = self.native_class(context) - self.set_query(query) - - def set_query(self, query: str) -> "SearchBar": - self.native_instance.setQuery(query, False) - return self - - def get_query(self) -> str: - return self.native_instance.getQuery().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uisearchbar - # ======================================== - - from rubicon.objc import ObjCClass - - class SearchBar(SearchBarBase, ViewBase): - def __init__(self, query: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UISearchBar") - self.native_instance = self.native_class.alloc().init() - self.set_query(query) - - def set_query(self, query: str) -> "SearchBar": - self.native_instance.set_text_(query) - return self - - def get_query(self) -> str: - return self.native_instance.text() diff --git a/src/pythonnative/stack_view.py b/src/pythonnative/stack_view.py deleted file mode 100644 index cb2273c..0000000 --- a/src/pythonnative/stack_view.py +++ /dev/null @@ -1,199 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, List - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class StackViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - self.views: List[Any] = [] - - @abstractmethod - def add_view(self, view: Any) -> None: - pass - - @abstractmethod - def set_axis(self, axis: str) -> "StackViewBase": - pass - - @abstractmethod - def set_spacing(self, spacing: float) -> "StackViewBase": - pass - - @abstractmethod - def set_alignment(self, alignment: str) -> "StackViewBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/LinearLayout - # ======================================== - - from java import jclass - - class StackView(StackViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.LinearLayout") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setOrientation(self.native_class.VERTICAL) - # Cache context and current orientation for spacing/alignment helpers - self._context = context - self._axis = "vertical" - - def add_view(self, view: Any) -> None: - self.views.append(view) - # Apply margins if the child has any recorded (supported for LinearLayout) - try: - lp = view.native_instance.getLayoutParams() - except Exception: - lp = None - if lp is None: - # Create default LayoutParams (WRAP_CONTENT) - layout_params = jclass("android.widget.LinearLayout$LayoutParams")(-2, -2) - else: - layout_params = lp - margin = getattr(view, "_pn_margin", None) - if margin is not None: - left, top, right, bottom = margin - # Convert dp to px - density = self._context.getResources().getDisplayMetrics().density - lpx = int(left * density) - tpx = int(top * density) - rpx = int(right * density) - bpx = int(bottom * density) - try: - layout_params.setMargins(lpx, tpx, rpx, bpx) - except Exception: - pass - try: - view.native_instance.setLayoutParams(layout_params) - except Exception: - pass - self.native_instance.addView(view.native_instance) - - def set_axis(self, axis: str) -> "StackView": - """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" - axis_l = (axis or "").lower() - if axis_l not in ("vertical", "horizontal"): - return self - orientation = self.native_class.VERTICAL if axis_l == "vertical" else self.native_class.HORIZONTAL - self.native_instance.setOrientation(orientation) - self._axis = axis_l - return self - - def set_spacing(self, spacing: float) -> "StackView": - """Set spacing between children in dp (Android: uses LinearLayout dividers). Returns self.""" - try: - density = self._context.getResources().getDisplayMetrics().density - px = max(0, int(spacing * density)) - # Use a transparent GradientDrawable with specified size as divider - GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") - drawable = GradientDrawable() - drawable.setColor(0x00000000) - if self._axis == "vertical": - drawable.setSize(1, px) - else: - drawable.setSize(px, 1) - self.native_instance.setShowDividers(self.native_class.SHOW_DIVIDER_MIDDLE) - self.native_instance.setDividerDrawable(drawable) - except Exception: - pass - return self - - def set_alignment(self, alignment: str) -> "StackView": - """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" - try: - Gravity = jclass("android.view.Gravity") - a = (alignment or "").lower() - if self._axis == "vertical": - # Cross-axis is horizontal - if a in ("fill",): - self.native_instance.setGravity(Gravity.FILL_HORIZONTAL) - elif a in ("center", "centre"): - self.native_instance.setGravity(Gravity.CENTER_HORIZONTAL) - elif a in ("leading", "start", "left"): - self.native_instance.setGravity(Gravity.START) - elif a in ("trailing", "end", "right"): - self.native_instance.setGravity(Gravity.END) - else: - # Cross-axis is vertical - if a in ("fill",): - self.native_instance.setGravity(Gravity.FILL_VERTICAL) - elif a in ("center", "centre"): - self.native_instance.setGravity(Gravity.CENTER_VERTICAL) - elif a in ("top",): - self.native_instance.setGravity(Gravity.TOP) - elif a in ("bottom",): - self.native_instance.setGravity(Gravity.BOTTOM) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uistackview - # ======================================== - - from rubicon.objc import ObjCClass - - class StackView(StackViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIStackView") - self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - # Default to vertical axis - self.native_instance.setAxis_(1) - - def add_view(self, view: Any) -> None: - self.views.append(view) - self.native_instance.addArrangedSubview_(view.native_instance) - - def set_axis(self, axis: str) -> "StackView": - """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" - axis_l = (axis or "").lower() - value = 1 if axis_l == "vertical" else 0 - try: - self.native_instance.setAxis_(value) - except Exception: - pass - return self - - def set_spacing(self, spacing: float) -> "StackView": - """Set spacing between arranged subviews. Returns self.""" - try: - self.native_instance.setSpacing_(float(spacing)) - except Exception: - pass - return self - - def set_alignment(self, alignment: str) -> "StackView": - """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" - a = (alignment or "").lower() - # UIStackViewAlignment: Fill=0, Leading/Top=1, Center=3, Trailing/Bottom=4 - mapping = { - "fill": 0, - "leading": 1, - "top": 1, - "center": 3, - "centre": 3, - "trailing": 4, - "bottom": 4, - } - value = mapping.get(a, 0) - try: - self.native_instance.setAlignment_(value) - except Exception: - pass - return self diff --git a/src/pythonnative/switch.py b/src/pythonnative/switch.py deleted file mode 100644 index 55d95ba..0000000 --- a/src/pythonnative/switch.py +++ /dev/null @@ -1,68 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class SwitchBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_on(self, value: bool) -> "SwitchBase": - pass - - @abstractmethod - def is_on(self) -> bool: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Switch - # ======================================== - - from java import jclass - - class Switch(SwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = jclass("android.widget.Switch") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_on(value) - - def set_on(self, value: bool) -> "Switch": - self.native_instance.setChecked(value) - return self - - def is_on(self) -> bool: - return self.native_instance.isChecked() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiswitch - # ======================================== - - from rubicon.objc import ObjCClass - - class Switch(SwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = ObjCClass("UISwitch") - self.native_instance = self.native_class.alloc().init() - self.set_on(value) - - def set_on(self, value: bool) -> "Switch": - self.native_instance.setOn_animated_(value, False) - return self - - def is_on(self) -> bool: - return self.native_instance.isOn() diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt index b5d1dab..4c35fcb 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt @@ -65,7 +65,8 @@ class PageFragment : Fragment() { utils.callAttr("set_android_fragment_container", view) // Now that container exists, invoke on_create so Python can attach its root view page?.callAttr("on_create") - } catch (_: Exception) { + } catch (e: Exception) { + Log.e(TAG, "on_create failed", e) } } diff --git a/src/pythonnative/text_field.py b/src/pythonnative/text_field.py deleted file mode 100644 index d6d1de1..0000000 --- a/src/pythonnative/text_field.py +++ /dev/null @@ -1,132 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TextFieldBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> "TextFieldBase": - pass - - @abstractmethod - def get_text(self) -> str: - pass - - @abstractmethod - def set_text_color(self, color: Any) -> "TextFieldBase": - pass - - @abstractmethod - def set_text_size(self, size: float) -> "TextFieldBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/EditText - # ======================================== - - from java import jclass - - class TextField(TextFieldBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.EditText") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setSingleLine(True) - self.set_text(text) - - def set_text(self, text: str) -> "TextField": - self.native_instance.setText(text) - return self - - def get_text(self) -> str: - return self.native_instance.getText().toString() - - def set_text_color(self, color: Any) -> "TextField": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - self.native_instance.setTextColor(color_int) - except Exception: - pass - return self - - def set_text_size(self, size_sp: float) -> "TextField": - try: - self.native_instance.setTextSize(float(size_sp)) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitextfield - # ======================================== - - from rubicon.objc import ObjCClass - - class TextField(TextFieldBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UITextField") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> "TextField": - self.native_instance.setText_(text) - return self - - def get_text(self) -> str: - return self.native_instance.text() - - def set_text_color(self, color: Any) -> "TextField": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - self.native_instance.setTextColor_(color_obj) - except Exception: - pass - return self - - def set_text_size(self, size: float) -> "TextField": - try: - UIFont = ObjCClass("UIFont") - font = UIFont.systemFontOfSize_(float(size)) - self.native_instance.setFont_(font) - except Exception: - pass - return self diff --git a/src/pythonnative/text_view.py b/src/pythonnative/text_view.py deleted file mode 100644 index 1e997bf..0000000 --- a/src/pythonnative/text_view.py +++ /dev/null @@ -1,135 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TextViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> "TextViewBase": - pass - - @abstractmethod - def get_text(self) -> str: - pass - - @abstractmethod - def set_text_color(self, color: Any) -> "TextViewBase": - pass - - @abstractmethod - def set_text_size(self, size: float) -> "TextViewBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/EditText - # ======================================== - - from java import jclass - - class TextView(TextViewBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.EditText") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setLines(3) - self.native_instance.setMaxLines(5) - self.native_instance.setVerticalScrollBarEnabled(True) - # self.native_instance.movementMethod = ScrollingMovementMethod() - self.set_text(text) - - def set_text(self, text: str) -> "TextView": - self.native_instance.setText(text) - return self - - def get_text(self) -> str: - return self.native_instance.getText().toString() - - def set_text_color(self, color: Any) -> "TextView": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - self.native_instance.setTextColor(color_int) - except Exception: - pass - return self - - def set_text_size(self, size_sp: float) -> "TextView": - try: - self.native_instance.setTextSize(float(size_sp)) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitextview - # ======================================== - - from rubicon.objc import ObjCClass - - class TextView(TextViewBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UITextView") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> "TextView": - self.native_instance.setText_(text) - return self - - def get_text(self) -> str: - return self.native_instance.text() - - def set_text_color(self, color: Any) -> "TextView": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - self.native_instance.setTextColor_(color_obj) - except Exception: - pass - return self - - def set_text_size(self, size: float) -> "TextView": - try: - UIFont = ObjCClass("UIFont") - font = UIFont.systemFontOfSize_(float(size)) - self.native_instance.setFont_(font) - except Exception: - pass - return self diff --git a/src/pythonnative/time_picker.py b/src/pythonnative/time_picker.py deleted file mode 100644 index d9085b9..0000000 --- a/src/pythonnative/time_picker.py +++ /dev/null @@ -1,77 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TimePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_time(self, hour: int, minute: int) -> "TimePickerBase": - pass - - @abstractmethod - def get_time(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/TimePicker - # ======================================== - - from typing import Any - - from java import jclass - - class TimePicker(TimePickerBase, ViewBase): - def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.TimePicker") - self.native_instance = self.native_class(context) - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "TimePicker": - self.native_instance.setHour(hour) - self.native_instance.setMinute(minute) - return self - - def get_time(self) -> tuple: - hour = self.native_instance.getHour() - minute = self.native_instance.getMinute() - return hour, minute - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import time - - from rubicon.objc import ObjCClass - - class TimePicker(TimePickerBase, ViewBase): - def __init__(self, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.native_instance.setDatePickerMode_(1) # Setting mode to Time - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "TimePicker": - t = time(hour, minute) - self.native_instance.setTime_(t) - return self - - def get_time(self) -> tuple: - t = self.native_instance.time() - return t.hour, t.minute diff --git a/src/pythonnative/utils.py b/src/pythonnative/utils.py index 1cbde95..4505a0e 100644 --- a/src/pythonnative/utils.py +++ b/src/pythonnative/utils.py @@ -1,27 +1,29 @@ +"""Platform detection and shared helpers. + +This module is imported early by most other modules, so it avoids +importing platform-specific packages at module level. +""" + import os from typing import Any, Optional -# Platform detection with multiple fallbacks suitable for Chaquopy/Android +# ====================================================================== +# Platform detection +# ====================================================================== + _is_android: Optional[bool] = None def _detect_android() -> bool: - # 1) Direct environment hints commonly present on Android env = os.environ if "ANDROID_BOOTLOGO" in env or "ANDROID_ROOT" in env or "ANDROID_DATA" in env or "ANDROID_ARGUMENT" in env: return True - - # 2) Chaquopy-specific: the builtin 'java' package is available try: - # Import inside try so importing this module doesn't explode off-device - from java import jclass + from java import jclass # noqa: F401 - _ = jclass # silence linter unused return True except Exception: pass - - # 3) Last resort: some Android Python dists set os.name/others, but avoid false positives return False @@ -39,53 +41,43 @@ def _get_is_android() -> bool: IS_ANDROID: bool = _get_is_android() -# Global hooks to access current Android Activity/Context and Fragment container from Python code +# ====================================================================== +# Android context management +# ====================================================================== + _android_context: Any = None _android_fragment_container: Any = None def set_android_context(context: Any) -> None: - """Record the current Android Activity/Context for implicit constructor use. - - On Android, Python UI components require a Context to create native views. - We capture it when a Page is constructed from the host Activity so component - constructors can be platform-consistent and avoid explicit context params. - """ - + """Record the current Android Activity/Context for view construction.""" global _android_context _android_context = context def set_android_fragment_container(container_view: Any) -> None: - """Record the current Fragment root container ViewGroup for rendering pages. - - The current Page's `set_root_view` will attach its native view to this container. - """ + """Record the current Fragment root container ViewGroup.""" global _android_fragment_container _android_fragment_container = container_view def get_android_context() -> Any: - """Return the previously set Android Activity/Context or raise if missing.""" - + """Return the current Android Activity/Context.""" if not IS_ANDROID: raise RuntimeError("get_android_context() called on non-Android platform") if _android_context is None: raise RuntimeError( - "Android context is not set. Ensure Page is initialized from an Activity " "before constructing views." + "Android context not set. Ensure Page is initialized from an Activity before constructing views." ) return _android_context def get_android_fragment_container() -> Any: - """Return the previously set Fragment container ViewGroup or raise if missing. - - This is set by the host `PageFragment` when its view is created. - """ + """Return the current Fragment container ViewGroup.""" if not IS_ANDROID: raise RuntimeError("get_android_fragment_container() called on non-Android platform") if _android_fragment_container is None: raise RuntimeError( - "Android fragment container is not set. Ensure PageFragment has been created before set_root_view." + "Android fragment container not set. Ensure PageFragment has been created before set_root_view." ) return _android_fragment_container diff --git a/src/pythonnative/view.py b/src/pythonnative/view.py deleted file mode 100644 index abef7f6..0000000 --- a/src/pythonnative/view.py +++ /dev/null @@ -1,173 +0,0 @@ -from abc import ABC -from typing import Any, Optional, Tuple - -# ======================================== -# Base class -# ======================================== - - -class ViewBase(ABC): - def __init__(self) -> None: - # Native bridge handles return types dynamically; these attributes are set at runtime. - self.native_instance: Any = None - self.native_class: Any = None - # Record margins for parents that can apply them (Android LinearLayout) - self._pn_margin: Optional[Tuple[int, int, int, int]] = None - - # ======================================== - # Lightweight style helpers - # ======================================== - - def set_background_color(self, color: Any) -> "ViewBase": - """Set background color. Accepts platform color int or CSS-like hex string. Returns self.""" - try: - from .utils import IS_ANDROID - - if isinstance(color, str): - # Support formats: #RRGGBB or #AARRGGBB - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - - if IS_ANDROID: - # Android expects ARGB int - self.native_instance.setBackgroundColor(color_int) - else: - # iOS expects a UIColor - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - try: - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - except Exception: - color_obj = UIColor.blackColor() - try: - self.native_instance.setBackgroundColor_(color_obj) - except Exception: - try: - # Some UIKit classes expose 'backgroundColor' property - self.native_instance.setBackgroundColor_(color_obj) - except Exception: - pass - except Exception: - pass - return self - - def set_padding( - self, - left: Optional[int] = None, - top: Optional[int] = None, - right: Optional[int] = None, - bottom: Optional[int] = None, - all: Optional[int] = None, - horizontal: Optional[int] = None, - vertical: Optional[int] = None, - ) -> "ViewBase": - """Set padding (dp on Android; best-effort on iOS where supported). Returns self. - - When provided, 'all' applies to all sides; 'horizontal' applies to left and right; - 'vertical' applies to top and bottom; individual overrides take precedence. - """ - try: - from .utils import IS_ANDROID, get_android_context - - left_value = left - top_value = top - right_value = right - bottom_value = bottom - if all is not None: - left_value = top_value = right_value = bottom_value = all - if horizontal is not None: - left_value = horizontal if left_value is None else left_value - right_value = horizontal if right_value is None else right_value - if vertical is not None: - top_value = vertical if top_value is None else top_value - bottom_value = vertical if bottom_value is None else bottom_value - left_value = left_value or 0 - top_value = top_value or 0 - right_value = right_value or 0 - bottom_value = bottom_value or 0 - - if IS_ANDROID: - density = get_android_context().getResources().getDisplayMetrics().density - lpx = int(left_value * density) - tpx = int(top_value * density) - rpx = int(right_value * density) - bpx = int(bottom_value * density) - self.native_instance.setPadding(lpx, tpx, rpx, bpx) - else: - # Best-effort: many UIKit views don't have direct padding; leave to containers (e.g. UIStackView) - # No-op by default. - pass - except Exception: - pass - return self - - def set_margin( - self, - left: Optional[int] = None, - top: Optional[int] = None, - right: Optional[int] = None, - bottom: Optional[int] = None, - all: Optional[int] = None, - horizontal: Optional[int] = None, - vertical: Optional[int] = None, - ) -> "ViewBase": - """Record margins for this view (applied where supported). Returns self. - - Currently applied automatically when added to Android LinearLayout (StackView). - """ - try: - left_value = left - top_value = top - right_value = right - bottom_value = bottom - if all is not None: - left_value = top_value = right_value = bottom_value = all - if horizontal is not None: - left_value = horizontal if left_value is None else left_value - right_value = horizontal if right_value is None else right_value - if vertical is not None: - top_value = vertical if top_value is None else top_value - bottom_value = vertical if bottom_value is None else bottom_value - left_value = int(left_value or 0) - top_value = int(top_value or 0) - right_value = int(right_value or 0) - bottom_value = int(bottom_value or 0) - self._pn_margin = (left_value, top_value, right_value, bottom_value) - except Exception: - pass - return self - - def wrap_in_scroll(self) -> Any: - """Return a ScrollView containing this view as its only child. Returns the ScrollView.""" - try: - # Local import to avoid circulars - from .scroll_view import ScrollView - - sv = ScrollView() - sv.add_view(self) - return sv - except Exception: - return None - - # @abstractmethod - # def add_view(self, view): - # pass - # - # @abstractmethod - # def set_layout(self, layout): - # pass - # - # @abstractmethod - # def show(self): - # pass diff --git a/src/pythonnative/web_view.py b/src/pythonnative/web_view.py deleted file mode 100644 index b72d51b..0000000 --- a/src/pythonnative/web_view.py +++ /dev/null @@ -1,60 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class WebViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def load_url(self, url: str) -> "WebViewBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/webkit/WebView - # ======================================== - - from java import jclass - - class WebView(WebViewBase, ViewBase): - def __init__(self, url: str = "") -> None: - super().__init__() - self.native_class = jclass("android.webkit.WebView") - context = get_android_context() - self.native_instance = self.native_class(context) - self.load_url(url) - - def load_url(self, url: str) -> "WebView": - self.native_instance.loadUrl(url) - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/webkit/wkwebview - # ======================================== - - from rubicon.objc import NSURL, NSURLRequest, ObjCClass - - class WebView(WebViewBase, ViewBase): - def __init__(self, url: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("WKWebView") - self.native_instance = self.native_class.alloc().init() - self.load_url(url) - - def load_url(self, url: str) -> "WebView": - ns_url = NSURL.URLWithString_(url) - request = NSURLRequest.requestWithURL_(ns_url) - self.native_instance.loadRequest_(request) - return self diff --git a/tests/e2e/android.yaml b/tests/e2e/android.yaml new file mode 100644 index 0000000..5b9a6ff --- /dev/null +++ b/tests/e2e/android.yaml @@ -0,0 +1,6 @@ +appId: com.pythonnative.android_template +env: + APP_ID: com.pythonnative.android_template +--- +- runFlow: flows/main_page.yaml +- runFlow: flows/navigation.yaml diff --git a/tests/e2e/flows/main_page.yaml b/tests/e2e/flows/main_page.yaml new file mode 100644 index 0000000..43c184d --- /dev/null +++ b/tests/e2e/flows/main_page.yaml @@ -0,0 +1,12 @@ +appId: ${APP_ID} +--- +# Verify main page renders correctly and the counter works. +- launchApp +- assertVisible: "Hello from PythonNative Demo!" +- assertVisible: "Tapped 0 times" +- assertVisible: "Tap me" +- assertVisible: "Go to Second Page" +- tapOn: "Tap me" +- assertVisible: "Tapped 1 times" +- tapOn: "Tap me" +- assertVisible: "Tapped 2 times" diff --git a/tests/e2e/flows/navigation.yaml b/tests/e2e/flows/navigation.yaml new file mode 100644 index 0000000..6e1b521 --- /dev/null +++ b/tests/e2e/flows/navigation.yaml @@ -0,0 +1,17 @@ +appId: ${APP_ID} +--- +# Navigate through all three pages and back to main. +- launchApp +- assertVisible: "Hello from PythonNative Demo!" +- tapOn: "Go to Second Page" +- assertVisible: "Greetings from MainPage" +- assertVisible: "Go to Third Page" +- assertVisible: "Back" +- tapOn: "Go to Third Page" +- assertVisible: "Third Page" +- assertVisible: "You navigated two levels deep." +- assertVisible: "Back to Second" +- tapOn: "Back to Second" +- assertVisible: "Greetings from MainPage" +- tapOn: "Back" +- assertVisible: "Hello from PythonNative Demo!" diff --git a/tests/e2e/ios.yaml b/tests/e2e/ios.yaml new file mode 100644 index 0000000..c41eaa8 --- /dev/null +++ b/tests/e2e/ios.yaml @@ -0,0 +1,6 @@ +appId: com.pythonnative.ios-template +env: + APP_ID: com.pythonnative.ios-template +--- +- runFlow: flows/main_page.yaml +- runFlow: flows/navigation.yaml diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000..8ff7393 --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,194 @@ +"""Unit tests for the built-in element-creating functions.""" + +from pythonnative.components import ( + ActivityIndicator, + Button, + Column, + Image, + ProgressBar, + Row, + ScrollView, + Spacer, + Switch, + Text, + TextInput, + WebView, +) + +# --------------------------------------------------------------------------- +# Text +# --------------------------------------------------------------------------- + + +def test_text_defaults() -> None: + el = Text() + assert el.type == "Text" + assert el.props.get("text", "") == "" + assert el.children == [] + + +def test_text_with_props() -> None: + el = Text("Hello", font_size=18, color="#FF0000", bold=True, text_align="center") + assert el.props["text"] == "Hello" + assert el.props["font_size"] == 18 + assert el.props["color"] == "#FF0000" + assert el.props["bold"] is True + assert el.props["text_align"] == "center" + + +def test_text_none_props_excluded() -> None: + el = Text("Hi") + assert "font_size" not in el.props + assert "color" not in el.props + + +# --------------------------------------------------------------------------- +# Button +# --------------------------------------------------------------------------- + + +def test_button_defaults() -> None: + el = Button() + assert el.type == "Button" + assert el.props["title"] == "" + assert "on_click" not in el.props + + +def test_button_with_callback() -> None: + cb = lambda: None # noqa: E731 + el = Button("Tap", on_click=cb, background_color="#123456") + assert el.props["title"] == "Tap" + assert el.props["on_click"] is cb + assert el.props["background_color"] == "#123456" + + +def test_button_disabled() -> None: + el = Button("Off", enabled=False) + assert el.props["enabled"] is False + + +# --------------------------------------------------------------------------- +# Column / Row +# --------------------------------------------------------------------------- + + +def test_column_with_children() -> None: + el = Column(Text("a"), Text("b"), spacing=10, padding=16, alignment="fill") + assert el.type == "Column" + assert len(el.children) == 2 + assert el.props["spacing"] == 10 + assert el.props["padding"] == 16 + assert el.props["alignment"] == "fill" + + +def test_row_with_children() -> None: + el = Row(Text("x"), Text("y"), spacing=5) + assert el.type == "Row" + assert len(el.children) == 2 + assert el.props["spacing"] == 5 + + +def test_column_no_spacing_omitted() -> None: + el = Column() + assert "spacing" not in el.props + + +# --------------------------------------------------------------------------- +# ScrollView +# --------------------------------------------------------------------------- + + +def test_scrollview_with_child() -> None: + child = Column(Text("a")) + el = ScrollView(child) + assert el.type == "ScrollView" + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_scrollview_empty() -> None: + el = ScrollView() + assert el.children == [] + + +# --------------------------------------------------------------------------- +# TextInput +# --------------------------------------------------------------------------- + + +def test_textinput_defaults() -> None: + el = TextInput() + assert el.type == "TextInput" + assert el.props["value"] == "" + + +def test_textinput_with_props() -> None: + cb = lambda s: None # noqa: E731 + el = TextInput(value="hi", placeholder="type...", on_change=cb, secure=True) + assert el.props["value"] == "hi" + assert el.props["placeholder"] == "type..." + assert el.props["on_change"] is cb + assert el.props["secure"] is True + + +# --------------------------------------------------------------------------- +# Other leaf components +# --------------------------------------------------------------------------- + + +def test_image() -> None: + el = Image("icon.png", width=48, height=48) + assert el.type == "Image" + assert el.props["source"] == "icon.png" + assert el.props["width"] == 48 + + +def test_switch() -> None: + el = Switch(value=True) + assert el.type == "Switch" + assert el.props["value"] is True + + +def test_progress_bar() -> None: + el = ProgressBar(value=0.5) + assert el.type == "ProgressBar" + assert el.props["value"] == 0.5 + + +def test_activity_indicator() -> None: + el = ActivityIndicator(animating=False) + assert el.type == "ActivityIndicator" + assert el.props["animating"] is False + + +def test_webview() -> None: + el = WebView(url="https://example.com") + assert el.type == "WebView" + assert el.props["url"] == "https://example.com" + + +def test_spacer() -> None: + el = Spacer(size=20) + assert el.type == "Spacer" + assert el.props["size"] == 20 + + +def test_spacer_empty() -> None: + el = Spacer() + assert el.type == "Spacer" + assert el.props == {} + + +# --------------------------------------------------------------------------- +# Key support +# --------------------------------------------------------------------------- + + +def test_key_propagation() -> None: + el = Text("keyed", key="k1") + assert el.key == "k1" + + +def test_column_key() -> None: + el = Column(key="col-1") + assert el.key == "col-1" diff --git a/tests/test_element.py b/tests/test_element.py new file mode 100644 index 0000000..bd9e887 --- /dev/null +++ b/tests/test_element.py @@ -0,0 +1,71 @@ +"""Unit tests for Element descriptors.""" + +from pythonnative.element import Element + + +def test_element_creation() -> None: + el = Element("Text", {"text": "hello"}, []) + assert el.type == "Text" + assert el.props == {"text": "hello"} + assert el.children == [] + assert el.key is None + + +def test_element_with_key() -> None: + el = Element("Text", {}, [], key="abc") + assert el.key == "abc" + + +def test_element_with_children() -> None: + child1 = Element("Text", {"text": "a"}, []) + child2 = Element("Text", {"text": "b"}, []) + parent = Element("Column", {}, [child1, child2]) + assert len(parent.children) == 2 + assert parent.children[0].props["text"] == "a" + assert parent.children[1].props["text"] == "b" + + +def test_element_equality() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Text", {"text": "hi"}, []) + assert a == b + + +def test_element_inequality_type() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Button", {"text": "hi"}, []) + assert a != b + + +def test_element_inequality_props() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Text", {"text": "bye"}, []) + assert a != b + + +def test_element_inequality_children() -> None: + child = Element("Text", {}, []) + a = Element("Column", {}, [child]) + b = Element("Column", {}, []) + assert a != b + + +def test_element_not_equal_to_other_types() -> None: + el = Element("Text", {}, []) + assert el != "not an element" + assert el != 42 + + +def test_element_repr() -> None: + el = Element("Button", {"title": "ok"}, []) + r = repr(el) + assert "Button" in r + assert "children=0" in r + + +def test_deeply_nested_equality() -> None: + leaf = Element("Text", {"text": "x"}, []) + mid = Element("Row", {}, [leaf]) + root_a = Element("Column", {}, [mid]) + root_b = Element("Column", {}, [Element("Row", {}, [Element("Text", {"text": "x"}, [])])]) + assert root_a == root_b diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py new file mode 100644 index 0000000..735592c --- /dev/null +++ b/tests/test_reconciler.py @@ -0,0 +1,280 @@ +"""Unit tests for the reconciler using a mock native backend.""" + +from typing import Any, Dict, List + +from pythonnative.element import Element +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend +# ====================================================================== + + +class MockView: + """Simulates a native view for testing.""" + + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + def __repr__(self) -> str: + return f"MockView({self.type_name}#{self.id})" + + +class MockBackend: + """Records operations for assertions.""" + + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, native_view: MockView, type_name: str, changed_props: Dict[str, Any]) -> None: + native_view.props.update(changed_props) + self.ops.append(("update", type_name, native_view.id, tuple(sorted(changed_props.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# Tests: mount +# ====================================================================== + + +def test_mount_single_element() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element("Text", {"text": "hello"}, []) + root = rec.mount(el) + assert isinstance(root, MockView) + assert root.type_name == "Text" + assert root.props["text"] == "hello" + + +def test_mount_nested_elements() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element( + "Column", + {}, + [ + Element("Text", {"text": "a"}, []), + Element("Button", {"title": "b"}, []), + ], + ) + root = rec.mount(el) + assert root.type_name == "Column" + assert len(root.children) == 2 + assert root.children[0].type_name == "Text" + assert root.children[1].type_name == "Button" + + +def test_mount_deeply_nested() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element( + "ScrollView", + {}, + [ + Element( + "Column", + {}, + [ + Element("Text", {"text": "deep"}, []), + ], + ), + ], + ) + root = rec.mount(el) + assert root.children[0].children[0].props["text"] == "deep" + + +# ====================================================================== +# Tests: reconcile (update props) +# ====================================================================== + + +def test_reconcile_updates_props() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "hello"}, []) + rec.mount(el1) + + backend.ops.clear() + el2 = Element("Text", {"text": "world"}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "text" in update_ops[0][3] + + +def test_reconcile_no_change_no_update() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element("Text", {"text": "same"}, []) + rec.mount(el) + + backend.ops.clear() + rec.reconcile(Element("Text", {"text": "same"}, [])) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 0 + + +# ====================================================================== +# Tests: reconcile children (add / remove) +# ====================================================================== + + +def test_reconcile_add_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + root = rec.mount(el1) + assert len(root.children) == 1 + + backend.ops.clear() + el2 = Element( + "Column", + {}, + [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])], + ) + rec.reconcile(el2) + + add_ops = [op for op in backend.ops if op[0] == "add_child"] + assert len(add_ops) == 1 + assert len(root.children) == 2 + + +def test_reconcile_remove_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element( + "Column", + {}, + [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])], + ) + root = rec.mount(el1) + assert len(root.children) == 2 + + backend.ops.clear() + el2 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + rec.reconcile(el2) + + remove_ops = [op for op in backend.ops if op[0] == "remove_child"] + assert len(remove_ops) == 1 + assert len(root.children) == 1 + + +def test_reconcile_replace_child_type() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + root = rec.mount(el1) + + backend.ops.clear() + el2 = Element("Column", {}, [Element("Button", {"title": "b"}, [])]) + rec.reconcile(el2) + + 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" + + +# ====================================================================== +# Tests: reconcile root type change +# ====================================================================== + + +def test_reconcile_root_type_change() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "a"}, []) + root1 = rec.mount(el1) + + el2 = Element("Button", {"title": "b"}, []) + root2 = rec.reconcile(el2) + assert root2.type_name == "Button" + assert root2 is not root1 + + +# ====================================================================== +# Tests: callback props always counted as changed +# ====================================================================== + + +def test_reconcile_callback_always_updated() -> None: + backend = MockBackend() + rec = Reconciler(backend) + cb1 = lambda: None # noqa: E731 + cb2 = lambda: None # noqa: E731 + el1 = Element("Button", {"title": "x", "on_click": cb1}, []) + rec.mount(el1) + + backend.ops.clear() + el2 = Element("Button", {"title": "x", "on_click": cb2}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "on_click" in update_ops[0][3] + + +# ====================================================================== +# Tests: removed props signalled as None +# ====================================================================== + + +def test_reconcile_removed_prop_becomes_none() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "hi", "color": "#FF0000"}, []) + root = rec.mount(el1) + + backend.ops.clear() + el2 = Element("Text", {"text": "hi"}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "color" in update_ops[0][3] + assert root.props.get("color") is None + + +# ====================================================================== +# Tests: complex multi-step reconciliation +# ====================================================================== + + +def test_multiple_reconcile_cycles() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + rec.mount(Element("Column", {}, [Element("Text", {"text": "0"}, [])])) + + for i in range(1, 5): + rec.reconcile(Element("Column", {}, [Element("Text", {"text": str(i)}, [])])) + + assert rec._tree is not None + assert rec._tree.children[0].element.props["text"] == "4" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d346c62..0dad9fc 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,2 +1,32 @@ -def test_pytest_workflow_smoke() -> None: - assert 2 + 2 == 4 +"""Verify the package is importable and exports the public API.""" + +import pythonnative as pn +from pythonnative.element import Element + + +def test_package_version() -> None: + assert pn.__version__ + + +def test_element_class_exported() -> None: + assert pn.Element is Element + + +def test_public_api_names() -> None: + expected = { + "ActivityIndicator", + "Button", + "Column", + "Element", + "Image", + "Page", + "ProgressBar", + "Row", + "ScrollView", + "Spacer", + "Switch", + "Text", + "TextInput", + "WebView", + } + assert expected.issubset(set(pn.__all__))