From 8103710aed5feb564583bb161cf81771669645fe Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:19:15 -0700 Subject: [PATCH 1/2] feat!: replace class-based Page with function components, style prop, and use_navigation hook --- README.md | 35 +- docs/api/component-properties.md | 81 ++- docs/api/pythonnative.md | 11 +- docs/concepts/architecture.md | 52 +- docs/concepts/components.md | 117 ++--- docs/concepts/hooks.md | 73 ++- docs/examples.md | 60 ++- docs/examples/hello-world.md | 28 +- docs/getting-started.md | 31 +- docs/guides/android.md | 4 + docs/guides/ios.md | 4 + docs/guides/navigation.md | 81 +-- docs/guides/styling.md | 102 ++-- docs/index.md | 20 +- docs/meta/roadmap.md | 153 ------ examples/hello-world/app/main_page.py | 42 +- examples/hello-world/app/second_page.py | 34 +- examples/hello-world/app/third_page.py | 24 +- mkdocs.yml | 1 - src/pythonnative/__init__.py | 26 +- src/pythonnative/cli/pn.py | 28 +- src/pythonnative/components.py | 468 +++++------------- src/pythonnative/hooks.py | 51 +- src/pythonnative/native_views.py | 102 +++- src/pythonnative/page.py | 310 ++++++------ src/pythonnative/style.py | 34 +- .../android_template/PageFragment.kt | 11 +- .../ios_template/ViewController.swift | 27 +- tests/test_cli.py | 2 +- tests/test_components.py | 52 +- tests/test_hooks.py | 32 ++ tests/test_smoke.py | 4 +- tests/test_style.py | 24 +- 33 files changed, 958 insertions(+), 1166 deletions(-) delete mode 100644 docs/meta/roadmap.md diff --git a/README.md b/README.md index b0c0d9a..62f5958 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,18 @@ ## Overview -PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a **declarative, React-like component model** with 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. +PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a **declarative, React-like component model** with hooks and automatic reconciliation, powered by Chaquopy on Android and rubicon-objc on iOS. Write function components with `use_state`, `use_effect`, and friends, just like React, and let PythonNative handle creating and updating native views. ## Features - **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. +- **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern. +- **`style` prop:** Pass all visual and layout properties through a single `style` dict, composable via `StyleSheet`. - **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation. - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app. -- **Navigation:** Push and pop screens with argument passing for multi-page apps. -- **Bundled templates:** Android Gradle and iOS Xcode templates are included — scaffolding requires no network access. +- **Navigation:** Push and pop screens with argument passing via the `use_navigation()` hook. +- **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. ## Quick Start @@ -52,21 +53,17 @@ pip install pythonnative 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( - "Tap me", - on_click=lambda: self.set_state(count=self.state["count"] + 1), - ), - spacing=12, - padding=16, - ) +@pn.component +def MainPage(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Tap me", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) ``` ## Documentation diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index 85e7a95..cda58d7 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -1,10 +1,10 @@ # Component Property Reference -All style and behaviour properties are passed as keyword arguments to element functions. +All visual and layout properties are passed via the `style` dict (or list of dicts) to element functions. Behavioural properties (callbacks, data, content) remain as keyword arguments. -## Common layout properties +## Common layout properties (inside `style`) -All components accept these layout properties: +All components accept these layout properties in their `style` dict: - `width` — fixed width in dp (Android) / pt (iOS) - `height` — fixed height @@ -13,60 +13,56 @@ All components accept these layout properties: - `min_width`, `max_width` — width constraints - `min_height`, `max_height` — height constraints - `align_self` — override parent alignment (`"fill"`, `"center"`, etc.) -- `key` — stable identity for reconciliation +- `key` — stable identity for reconciliation (passed as a kwarg, not inside `style`) ## Text ```python -pn.Text(text, font_size=None, color=None, bold=False, text_align=None, - background_color=None, max_lines=None) +pn.Text(text, style={"font_size": 18, "color": "#333", "bold": True, "text_align": "center"}) ``` -- `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 +- `text` — display string (positional) +- Style properties: `font_size`, `color`, `bold`, `text_align`, `background_color`, `max_lines` ## Button ```python -pn.Button(title, on_click=None, color=None, background_color=None, - font_size=None, enabled=True) +pn.Button(title, on_click=handler, style={"color": "#FFF", "background_color": "#007AFF", "font_size": 16}) ``` -- `title` — button label +- `title` — button label (positional) - `on_click` — callback `() -> None` -- `color` — title text colour -- `background_color` — button background -- `enabled` — interactive state +- `enabled` — interactive state (kwarg, default `True`) +- Style properties: `color`, `background_color`, `font_size` ## Column / Row ```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) +pn.Column(*children, style={"spacing": 12, "padding": 16, "align_items": "center"}) +pn.Row(*children, style={"spacing": 8, "justify_content": "space_between"}) ``` -- `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 +- `*children` — child elements (positional) +- Style properties: + - `spacing` — gap between children (dp / pt) + - `padding` — inner padding (int for all sides, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) + - `alignment` — cross-axis alignment shorthand + - `align_items` — cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"`, `"stretch"` + - `justify_content` — main-axis distribution: `"start"`, `"center"`, `"end"`, `"space_between"`, `"space_around"` + - `background_color` — container background ## View ```python -pn.View(*children, background_color=None, padding=None) +pn.View(*children, style={"background_color": "#F5F5F5", "padding": 16}) ``` -Generic container (UIView / FrameLayout). Supports all layout properties. +Generic container (UIView / FrameLayout). Supports all layout properties in `style`. ## SafeAreaView ```python -pn.SafeAreaView(*children, background_color=None, padding=None) +pn.SafeAreaView(*children, style={"background_color": "#FFF", "padding": 8}) ``` Container that respects safe area insets (notch, status bar). @@ -74,14 +70,14 @@ Container that respects safe area insets (notch, status bar). ## ScrollView ```python -pn.ScrollView(child, background_color=None) +pn.ScrollView(child, style={"background_color": "#FFF"}) ``` ## TextInput ```python -pn.TextInput(value="", placeholder="", on_change=None, secure=False, - font_size=None, color=None, background_color=None) +pn.TextInput(value="", placeholder="Enter text", on_change=handler, secure=False, + style={"font_size": 16, "color": "#000", "background_color": "#FFF"}) ``` - `on_change` — callback `(str) -> None` receiving new text @@ -89,16 +85,16 @@ pn.TextInput(value="", placeholder="", on_change=None, secure=False, ## Image ```python -pn.Image(source="", width=None, height=None, scale_type=None, background_color=None) +pn.Image(source="https://example.com/photo.jpg", style={"width": 200, "height": 150, "scale_type": "cover"}) ``` - `source` — image URL (`http://...` / `https://...`) or local resource name -- `scale_type` — `"cover"`, `"contain"`, `"stretch"`, `"center"` +- Style properties: `width`, `height`, `scale_type` (`"cover"`, `"contain"`, `"stretch"`, `"center"`), `background_color` ## Switch ```python -pn.Switch(value=False, on_change=None) +pn.Switch(value=False, on_change=handler) ``` - `on_change` — callback `(bool) -> None` @@ -106,7 +102,7 @@ pn.Switch(value=False, on_change=None) ## Slider ```python -pn.Slider(value=0.0, min_value=0.0, max_value=1.0, on_change=None) +pn.Slider(value=0.5, min_value=0.0, max_value=1.0, on_change=handler) ``` - `on_change` — callback `(float) -> None` @@ -114,7 +110,7 @@ pn.Slider(value=0.0, min_value=0.0, max_value=1.0, on_change=None) ## ProgressBar ```python -pn.ProgressBar(value=0.0, background_color=None) +pn.ProgressBar(value=0.5, style={"background_color": "#EEE"}) ``` - `value` — 0.0 to 1.0 @@ -128,13 +124,13 @@ pn.ActivityIndicator(animating=True) ## WebView ```python -pn.WebView(url="") +pn.WebView(url="https://example.com") ``` ## Spacer ```python -pn.Spacer(size=None, flex=None) +pn.Spacer(size=16, flex=1) ``` - `size` — fixed dimension in dp / pt @@ -143,7 +139,7 @@ pn.Spacer(size=None, flex=None) ## Pressable ```python -pn.Pressable(child, on_press=None, on_long_press=None) +pn.Pressable(child, on_press=handler, on_long_press=handler) ``` Wraps any child element with tap/long-press handling. @@ -151,7 +147,8 @@ Wraps any child element with tap/long-press handling. ## Modal ```python -pn.Modal(*children, visible=False, on_dismiss=None, title=None, background_color=None) +pn.Modal(*children, visible=show_modal, on_dismiss=handler, title="Confirm", + style={"background_color": "#FFF"}) ``` Overlay dialog shown when `visible=True`. @@ -159,8 +156,8 @@ Overlay dialog shown when `visible=True`. ## FlatList ```python -pn.FlatList(data=None, render_item=None, key_extractor=None, - separator_height=0, background_color=None) +pn.FlatList(data=items, render_item=render_fn, key_extractor=key_fn, + separator_height=1, style={"background_color": "#FFF"}) ``` - `data` — list of items diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 8caac58..849cf95 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -2,16 +2,16 @@ ## Public API -### Page +### create_page -`pythonnative.Page` — base class for screens. Subclass it, implement `render()`, and use `set_state()` to trigger re-renders. +`pythonnative.create_page(...)` — called internally by native templates to bootstrap the root component. You don't call this directly. ### Element functions - `pythonnative.Text`, `Button`, `Column`, `Row`, `ScrollView`, `TextInput`, `Image`, `Switch`, `ProgressBar`, `ActivityIndicator`, `WebView`, `Spacer` - `pythonnative.View`, `SafeAreaView`, `Modal`, `Slider`, `Pressable`, `FlatList` -Each returns an `Element` descriptor. See the Component Property Reference for full signatures. +Each returns an `Element` descriptor. Visual and layout properties are passed via `style={...}`. See the Component Property Reference for full details. ### Element @@ -24,6 +24,7 @@ Function component primitives: - `pythonnative.component` — decorator to create a function component - `pythonnative.use_state(initial)` — local component state - `pythonnative.use_effect(effect, deps)` — side effects +- `pythonnative.use_navigation()` — navigation handle (push/pop/get_args) - `pythonnative.use_memo(factory, deps)` — memoised values - `pythonnative.use_callback(fn, deps)` — stable function references - `pythonnative.use_ref(initial)` — mutable ref object @@ -47,12 +48,12 @@ Function component primitives: - `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.set_android_context(ctx)` — set internally during page bootstrapping; you generally don't call this directly. - `pythonnative.utils.get_android_fragment_container()` — returns the current Fragment container `ViewGroup` used for page rendering. ## Reconciler -`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, and context providers. Used internally by `Page`. +`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, and context providers. Used internally by `create_page`. ## Hot reload diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 903db1e..6ededb9 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -4,21 +4,20 @@ PythonNative combines **direct native bindings** with a **declarative reconciler ## High-level model -1. **Declarative element tree:** Your `Page.render()` method returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes). -2. **Function components and hooks:** Reusable components with independent state via `@pn.component`, `use_state`, `use_effect`, etc. — inspired by React hooks but designed for Python. -3. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by `set_state` or hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations. +1. **Declarative element tree:** Your `@pn.component` function returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes). +2. **Function components and hooks:** All UI is built with `@pn.component` functions using `use_state`, `use_effect`, `use_navigation`, etc. — inspired by React hooks but designed for Python. +3. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations. 4. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content. 5. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: - **iOS:** rubicon-objc exposes Objective-C/Swift classes (`UILabel`, `UIButton`, `UIStackView`, etc.). - **Android:** Chaquopy exposes Java classes (`android.widget.TextView`, `android.widget.Button`, etc.) via the JNI bridge. -6. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It passes a live instance/pointer into Python, and Python drives the UI through the reconciler. +6. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls `create_page()` internally to bootstrap your Python component, and the reconciler drives the UI from there. ## How it works ``` -Page.render() → Element tree → Reconciler → Native views - ↑ -Page.set_state() → re-render → diff → patch native views +@pn.component fn → Element tree → Reconciler → Native views + ↑ Hook set_state() → re-render → diff → patch native views ``` @@ -26,32 +25,36 @@ The reconciler uses **key-based diffing** (matching children by key first, then ## Component model -PythonNative supports two kinds of components: - -### Page classes (screens) - -Each screen is a `Page` subclass that bridges native lifecycle events to Python. Pages have `render()`, `set_state()`, navigation (`push`/`pop`), and lifecycle hooks (`on_create`, `on_resume`, etc.). - -### Function components (reusable UI) - -Decorated with `@pn.component`, these are Python functions that return `Element` trees and can use hooks for state, effects, memoisation, and context. Each call site creates an independent instance with its own hook state. +PythonNative uses a single component model: **function components** decorated with `@pn.component`. ```python @pn.component -def counter(initial: int = 0) -> pn.Element: +def Counter(initial: int = 0): count, set_count = pn.use_state(initial) - return pn.Text(f"Count: {count}") + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 18}), + pn.Button("+", on_click=lambda: set_count(count + 1)), + style={"spacing": 4}, + ) ``` +Each component is a Python function that: +- Accepts props as keyword arguments +- Uses hooks for state (`use_state`), side effects (`use_effect`), navigation (`use_navigation`), and more +- Returns an `Element` tree describing the UI +- Each call site creates an independent instance with its own hook state + +The entry point `create_page()` is called internally by native templates to bootstrap your root component. You don't call it directly. + ## Styling -- **Inline styles:** Pass props directly to components (`font_size=24`, `color="#333"`). +- **`style` prop:** Pass a dict (or list of dicts) to any component — `style={"font_size": 24, "color": "#333"}`. - **StyleSheet:** Create reusable named style dictionaries with `pn.StyleSheet.create(...)`. - **Theming:** Use `pn.ThemeContext` with `pn.Provider` and `pn.use_context` to propagate theme values through the tree. ## Layout -All components support layout properties: `width`, `height`, `flex`, `margin`, `min_width`, `max_width`, `min_height`, `max_height`, `align_self`. Containers (`Column`, `Row`) support `spacing`, `padding`, and `alignment`. +All components support layout properties inside the `style` dict: `width`, `height`, `flex`, `margin`, `min_width`, `max_width`, `min_height`, `max_height`, `align_self`. Containers (`Column`, `Row`) support `spacing`, `padding`, `alignment`, `align_items`, and `justify_content`. ## Comparison @@ -60,14 +63,14 @@ All components support layout properties: `width`, `height`, `flex`, `margin`, ` ## iOS flow (Rubicon-ObjC) -- The iOS template (Swift + PythonKit) boots Python and instantiates your `MainPage` with the current `UIViewController` pointer. -- `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. +- The iOS template (Swift + PythonKit) boots Python and calls `create_page()` internally with the current `UIViewController` pointer. +- The reconciler creates UIKit views and attaches them to the controller's view. +- State changes trigger re-renders; the reconciler patches UIKit views in-place. ## Android flow (Chaquopy) - The Android template (Kotlin + Chaquopy) initializes Python in `MainActivity` and passes the `Activity` to Python. -- `PageFragment` calls `on_create()` on the Python `Page`, which renders and attaches views to the fragment container. +- `PageFragment` calls `create_page()` internally, which renders the root component and attaches views to the fragment container. - State changes trigger re-render; the reconciler patches Android views in-place. ## Hot reload @@ -86,6 +89,7 @@ PythonNative provides cross-platform modules for common device APIs: ## Navigation model overview - See the Navigation guide for full details. + - Navigation is handled via the `use_navigation()` hook, which returns a `NavigationHandle` with `.push()`, `.pop()`, and `.get_args()`. - 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 a39d869..6a2db75 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -9,13 +9,12 @@ UI is built with element-creating functions. Each returns a lightweight `Element ```python import pythonnative as pn -pn.Text("Hello", font_size=18, color="#333333") +pn.Text("Hello", style={"font_size": 18, "color": "#333333"}) pn.Button("Tap me", on_click=lambda: print("tapped")) pn.Column( pn.Text("First"), pn.Text("Second"), - spacing=8, - padding=16, + style={"spacing": 8, "padding": 16}, ) ``` @@ -23,23 +22,23 @@ pn.Column( **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 -- `View(*children, background_color, padding)` — generic container -- `SafeAreaView(*children, background_color, padding)` — safe-area-aware container +- `Column(*children, style=...)` — vertical stack +- `Row(*children, style=...)` — horizontal stack +- `ScrollView(child, style=...)` — scrollable container +- `View(*children, style=...)` — generic container +- `SafeAreaView(*children, style=...)` — safe-area-aware container - `Spacer(size, flex)` — empty space **Display:** -- `Text(text, font_size, color, bold, text_align, background_color, max_lines)` — text display -- `Image(source, width, height, scale_type)` — image display (supports URLs and resource names) +- `Text(text, style=...)` — text display +- `Image(source, style=...)` — image display (supports URLs and resource names) - `WebView(url)` — embedded web content **Input:** -- `Button(title, on_click, color, background_color, font_size, enabled)` — tappable button -- `TextInput(value, placeholder, on_change, secure, font_size, color)` — text entry +- `Button(title, on_click, style=...)` — tappable button +- `TextInput(value, placeholder, on_change, secure, style=...)` — text entry - `Switch(value, on_change)` — toggle switch - `Slider(value, min_value, max_value, on_change)` — continuous slider - `Pressable(child, on_press, on_long_press)` — tap handler wrapper @@ -59,7 +58,7 @@ pn.Column( ### Layout properties -All components support common layout properties: +All components accept layout properties inside the `style` dict: - `width`, `height` — fixed dimensions (dp / pt) - `flex` — flex grow factor @@ -67,74 +66,65 @@ All components support common layout properties: - `min_width`, `max_width`, `min_height`, `max_height` — size constraints - `align_self` — override parent alignment for this child -## Page — the root component +## Function components — the building block -Each screen is a `Page` subclass with a `render()` method that returns an element tree: +All UI in PythonNative is built with `@pn.component` function components. Each screen is a function component 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) +@pn.component +def MainPage(): + name, set_name = pn.use_state("World") + return pn.Text(f"Hello, {name}!", style={"font_size": 24}) ``` +The entry point `create_page()` is called internally by native templates to bootstrap your root component. You don't call it directly — just export your component and configure the entry point in `pythonnative.json`. + ## State and re-rendering -Call `self.set_state(key=value)` to update state. The framework automatically calls `render()` again and applies only the differences to the native views: +Use `pn.use_state(initial)` to create local component state. Call the setter to update — the framework automatically re-renders the component and applies only the differences to the native views: ```python -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 render(self): - return pn.Column( - pn.Text(f"Count: {self.state['count']}", font_size=24), - pn.Button("Increment", on_click=self.increment), - spacing=12, - ) +@pn.component +def CounterPage(): + count, set_count = pn.use_state(0) + + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button("Increment", on_click=lambda: set_count(count + 1)), + style={"spacing": 12}, + ) ``` -## Function components with hooks +## Composing components -For reusable UI pieces **with their own state**, use the `@pn.component` decorator and hooks: +Build complex UIs by composing smaller `@pn.component` functions. Each instance has **independent state**: ```python @pn.component -def counter(label: str = "Count", initial: int = 0) -> pn.Element: +def Counter(label: str = "Count", initial: int = 0): count, set_count = pn.use_state(initial) return pn.Column( - pn.Text(f"{label}: {count}", font_size=18), + pn.Text(f"{label}: {count}", style={"font_size": 18}), pn.Row( pn.Button("-", on_click=lambda: set_count(count - 1)), pn.Button("+", on_click=lambda: set_count(count + 1)), - spacing=8, + style={"spacing": 8}, ), - spacing=4, + style={"spacing": 4}, ) -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def render(self): - return pn.Column( - counter(label="Apples", initial=0), - counter(label="Oranges", initial=5), - spacing=16, - padding=16, - ) + +@pn.component +def MainPage(): + return pn.Column( + Counter(label="Apples", initial=0), + Counter(label="Oranges", initial=5), + style={"spacing": 16, "padding": 16}, + ) ``` -Each `counter` instance has **independent state** — changing one doesn't affect the other. +Changing one `Counter` doesn't affect the other — each has its own hook state. ### Available hooks @@ -144,6 +134,7 @@ Each `counter` instance has **independent state** — changing one doesn't affec - `use_callback(fn, deps)` — stable function references - `use_ref(initial)` — mutable ref that persists across renders - `use_context(context)` — read from a context provider +- `use_navigation()` — navigation handle for push/pop between screens ### Custom hooks @@ -164,16 +155,16 @@ Share values across the tree without prop drilling: ```python theme = pn.create_context({"primary": "#007AFF"}) -# In a page's render(): -pn.Provider(theme, {"primary": "#FF0000"}, - my_component() -) +@pn.component +def App(): + return pn.Provider(theme, {"primary": "#FF0000"}, + MyComponent() + ) -# In my_component: @pn.component -def my_component() -> pn.Element: +def MyComponent(): t = pn.use_context(theme) - return pn.Button("Click", color=t["primary"]) + return pn.Button("Click", style={"color": t["primary"]}) ``` ## Platform detection @@ -185,5 +176,3 @@ from pythonnative.utils import IS_ANDROID title = "Android App" if IS_ANDROID else "iOS App" ``` - -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/concepts/hooks.md b/docs/concepts/hooks.md index a52197a..1f23b7c 100644 --- a/docs/concepts/hooks.md +++ b/docs/concepts/hooks.md @@ -1,6 +1,6 @@ # Function Components and Hooks -PythonNative supports React-like function components with hooks for managing state, effects, memoisation, and context. This is the recommended way to build reusable UI pieces. +PythonNative uses React-like function components with hooks for managing state, effects, navigation, memoisation, and context. Function components decorated with `@pn.component` are the only way to build UI in PythonNative. ## Creating a function component @@ -10,20 +10,20 @@ Decorate a Python function with `@pn.component`: import pythonnative as pn @pn.component -def greeting(name: str = "World") -> pn.Element: - return pn.Text(f"Hello, {name}!", font_size=20) +def Greeting(name: str = "World"): + return pn.Text(f"Hello, {name}!", style={"font_size": 20}) ``` Use it like any other component: ```python -class MyPage(pn.Page): - def render(self): - return pn.Column( - greeting(name="Alice"), - greeting(name="Bob"), - spacing=12, - ) +@pn.component +def MyPage(): + return pn.Column( + Greeting(name="Alice"), + Greeting(name="Bob"), + style={"spacing": 12}, + ) ``` ## Hooks @@ -36,7 +36,7 @@ Local component state. Returns `(value, setter)`. ```python @pn.component -def counter(initial: int = 0) -> pn.Element: +def Counter(initial: int = 0): count, set_count = pn.use_state(initial) return pn.Column( @@ -64,7 +64,7 @@ Run side effects after render. The effect function may return a cleanup callable ```python @pn.component -def timer() -> pn.Element: +def Timer(): seconds, set_seconds = pn.use_state(0) def tick(): @@ -84,6 +84,38 @@ Dependency control: - `pn.use_effect(fn, [])` — run on mount only - `pn.use_effect(fn, [a, b])` — run when `a` or `b` change +### use_navigation + +Access the navigation stack from any component. Returns a `NavigationHandle` with `.push()`, `.pop()`, and `.get_args()`. + +```python +@pn.component +def HomeScreen(): + nav = pn.use_navigation() + + return pn.Column( + pn.Text("Home", style={"font_size": 24}), + pn.Button( + "Go to Details", + on_click=lambda: nav.push(DetailScreen, args={"id": 42}), + ), + style={"spacing": 12, "padding": 16}, + ) + +@pn.component +def DetailScreen(): + nav = pn.use_navigation() + item_id = nav.get_args().get("id", 0) + + return pn.Column( + pn.Text(f"Detail #{item_id}", style={"font_size": 20}), + pn.Button("Back", on_click=nav.pop), + style={"spacing": 12, "padding": 16}, + ) +``` + +See the [Navigation guide](../guides/navigation.md) for full details. + ### use_memo Memoise an expensive computation: @@ -123,17 +155,16 @@ color = theme["primary_color"] Share values through the component tree without passing props manually: ```python -# Create a context with a default value user_context = pn.create_context({"name": "Guest"}) -# Provide a value to descendants -pn.Provider(user_context, {"name": "Alice"}, - user_profile() -) +@pn.component +def App(): + return pn.Provider(user_context, {"name": "Alice"}, + UserProfile() + ) -# Consume in any descendant @pn.component -def user_profile() -> pn.Element: +def UserProfile(): user = pn.use_context(user_context) return pn.Text(f"Welcome, {user['name']}") ``` @@ -157,11 +188,11 @@ Use them in any component: ```python @pn.component -def settings() -> pn.Element: +def Settings(): dark_mode, toggle_dark = use_toggle(False) return pn.Column( - pn.Text("Settings", font_size=24, bold=True), + pn.Text("Settings", style={"font_size": 24, "bold": True}), pn.Row( pn.Text("Dark mode"), pn.Switch(value=dark_mode, on_change=lambda v: toggle_dark()), diff --git a/docs/examples.md b/docs/examples.md index 0f007dc..20112d5 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,49 +8,45 @@ A collection of examples showing PythonNative's declarative component model and 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, - ) +@pn.component +def Counter(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Increment", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) ``` ## Reusable components ```python -def labeled_input(label, placeholder=""): +import pythonnative as pn + + +@pn.component +def LabeledInput(label: str = "", placeholder: str = ""): return pn.Column( - pn.Text(label, font_size=14, bold=True), + pn.Text(label, style={"font_size": 14, "bold": True}), pn.TextInput(placeholder=placeholder), - spacing=4, + style={"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, - ) +@pn.component +def FormPage(): + return pn.ScrollView( + pn.Column( + pn.Text("Sign Up", style={"font_size": 24, "bold": True}), + LabeledInput(label="Name", placeholder="Enter your name"), + LabeledInput(label="Email", placeholder="you@example.com"), + pn.Button("Submit", on_click=lambda: print("submitted")), + style={"spacing": 12, "padding": 16}, ) + ) ``` See `examples/hello-world/` for a full multi-page demo with navigation. diff --git a/docs/examples/hello-world.md b/docs/examples/hello-world.md index 56f07ac..73fe9cc 100644 --- a/docs/examples/hello-world.md +++ b/docs/examples/hello-world.md @@ -1,26 +1,22 @@ # Hello World -Create a simple page with a counter that increments on tap. +Create a simple component with a counter that increments on tap. ```python import pythonnative as pn -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - 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, - ) +@pn.component +def App(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Tap me", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) ``` Run it: diff --git a/docs/getting-started.md b/docs/getting-started.md index 538f28e..f36d5c5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -24,27 +24,24 @@ A minimal `app/main_page.py` looks like: 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( - "Tap me", - on_click=lambda: self.set_state(count=self.state["count"] + 1), - ), - spacing=12, - padding=16, - ) +@pn.component +def App(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button( + "Tap me", + on_click=lambda: set_count(count + 1), + ), + style={"spacing": 12, "padding": 16}, + ) ``` Key ideas: -- **`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. +- **`@pn.component`** marks a function as a PythonNative component. The function returns an element tree describing the UI. PythonNative creates and updates native views automatically. +- **`pn.use_state(initial)`** creates local component state. Call the setter to update it — the UI re-renders automatically. +- **`style={...}`** passes visual and layout properties as a dict (or list of dicts) to any component. - Element functions like `pn.Text(...)`, `pn.Button(...)`, `pn.Column(...)` create lightweight descriptions, not native objects. ## Run on a platform diff --git a/docs/guides/android.md b/docs/guides/android.md index 5e939dd..23b3a6e 100644 --- a/docs/guides/android.md +++ b/docs/guides/android.md @@ -8,6 +8,10 @@ Basic steps to build and run an Android project generated by `pn`. No network is required for the template itself; the template zip is bundled with the package. +## Component model + +Your `app/` directory contains `@pn.component` function components. The native Android template uses `create_page()` internally to bootstrap your root component inside a `PageFragment`. You don't call `create_page()` directly — just export your component and configure the entry point in `pythonnative.json`. + ## Run ```bash diff --git a/docs/guides/ios.md b/docs/guides/ios.md index a0a400f..7409bc4 100644 --- a/docs/guides/ios.md +++ b/docs/guides/ios.md @@ -8,6 +8,10 @@ Basic steps to build and run an iOS project generated by `pn`. The default `ViewController.swift` initializes PythonKit, prints the Python version, and attempts to import `rubicon.objc` if present. +## Component model + +Your `app/` directory contains `@pn.component` function components. The native iOS template uses `create_page()` internally to bootstrap your root component inside a `ViewController`. You don't call `create_page()` directly — just export your component and configure the entry point in `pythonnative.json`. + ## Run / Prepare ```bash diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 6887360..58b22ef 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -1,56 +1,59 @@ # Navigation -This guide shows how to navigate between pages and pass data. +This guide shows how to navigate between screens and pass data using the `use_navigation()` hook. ## Push / Pop -Use `push` and `pop` on your `Page` to change screens. Pass a dotted path string or a class reference, with optional `args`. +Call `pn.use_navigation()` inside a `@pn.component` to get a `NavigationHandle`. Use `.push()` and `.pop()` to change screens, passing a component reference with optional `args`. ```python import pythonnative as pn - - -class MainPage(pn.Page): - 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"}, - ), +from app.second_page import SecondPage + + +@pn.component +def HomeScreen(): + nav = pn.use_navigation() + return pn.Column( + pn.Text("Home", style={"font_size": 24}), + pn.Button( + "Go next", + on_click=lambda: nav.push( + SecondPage, + args={"message": "Hello from Home"}, ), - spacing=12, - padding=16, - ) + ), + style={"spacing": 12, "padding": 16}, + ) ``` -On the target page, retrieve args with `self.get_args()`: +On the target screen, retrieve args with `nav.get_args()`: ```python -class SecondPage(pn.Page): - 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, - ) +@pn.component +def SecondPage(): + nav = pn.use_navigation() + message = nav.get_args().get("message", "Second Page") + return pn.Column( + pn.Text(message, style={"font_size": 20}), + pn.Button("Back", on_click=nav.pop), + style={"spacing": 12, "padding": 16}, + ) ``` +## NavigationHandle API + +`pn.use_navigation()` returns a `NavigationHandle` with: + +- **`.push(component, args=...)`** — navigate to a new screen. Pass a component reference (the `@pn.component` function itself), with an optional `args` dict. +- **`.pop()`** — go back to the previous screen. +- **`.get_args()`** — retrieve the args dict passed by the caller. + ## Lifecycle PythonNative forwards lifecycle events from the host: -- `on_create` — triggers the initial `render()` +- `on_create` — triggers the initial render - `on_start` - `on_resume` - `on_pause` @@ -60,8 +63,6 @@ PythonNative forwards lifecycle events from the host: - `on_save_instance_state` - `on_restore_instance_state` -Override any of these on your `Page` subclass to respond to lifecycle changes. - ## Notes - On Android, `push` navigates via `NavController` to a `PageFragment` and passes `page_path` and optional JSON `args`. @@ -70,13 +71,13 @@ Override any of these on your `Page` subclass to respond to lifecycle changes. ## Platform specifics ### iOS (UIViewController per page) -- Each PythonNative page is hosted by a Swift `ViewController` instance. -- Pages are pushed and popped on a root `UINavigationController`. -- Lifecycle is forwarded from Swift to the registered Python page instance. +- Each PythonNative screen is hosted by a Swift `ViewController` instance. +- Screens are pushed and popped on a root `UINavigationController`. +- Lifecycle is forwarded from Swift to the registered Python component. ### Android (single Activity, Fragment stack) - Single host `MainActivity` sets a `NavHostFragment` containing a navigation graph. -- Each PythonNative page is represented by a generic `PageFragment` which instantiates the Python page and attaches its root view. +- Each PythonNative screen is represented by a generic `PageFragment` which instantiates the Python component and attaches its root view. - `push`/`pop` delegate to `NavController` (via a small `Navigator` helper). - Arguments live in Fragment arguments and restore across configuration changes. diff --git a/docs/guides/styling.md b/docs/guides/styling.md index cf93f8f..1dd2026 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -1,15 +1,15 @@ # Styling -Style properties are passed as keyword arguments to element functions. PythonNative also provides a `StyleSheet` utility for creating reusable styles and a theming system via context. +Style properties are passed via the `style` prop as a dict (or list of dicts) to any element function. PythonNative also provides a `StyleSheet` utility for creating reusable styles and a theming system via context. ## Inline styles -Pass style props directly to components: +Pass a `style` dict to components: ```python -pn.Text("Hello", color="#FF3366", font_size=24, bold=True) -pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") -pn.Column(pn.Text("Content"), background_color="#FFF5F5F5") +pn.Text("Hello", style={"color": "#FF3366", "font_size": 24, "bold": True}) +pn.Button("Tap", style={"background_color": "#FF1E88E5", "color": "#FFFFFF"}) +pn.Column(pn.Text("Content"), style={"background_color": "#FFF5F5F5"}) ``` ## StyleSheet @@ -25,11 +25,10 @@ styles = pn.StyleSheet.create( container={"padding": 16, "spacing": 12, "alignment": "fill"}, ) -# Apply with dict unpacking -pn.Text("Welcome", **styles["title"]) +pn.Text("Welcome", style=styles["title"]) pn.Column( - pn.Text("Subtitle", **styles["subtitle"]), - **styles["container"], + pn.Text("Subtitle", style=styles["subtitle"]), + style=styles["container"], ) ``` @@ -44,6 +43,14 @@ merged = pn.StyleSheet.compose(base, highlight) # Result: {"font_size": 16, "color": "#FF0000", "bold": True} ``` +### Combining styles with a list + +You can also pass a list of dicts to `style`. They are merged left-to-right: + +```python +pn.Text("Highlighted", style=[base, highlight]) +``` + ### Flattening styles Flatten a style or list of styles into a single dict: @@ -55,30 +62,30 @@ pn.StyleSheet.flatten(None) # returns {} ## Colors -Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color props: +Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color properties inside `style`: ```python -pn.Text("Hello", color="#FF3366") -pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") +pn.Text("Hello", style={"color": "#FF3366"}) +pn.Button("Tap", style={"background_color": "#FF1E88E5", "color": "#FFFFFF"}) ``` ## Text styling -`Text` and `Button` accept `font_size`, `color`, `bold`, and `text_align`: +`Text` and `Button` accept `font_size`, `color`, `bold`, and `text_align` inside `style`: ```python -pn.Text("Title", font_size=24, bold=True, text_align="center") -pn.Text("Subtitle", font_size=14, color="#666666") +pn.Text("Title", style={"font_size": 24, "bold": True, "text_align": "center"}) +pn.Text("Subtitle", style={"font_size": 14, "color": "#666666"}) ``` ## Layout properties -All components support common layout properties: +All components support common layout properties inside `style`: ```python -pn.Text("Fixed size", width=200, height=50) -pn.View(child, flex=1, margin=8) -pn.Column(items, margin={"horizontal": 16, "vertical": 8}) +pn.Text("Fixed size", style={"width": 200, "height": 50}) +pn.View(child, style={"flex": 1, "margin": 8}) +pn.Column(items, style={"margin": {"horizontal": 16, "vertical": 8}}) ``` - `width`, `height` — fixed dimensions in dp (Android) / pt (iOS) @@ -98,25 +105,36 @@ pn.Column( pn.Text("Password"), pn.TextInput(placeholder="Enter password", secure=True), pn.Button("Login", on_click=handle_login), - spacing=8, - padding=16, - alignment="fill", + style={"spacing": 8, "padding": 16, "alignment": "fill"}, ) ``` -### Spacing +### Alignment properties -- `spacing=N` sets the gap between children in dp (Android) / points (iOS). +Column and Row support `align_items` and `justify_content` inside `style`: -### Padding +- **`align_items`** — cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"` +- **`justify_content`** — main-axis distribution: `"start"`, `"center"`, `"end"`, `"space_between"`, `"space_around"` +- **`alignment`** — shorthand for cross-axis alignment (same values as `align_items`) -- `padding=16` — all sides -- `padding={"horizontal": 12, "vertical": 8}` — per axis -- `padding={"left": 8, "top": 16, "right": 8, "bottom": 16}` — per side +```python +pn.Row( + pn.Text("Left"), + pn.Spacer(flex=1), + pn.Text("Right"), + style={"align_items": "center", "justify_content": "space_between", "padding": 16}, +) +``` -### Alignment +### Spacing + +- `spacing` sets the gap between children in dp (Android) / points (iOS). -Cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"`. +### Padding + +- `padding: 16` — all sides +- `padding: {"horizontal": 12, "vertical": 8}` — per axis +- `padding: {"left": 8, "top": 16, "right": 8, "bottom": 16}` — per side ## Theming @@ -126,19 +144,21 @@ PythonNative includes a built-in theme context with light and dark themes: import pythonnative as pn from pythonnative.style import DEFAULT_DARK_THEME + @pn.component -def themed_text(text: str = "") -> pn.Element: +def ThemedText(text: str = ""): theme = pn.use_context(pn.ThemeContext) - return pn.Text(text, color=theme["text_color"], font_size=theme["font_size"]) - -class MyPage(pn.Page): - def render(self): - return pn.Provider(pn.ThemeContext, DEFAULT_DARK_THEME, - pn.Column( - themed_text(text="Dark mode!"), - spacing=8, - ) + return pn.Text(text, style={"color": theme["text_color"], "font_size": theme["font_size"]}) + + +@pn.component +def DarkPage(): + return pn.Provider(pn.ThemeContext, DEFAULT_DARK_THEME, + pn.Column( + ThemedText(text="Dark mode!"), + style={"spacing": 8}, ) + ) ``` ### Theme properties @@ -163,7 +183,7 @@ pn.ScrollView( pn.Text("Item 1"), pn.Text("Item 2"), # ... many items - spacing=8, + style={"spacing": 8}, ) ) ``` diff --git a/docs/index.md b/docs/index.md index 96ac99d..fa81dfb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,16 +8,12 @@ PythonNative provides a Pythonic API for native UI components, a virtual view tr 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, - ) +@pn.component +def App(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + style={"spacing": 12, "padding": 16}, + ) ``` diff --git a/docs/meta/roadmap.md b/docs/meta/roadmap.md deleted file mode 100644 index 5cb3873..0000000 --- a/docs/meta/roadmap.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -title: Roadmap ---- - -# PythonNative Roadmap (v0.2.0 → v0.10.0) - -This roadmap focuses on transforming PythonNative into a workable, React Native / Expo-like framework from a developer-experience and simplicity standpoint. Releases are incremental and designed to be shippable, with DX-first improvements balanced with platform capability. - -Assumptions -- Scope: Android (Chaquopy/Java bridge) and iOS (Rubicon-ObjC), Python 3.9–3.12 -- Goals: Zero-config templates, one CLI, fast iteration loop, portable component API, and a curated subset of native capabilities with room to expand. - -Guiding Principles -- Single CLI for init/run/build/clean. -- Convention over configuration: opinionated project layout (`app/`, `pythonnative.json`, `requirements.txt`). -- Hot reload (where feasible) and rapid feedback. -- Stable component API; platform shims kept internal. -- Progressive enhancement: start with a minimal but complete loop, add breadth and depth over time. - -Milestones - -0.2.0 — Foundations: DX Baseline and Templates -- CLI - - pn init: generate project with `app/`, `pythonnative.json`, `requirements.txt`, `.gitignore`. - - pn run android|ios: scaffold template apps (from bundled zips), copy `app/`, install requirements, build+install/run. - - pn clean: remove `build/` safely. -- Templates - - Bundle `templates/android_template.zip` and `templates/ios_template.zip` into package to avoid network. - - Ensure Android template uses Kotlin+Chaquopy; iOS template uses Swift+PythonKit+Rubicon. -- Core APIs - - Stabilize `Page`, `StackView`, `Label`, `Button`, `ImageView`, `TextField`, `TextView`, `Switch`, `ProgressView`, `ActivityIndicatorView`, `WebView` with consistent ctor patterns. - - Add `utils.IS_ANDROID` fallback detection improvements. -- Docs - - Getting Started (one page), Hello World, Concepts: Components, Guides: Android/iOS quickstart. - - Roadmap (this page). Contributing. - -Success Criteria -- New user can: pn init → pn run android → sees Hello World UI; same for iOS. - -0.3.0 — Navigation and Lifecycle -- API - - Page navigation abstraction with push/pop (Android: Activity/Fragment shim, iOS: UINavigationController). - - Lifecycle events stabilized and wired from host to Python (on_create/start/resume/pause/stop/destroy). -- Templates - - Two-screen sample demonstrating navigation and parameter passing. -- Docs - - Navigation guide with examples. - -Success Criteria -- Sample app navigates between two pages on both platforms using the same Python API. - -0.4.0 — Layout and Styling Pass -- API - - Improve `StackView` configuration: axis, spacing, alignment; add `ScrollView` wrapping helpers. - - Add lightweight style API (padding/margin where supported, background color, text color/size for text components). -- DX - - Component property setters return self for fluent configuration where ergonomic. -- Docs - - Styling guide and component property reference. - -Success Criteria -- Build complex vertical forms and simple horizontal layouts with predictable results on both platforms. - -0.5.0 — Developer Experience: Live Reload Loop -- DX - - pn dev android|ios: dev server watching `app/` with file-sync into running app. - - Implement soft-reload: trigger Python module reload and page re-render without full app restart where possible. - - Fallback to fast reinstall when soft-reload not possible. -- Templates - - Integrate dev menu gesture (e.g., triple-tap or shake) to trigger reload. -- Docs - - Dev workflow: live reload expectations and caveats. - -Success Criteria -- Edit Python in `app/`, trigger near-instant UI update on device/emulator. - -0.6.0 — Forms and Lists -- API - - `ListView` cross-platform wrapper with simple adapter API (Python callback to render rows, handle click). - - Input controls: `DatePicker`, `TimePicker`, basic validation utilities. - - Add `PickerView` parity or mark as experimental if iOS-first. -- Performance - - Ensure cell reuse on Android/iOS to handle 1k-row lists smoothly. -- Docs - - Lists guide, forms guide with validation patterns. - -Success Criteria -- Build a basic todo app with a scrollable list and an add-item form. - -0.7.0 — Networking, Storage, and Permissions Primitives -- API - - Simple `fetch`-like helper (thin wrapper over requests/URLSession with threading off main UI thread). - - Key-value storage abstraction (Android SharedPreferences / iOS UserDefaults). - - Permission prompts helper (camera, location, notifications) with consistent API returning futures/promises. -- DX - - Background threading utilities for long-running tasks with callback to main thread. -- Docs - - Data fetching, local storage, permissions cookbook. - -Success Criteria -- Build a data-driven screen that fetches remote JSON, caches a token, and requests permission. - -0.8.0 — Theming and Material Components (Android parity), iOS polish -- API - - Theme object for colors/typography; propagate defaults to components. - - Material variants: MaterialButton, MaterialProgress, MaterialSearchBar, MaterialSwitch stabilized. - - iOS polishing: ensure UIKit equivalents’ look-and-feel is sensible by default. -- DX - - Dark/light theme toggling hook. -- Docs - - Theming guide with examples. - -Success Criteria -- Switch between light/dark themes and see consistent component styling across screens. - -0.9.0 — Packaging, Testing, and CI -- CLI - - pn build android|ios: produce signed (debug) APK/IPA or x archive guidance; integrate keystore setup helper for Android. - - pn test: run Python unit tests; document UI test strategy (manual/host-level instrumentation later). -- Tooling - - Add ruff/black/mypy default config and `pn fmt`, `pn lint` wrappers. -- Docs - - Release checklist; testing guide. - -Success Criteria -- Produce installable builds via pn build; run unit tests with a single command. - -0.10.0 — Plugin System (Early) and Project Orchestration -- Plugins - - Define `pythonnative.plugins` entry point allowing add-ons (e.g., Camera, Filesystem) to register platform shims. - - pn plugin add : scaffold plugin structure and install dependency. -- Orchestration - - Config-driven `pythonnative.json`: targets, app id/name, icons/splash, permissions, minSDK/iOS version. - - Asset pipeline: copy assets to correct platform locations. -- Docs - - Plugin authoring guide; configuration reference. - -Success Criteria -- Install a community plugin and use it from Python without touching native code. - -Backlog and Stretch (post-0.10) -- Cross-platform navigation stack parity (Fragments vs Activities, or single-activity multi-fragment on Android). -- Advanced layout (ConstraintLayout/AutoLayout helpers) with declarative constraints. -- Gesture/touch handling unification, animations/transitions. -- Expo-like over-the-air updates pipeline. -- Desktop/web exploration via PyObjC/Qt bridges (research). - -Breaking Changes Policy -- Pre-1.0: Minor versions may include breaking changes; provide migration notes and deprecation warnings one release ahead when possible. - -Tracking and Releases -- Each milestone will have a GitHub project board and labeled issues. -- Changelogs maintained per release; upgrade guides in docs. diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index dbf2fe8..d524568 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,5 +1,3 @@ -from typing import Any - import emoji import pythonnative as pn @@ -11,7 +9,7 @@ title={"font_size": 24, "bold": True}, subtitle={"font_size": 16, "color": "#666666"}, medal={"font_size": 32}, - section={"spacing": 12, "padding": 16, "alignment": "fill"}, + section={"spacing": 12, "padding": 16, "align_items": "stretch"}, ) @@ -22,29 +20,27 @@ def counter_badge(initial: int = 0) -> pn.Element: medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") return pn.Column( - pn.Text(f"Tapped {count} times", **styles["subtitle"]), - pn.Text(medal, **styles["medal"]), + pn.Text(f"Tapped {count} times", style=styles["subtitle"]), + pn.Text(medal, style=styles["medal"]), pn.Button("Tap me", on_click=lambda: set_count(count + 1)), - spacing=4, + style={"spacing": 4}, ) -class MainPage(pn.Page): - def __init__(self, native_instance: Any) -> None: - super().__init__(native_instance) - - def render(self) -> pn.Element: - return pn.ScrollView( - pn.Column( - pn.Text("Hello from PythonNative Demo!", **styles["title"]), - counter_badge(), - pn.Button( - "Go to Second Page", - on_click=lambda: self.push( - "app.second_page.SecondPage", - args={"message": "Greetings from MainPage"}, - ), +@pn.component +def MainPage() -> pn.Element: + nav = pn.use_navigation() + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative Demo!", style=styles["title"]), + counter_badge(), + pn.Button( + "Go to Second Page", + on_click=lambda: nav.push( + "app.second_page.SecondPage", + args={"message": "Greetings from MainPage"}, ), - **styles["section"], - ) + ), + style=styles["section"], ) + ) diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index bd8d2f1..8783d41 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -1,24 +1,18 @@ -from typing import Any - import pythonnative as pn -class SecondPage(pn.Page): - def __init__(self, native_instance: Any) -> None: - super().__init__(native_instance) - - 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", - ) +@pn.component +def SecondPage() -> pn.Element: + nav = pn.use_navigation() + message = nav.get_args().get("message", "Second Page") + return pn.ScrollView( + pn.Column( + pn.Text(message, style={"font_size": 20}), + pn.Button( + "Go to Third Page", + on_click=lambda: nav.push("app.third_page.ThirdPage"), + ), + pn.Button("Back", on_click=nav.pop), + style={"spacing": 12, "padding": 16, "align_items": "stretch"}, ) + ) diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index efd3d5c..3ebc174 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -1,18 +1,12 @@ -from typing import Any - import pythonnative as pn -class ThirdPage(pn.Page): - def __init__(self, native_instance: Any) -> None: - super().__init__(native_instance) - - 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", - ) +@pn.component +def ThirdPage() -> pn.Element: + nav = pn.use_navigation() + return pn.Column( + pn.Text("Third Page", style={"font_size": 24, "bold": True}), + pn.Text("You navigated two levels deep."), + pn.Button("Back to Second", on_click=nav.pop), + style={"spacing": 12, "padding": 16, "align_items": "stretch"}, + ) diff --git a/mkdocs.yml b/mkdocs.yml index 308e6c7..2ea4223 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,7 +30,6 @@ nav: - Package: api/pythonnative.md - Component Properties: api/component-properties.md - Meta: - - Roadmap: meta/roadmap.md - Contributing: meta/contributing.md plugins: - search diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index f3e4be4..59e6892 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -5,25 +5,13 @@ import pythonnative as pn @pn.component - def counter(initial=0): - count, set_count = pn.use_state(initial) + def App(): + count, set_count = pn.use_state(0) return pn.Column( - pn.Text(f"Count: {count}", font_size=24), + pn.Text(f"Count: {count}", style={"font_size": 24}), pn.Button("+", on_click=lambda: set_count(count + 1)), - spacing=12, + style={"spacing": 12}, ) - - class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def render(self): - return pn.Column( - counter(initial=0), - counter(initial=10), - spacing=16, - padding=16, - ) """ __version__ = "0.6.0" @@ -57,10 +45,11 @@ def render(self): use_context, use_effect, use_memo, + use_navigation, use_ref, use_state, ) -from .page import Page +from .page import create_page from .style import StyleSheet, ThemeContext __all__ = [ @@ -85,7 +74,7 @@ def render(self): "WebView", # Core "Element", - "Page", + "create_page", # Hooks "component", "create_context", @@ -93,6 +82,7 @@ def render(self): "use_context", "use_effect", "use_memo", + "use_navigation", "use_ref", "use_state", "Provider", diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 18d1bf8..2bade03 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -48,25 +48,17 @@ def init_project(args: argparse.Namespace) -> None: f.write("""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.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", - ) +@pn.component +def MainPage(): + count, set_count = pn.use_state(0) + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative!", style={"font_size": 24, "bold": True}), + pn.Text(f"Tapped {count} times"), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + style={"spacing": 12, "padding": 16, "align_items": "stretch"}, ) + ) """) # Create config diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py index 08bb221..820f001 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -4,52 +4,23 @@ These are pure data — no native views are created until the reconciler mounts the element tree. -Layout properties (``width``, ``height``, ``flex``, ``margin``, -``min_width``, ``max_width``, ``min_height``, ``max_height``, -``align_self``) are supported by all components. -""" - -from typing import Any, Callable, Dict, List, Optional, Union - -from .element import Element +All visual and layout properties are passed via the ``style`` parameter, +which accepts a dict or a list of dicts (later entries override earlier). -# ====================================================================== -# Shared helpers -# ====================================================================== +Layout properties supported by all components:: -PaddingValue = Union[int, float, Dict[str, Union[int, float]]] -MarginValue = Union[int, float, Dict[str, Union[int, float]]] + width, height, flex, margin, min_width, max_width, min_height, + max_height, align_self +Container-specific layout properties (Column / Row):: -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} - + spacing, padding, align_items, justify_content +""" -def _layout_props( - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, -) -> Dict[str, Any]: - """Collect common layout props into a dict (excluding Nones).""" - return _filter_none( - width=width, - height=height, - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) +from typing import Any, Callable, Dict, List, Optional +from .element import Element +from .style import StyleValue, resolve_style # ====================================================================== # Leaf components @@ -59,46 +30,16 @@ def _layout_props( 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, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, + style: StyleValue = 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, - ) - props.update( - _layout_props( - width=width, - height=height, - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) - ) + """Display text. + + Style properties: ``font_size``, ``color``, ``bold``, ``text_align``, + ``background_color``, ``max_lines``, plus common layout props. + """ + props: Dict[str, Any] = {"text": text} + props.update(resolve_style(style)) return Element("Text", props, [], key=key) @@ -106,46 +47,21 @@ 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, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Create a tappable button.""" + """Create a tappable button. + + Style properties: ``color``, ``background_color``, ``font_size``, + plus common layout props. + """ props: Dict[str, Any] = {"title": title} if on_click is not None: props["on_click"] = on_click - if 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 - props.update( - _layout_props( - width=width, - height=height, - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) - ) + props.update(resolve_style(style)) return Element("Button", props, [], key=key) @@ -155,21 +71,14 @@ def TextInput( 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, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Create a single-line text entry field.""" + """Create a single-line text entry field. + + Style properties: ``font_size``, ``color``, ``background_color``, + plus common layout props. + """ props: Dict[str, Any] = {"value": value} if placeholder: props["placeholder"] = placeholder @@ -177,63 +86,27 @@ def TextInput( 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 - props.update( - _layout_props( - width=width, - height=height, - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) - ) + props.update(resolve_style(style)) 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, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, + style: StyleValue = 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, - ) - props.update( - _layout_props( - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) - ) + """Display an image from a resource path or URL. + + Style properties: ``background_color``, plus common layout props. + """ + props: Dict[str, Any] = {} + if source: + props["source"] = source + if scale_type is not None: + props["scale_type"] = scale_type + props.update(resolve_style(style)) return Element("Image", props, [], key=key) @@ -241,68 +114,52 @@ def Switch( *, value: bool = False, on_change: Optional[Callable[[bool], None]] = None, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Create a toggle switch.""" props: Dict[str, Any] = {"value": value} if on_change is not None: props["on_change"] = on_change - props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) + props.update(resolve_style(style)) return Element("Switch", props, [], key=key) def ProgressBar( *, value: float = 0.0, - background_color: Optional[str] = None, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Show determinate progress (0.0 – 1.0).""" - props = _filter_none(value=value, background_color=background_color) - props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) + props: Dict[str, Any] = {"value": value} + props.update(resolve_style(style)) return Element("ProgressBar", props, [], key=key) def ActivityIndicator( *, animating: bool = True, - width: Optional[float] = None, - height: Optional[float] = None, - margin: Optional[MarginValue] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Show an indeterminate loading spinner.""" props: Dict[str, Any] = {"animating": animating} - props.update(_layout_props(width=width, height=height, margin=margin, align_self=align_self)) + props.update(resolve_style(style)) return Element("ActivityIndicator", props, [], key=key) def WebView( *, url: str = "", - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Embed web content.""" props: Dict[str, Any] = {} if url: props["url"] = url - props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) + props.update(resolve_style(style)) return Element("WebView", props, [], key=key) @@ -312,11 +169,36 @@ def Spacer( flex: Optional[float] = None, key: Optional[str] = None, ) -> Element: - """Insert empty space with an optional fixed size.""" - props = _filter_none(size=size, flex=flex) + """Insert empty space with an optional fixed size or flex weight.""" + props: Dict[str, Any] = {} + if size is not None: + props["size"] = size + if flex is not None: + props["flex"] = flex return Element("Spacer", props, [], key=key) +def Slider( + *, + value: float = 0.0, + min_value: float = 0.0, + max_value: float = 1.0, + on_change: Optional[Callable[[float], None]] = None, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Continuous value slider.""" + props: Dict[str, Any] = { + "value": value, + "min_value": min_value, + "max_value": max_value, + } + if on_change is not None: + props["on_change"] = on_change + props.update(resolve_style(style)) + return Element("Slider", props, [], key=key) + + # ====================================================================== # Container components # ====================================================================== @@ -324,146 +206,82 @@ def Spacer( def Column( *children: Element, - spacing: float = 0, - padding: Optional[PaddingValue] = None, - alignment: Optional[str] = None, - background_color: Optional[str] = None, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Arrange children vertically.""" - props = _filter_none( - spacing=spacing or None, - padding=padding, - alignment=alignment, - background_color=background_color, - ) - props.update( - _layout_props( - width=width, - height=height, - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) - ) + """Arrange children vertically. + + Style properties: ``spacing``, ``padding``, ``align_items``, + ``justify_content``, ``background_color``, plus common layout props. + + ``align_items`` controls cross-axis (horizontal) alignment: + ``"stretch"`` (default), ``"flex_start"``/``"leading"``, + ``"center"``, ``"flex_end"``/``"trailing"``. + + ``justify_content`` controls main-axis (vertical) distribution: + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"``. + """ + props: Dict[str, Any] = {} + props.update(resolve_style(style)) 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, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Arrange children horizontally.""" - props = _filter_none( - spacing=spacing or None, - padding=padding, - alignment=alignment, - background_color=background_color, - ) - props.update( - _layout_props( - width=width, - height=height, - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) - ) + """Arrange children horizontally. + + Style properties: ``spacing``, ``padding``, ``align_items``, + ``justify_content``, ``background_color``, plus common layout props. + + ``align_items`` controls cross-axis (vertical) alignment: + ``"stretch"`` (default), ``"flex_start"``/``"top"``, + ``"center"``, ``"flex_end"``/``"bottom"``. + + ``justify_content`` controls main-axis (horizontal) distribution: + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"``. + """ + props: Dict[str, Any] = {} + props.update(resolve_style(style)) return Element("Row", props, list(children), key=key) def ScrollView( child: Optional[Element] = None, *, - background_color: Optional[str] = None, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Wrap a single child in a scrollable container.""" children = [child] if child is not None else [] - props = _filter_none(background_color=background_color) - props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) + props: Dict[str, Any] = {} + props.update(resolve_style(style)) return Element("ScrollView", props, children, key=key) def View( *children: Element, - background_color: Optional[str] = None, - padding: Optional[PaddingValue] = None, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - min_width: Optional[float] = None, - max_width: Optional[float] = None, - min_height: Optional[float] = None, - max_height: Optional[float] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Generic container view (``UIView`` / ``android.view.View``).""" - props = _filter_none( - background_color=background_color, - padding=padding, - ) - props.update( - _layout_props( - width=width, - height=height, - flex=flex, - margin=margin, - min_width=min_width, - max_width=max_width, - min_height=min_height, - max_height=max_height, - align_self=align_self, - ) - ) + props: Dict[str, Any] = {} + props.update(resolve_style(style)) return Element("View", props, list(children), key=key) def SafeAreaView( *children: Element, - background_color: Optional[str] = None, - padding: Optional[PaddingValue] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Container that respects safe area insets (notch, status bar).""" - props = _filter_none(background_color=background_color, padding=padding) + props: Dict[str, Any] = {} + props.update(resolve_style(style)) return Element("SafeAreaView", props, list(children), key=key) @@ -472,7 +290,7 @@ def Modal( visible: bool = False, on_dismiss: Optional[Callable[[], None]] = None, title: Optional[str] = None, - background_color: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Overlay modal dialog. @@ -484,34 +302,10 @@ def Modal( props["on_dismiss"] = on_dismiss if title is not None: props["title"] = title - if background_color is not None: - props["background_color"] = background_color + props.update(resolve_style(style)) return Element("Modal", props, list(children), key=key) -def Slider( - *, - value: float = 0.0, - min_value: float = 0.0, - max_value: float = 1.0, - on_change: Optional[Callable[[float], None]] = None, - width: Optional[float] = None, - margin: Optional[MarginValue] = None, - align_self: Optional[str] = None, - key: Optional[str] = None, -) -> Element: - """Continuous value slider.""" - props: Dict[str, Any] = { - "value": value, - "min_value": min_value, - "max_value": max_value, - } - if on_change is not None: - props["on_change"] = on_change - props.update(_layout_props(width=width, margin=margin, align_self=align_self)) - return Element("Slider", props, [], key=key) - - def Pressable( child: Optional[Element] = None, *, @@ -535,20 +329,14 @@ def FlatList( render_item: Optional[Callable[[Any, int], Element]] = None, key_extractor: Optional[Callable[[Any, int], str]] = None, separator_height: float = 0, - background_color: Optional[str] = None, - width: Optional[float] = None, - height: Optional[float] = None, - flex: Optional[float] = None, - margin: Optional[MarginValue] = None, - align_self: Optional[str] = None, + style: StyleValue = None, key: Optional[str] = None, ) -> Element: """Scrollable list that renders items from *data* using *render_item*. Each item is rendered by calling ``render_item(item, index)``. If ``key_extractor`` is provided, it is called as ``key_extractor(item, index)`` - to produce a stable key for each child element. This enables the - reconciler to preserve widget state across data changes. + to produce a stable key for each child element. """ items: List[Element] = [] for i, item in enumerate(data or []): @@ -557,7 +345,7 @@ def FlatList( el = Element(el.type, el.props, el.children, key=key_extractor(item, i)) items.append(el) - inner = Column(*items, spacing=separator_height) - sv_props = _filter_none(background_color=background_color) - sv_props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) + inner = Column(*items, style={"spacing": separator_height} if separator_height else None) + sv_props: Dict[str, Any] = {} + sv_props.update(resolve_style(style)) return Element("ScrollView", sv_props, [inner], key=key) diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py index 79ff061..4d7b122 100644 --- a/src/pythonnative/hooks.py +++ b/src/pythonnative/hooks.py @@ -1,7 +1,8 @@ """Hook primitives for function components. Provides React-like hooks for managing state, effects, memoisation, -and context within function components decorated with :func:`component`. +context, and navigation within function components decorated with +:func:`component`. Usage:: @@ -18,7 +19,7 @@ def counter(initial=0): import inspect import threading -from typing import Any, Callable, List, Optional, Tuple, TypeVar +from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar from .element import Element @@ -246,6 +247,52 @@ def Provider(context: Context, value: Any, child: Element) -> Element: return Element("__Provider__", {"__context__": context, "__value__": value}, [child]) +# ====================================================================== +# Navigation +# ====================================================================== + +_NavigationContext: Context = create_context(None) + + +class NavigationHandle: + """Object returned by :func:`use_navigation` providing push/pop/get_args. + + Navigates by component reference rather than string path, e.g.:: + + nav = pn.use_navigation() + nav.push(DetailScreen, args={"id": 42}) + """ + + def __init__(self, host: Any) -> None: + self._host = host + + def push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: + """Navigate forward to *page* (a ``@component`` function or class).""" + self._host._push(page, args) + + def pop(self) -> None: + """Navigate back to the previous screen.""" + self._host._pop() + + def get_args(self) -> Dict[str, Any]: + """Return arguments passed from the previous screen.""" + return self._host._get_nav_args() + + +def use_navigation() -> NavigationHandle: + """Return a :class:`NavigationHandle` for the current screen. + + Must be called inside a ``@component`` function rendered by PythonNative. + """ + handle = use_context(_NavigationContext) + if handle is None: + raise RuntimeError( + "use_navigation() called outside a PythonNative page. " + "Ensure your component is rendered via create_page()." + ) + return handle + + # ====================================================================== # @component decorator # ====================================================================== diff --git a/src/pythonnative/native_views.py b/src/pythonnative/native_views.py index ff6435f..0d75e61 100644 --- a/src/pythonnative/native_views.py +++ b/src/pythonnative/native_views.py @@ -277,6 +277,7 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: _apply_layout(native_view, changed) def _apply(self, ll: Any, props: Dict[str, Any]) -> None: + Gravity = jclass("android.view.Gravity") if "spacing" in props and props["spacing"]: px = _dp(float(props["spacing"])) GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") @@ -288,17 +289,31 @@ def _apply(self, ll: Any, props: Dict[str, Any]) -> None: 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 = { + gravity = 0 + ai = props.get("align_items") or props.get("alignment") + if ai: + cross_map = { + "stretch": Gravity.FILL_HORIZONTAL, "fill": Gravity.FILL_HORIZONTAL, - "center": Gravity.CENTER_HORIZONTAL, + "flex_start": Gravity.START, "leading": Gravity.START, "start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, "trailing": Gravity.END, "end": Gravity.END, } - ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_HORIZONTAL)) + gravity |= cross_map.get(ai, 0) + jc = props.get("justify_content") + if jc: + main_map = { + "flex_start": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, + } + gravity |= main_map.get(jc, 0) + if gravity: + ll.setGravity(gravity) if "background_color" in props and props["background_color"] is not None: ll.setBackgroundColor(parse_color_int(props["background_color"])) @@ -326,6 +341,7 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: _apply_layout(native_view, changed) def _apply(self, ll: Any, props: Dict[str, Any]) -> None: + Gravity = jclass("android.view.Gravity") if "spacing" in props and props["spacing"]: px = _dp(float(props["spacing"])) GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") @@ -337,15 +353,29 @@ def _apply(self, ll: Any, props: Dict[str, Any]) -> None: 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 = { + gravity = 0 + ai = props.get("align_items") or props.get("alignment") + if ai: + cross_map = { + "stretch": Gravity.FILL_VERTICAL, "fill": Gravity.FILL_VERTICAL, - "center": Gravity.CENTER_VERTICAL, + "flex_start": Gravity.TOP, "top": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, "bottom": Gravity.BOTTOM, } - ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_VERTICAL)) + gravity |= cross_map.get(ai, 0) + jc = props.get("justify_content") + if jc: + main_map = { + "flex_start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, + } + gravity |= main_map.get(jc, 0) + if gravity: + ll.setGravity(gravity) if "background_color" in props and props["background_color"] is not None: ll.setBackgroundColor(parse_color_int(props["background_color"])) @@ -910,9 +940,29 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: 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)) + ai = props.get("align_items") or props.get("alignment") + if ai: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "leading": 1, + "center": 3, + "flex_end": 4, + "trailing": 4, + } + sv.setAlignment_(alignment_map.get(ai, 0)) + jc = props.get("justify_content") + if jc: + distribution_map = { + "flex_start": 0, + "center": 0, + "flex_end": 0, + "space_between": 3, + "space_around": 4, + "space_evenly": 4, + } + sv.setDistribution_(distribution_map.get(jc, 0)) if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor_(_uicolor(props["background_color"])) if "padding" in props: @@ -950,9 +1000,29 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: 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)) + ai = props.get("align_items") or props.get("alignment") + if ai: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "top": 1, + "center": 3, + "flex_end": 4, + "bottom": 4, + } + sv.setAlignment_(alignment_map.get(ai, 0)) + jc = props.get("justify_content") + if jc: + distribution_map = { + "flex_start": 0, + "center": 0, + "flex_end": 0, + "space_between": 3, + "space_around": 4, + "space_evenly": 4, + } + sv.setDistribution_(distribution_map.get(jc, 0)) if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor_(_uicolor(props["background_color"])) diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index 942db1d..f46d9e6 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -1,161 +1,107 @@ -"""Page — the root component that bridges native lifecycle and declarative UI. +"""Page host — the bridge between native lifecycle and function components. -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. +Users no longer subclass ``Page``. Instead they write ``@component`` +functions and the native template calls :func:`create_page` to obtain +an :class:`_AppHost` that manages the reconciler and lifecycle. -Usage:: +Usage (user code):: 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, - ) + @pn.component + def MainPage(): + count, set_count = pn.use_state(0) + return pn.Column( + pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + style={"spacing": 12, "padding": 16}, + ) + +The native template calls:: + + host = pythonnative.page.create_page("app.main_page.MainPage", native_instance) + host.on_create() """ +import importlib import json -from abc import ABC, abstractmethod -from typing import Any, Optional, Union +from typing import Any, Dict, Optional from .utils import IS_ANDROID, set_android_context # ====================================================================== -# Base class (platform-independent) +# Component path resolution # ====================================================================== -class PageBase(ABC): - """Abstract base defining the Page interface.""" - - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - 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.""" - - def on_create(self) -> None: - """Called when the page is first created. Triggers initial render.""" - - def on_start(self) -> None: - pass - - 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 - - @abstractmethod - def set_args(self, args: Optional[dict]) -> None: - pass - - @abstractmethod - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - pass - - @abstractmethod - def pop(self) -> None: - pass +def _resolve_component_path(page_ref: Any) -> str: + """Resolve a component function to a ``module.name`` path string.""" + if isinstance(page_ref, str): + return page_ref + func = getattr(page_ref, "__wrapped__", page_ref) + module = getattr(func, "__module__", None) + name = getattr(func, "__name__", None) + if module and name: + return f"{module}.{name}" + raise ValueError(f"Cannot resolve component path for {page_ref!r}") - def get_args(self) -> dict: - """Return navigation arguments (empty dict if none).""" - return getattr(self, "_args", {}) - def navigate_to(self, page: Any) -> None: - self.push(page) +def _import_component(component_path: str) -> Any: + """Import and return the component function from a dotted path.""" + module_path, component_name = component_path.rsplit(".", 1) + module = importlib.import_module(module_path) + return getattr(module, component_name) # ====================================================================== -# Shared declarative rendering helpers +# Shared 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 _init_host_common(host: Any) -> None: + host._args = {} + host._reconciler = None + host._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: +def _on_create(host: Any) -> None: + from .hooks import NavigationHandle, Provider, _NavigationContext from .native_views import get_registry from .reconciler import Reconciler - page._reconciler = Reconciler(get_registry()) - page._reconciler._page_re_render = lambda: _re_render(page) - element = page.render() - page._root_native_view = page._reconciler.mount(element) - page._attach_root(page._root_native_view) + host._reconciler = Reconciler(get_registry()) + host._reconciler._page_re_render = lambda: _re_render(host) + nav_handle = NavigationHandle(host) + app_element = host._component() + provider_element = Provider(_NavigationContext, nav_handle, app_element) -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) + host._root_native_view = host._reconciler.mount(provider_element) + host._attach_root(host._root_native_view) -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 _re_render(host: Any) -> None: + from .hooks import NavigationHandle, Provider, _NavigationContext + + nav_handle = NavigationHandle(host) + app_element = host._component() + provider_element = Provider(_NavigationContext, nav_handle, app_element) + new_root = host._reconciler.reconcile(provider_element) + if new_root is not host._root_native_view: + host._detach_root(host._root_native_view) + host._root_native_view = new_root + host._attach_root(new_root) -def _set_args(page: Any, args: Optional[dict]) -> None: + +def _set_args(host: Any, args: Any) -> None: if isinstance(args, str): try: - page._args = json.loads(args) or {} + host._args = json.loads(args) or {} except Exception: - page._args = {} + host._args = {} return - page._args = args or {} + host._args = args if isinstance(args, dict) else {} # ====================================================================== @@ -165,21 +111,14 @@ def _set_args(page: Any, args: Optional[dict]) -> None: if IS_ANDROID: from java import jclass - class Page(PageBase): - """Android Page backed by an Activity and Fragment navigation.""" + class _AppHost: + """Android host backed by an Activity and Fragment navigation.""" - def __init__(self, native_instance: Any) -> None: - super().__init__() - self.native_class = jclass("android.app.Activity") + def __init__(self, native_instance: Any, component_func: Any) -> None: self.native_instance = native_instance + self._component = component_func set_android_context(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) + _init_host_common(self) def on_create(self) -> None: _on_create(self) @@ -208,16 +147,19 @@ def on_save_instance_state(self) -> None: def on_restore_instance_state(self) -> None: pass - def set_args(self, args: Optional[dict]) -> None: + def set_args(self, args: Any) -> None: _set_args(self, args) - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - page_path = _resolve_page_path(page) + def _get_nav_args(self) -> Dict[str, Any]: + return self._args + + def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: + page_path = _resolve_component_path(page) 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: + def _pop(self) -> None: try: Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") Navigator.pop(self.native_instance) @@ -265,10 +207,10 @@ def _detach_root(self, native_view: Any) -> None: _IOS_PAGE_REGISTRY: _Dict[int, Any] = {} - def _ios_register_page(vc_instance: Any, page_obj: Any) -> None: + def _ios_register_page(vc_instance: Any, host_obj: Any) -> None: try: ptr = int(vc_instance.ptr) - _IOS_PAGE_REGISTRY[ptr] = page_obj + _IOS_PAGE_REGISTRY[ptr] = host_obj except Exception: pass @@ -280,38 +222,31 @@ 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.""" - page = _IOS_PAGE_REGISTRY.get(int(native_addr)) - if page is None: + """Forward a lifecycle event from Swift ViewController to the registered host.""" + host = _IOS_PAGE_REGISTRY.get(int(native_addr)) + if host is None: return - handler = getattr(page, event, None) + handler = getattr(host, event, None) if handler: handler() if _rubicon_available: - class Page(PageBase): - """iOS Page backed by a UIViewController.""" + class _AppHost: + """iOS host backed by a UIViewController.""" - def __init__(self, native_instance: Any) -> None: - super().__init__() - self.native_class = ObjCClass("UIViewController") + def __init__(self, native_instance: Any, component_func: Any) -> None: if isinstance(native_instance, int): try: native_instance = ObjCInstance(native_instance) except Exception: native_instance = None self.native_instance = native_instance - _init_page_common(self) + self._component = component_func + _init_host_common(self) if self.native_instance is not None: _ios_register_page(self.native_instance, 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) @@ -340,11 +275,14 @@ def on_save_instance_state(self) -> None: def on_restore_instance_state(self) -> None: pass - def set_args(self, args: Optional[dict]) -> None: + def set_args(self, args: Any) -> None: _set_args(self, args) - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - page_path = _resolve_page_path(page) + def _get_nav_args(self) -> Dict[str, Any]: + return self._args + + def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: + page_path = _resolve_component_path(page) ViewController = None try: ViewController = ObjCClass("ViewController") @@ -373,11 +311,11 @@ def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: nav = getattr(self.native_instance, "navigationController", None) if nav is None: raise RuntimeError( - "No UINavigationController available; ensure template embeds root in navigation controller" + "No UINavigationController available; " "ensure template embeds root in navigation controller" ) nav.pushViewController_animated_(next_vc, True) - def pop(self) -> None: + def _pop(self) -> None: nav = getattr(self.native_instance, "navigationController", None) if nav is not None: nav.popViewControllerAnimated_(True) @@ -408,23 +346,17 @@ def _detach_root(self, native_view: Any) -> None: else: - class Page(PageBase): + class _AppHost: """Desktop stub — no native runtime available. Fully functional for testing with a mock backend via ``native_views.set_registry()``. """ - def __init__(self, native_instance: Any = None) -> None: - super().__init__() + def __init__(self, native_instance: Any = None, component_func: Any = None) -> None: 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) + self._component = component_func + _init_host_common(self) def on_create(self) -> None: _on_create(self) @@ -453,13 +385,16 @@ def on_save_instance_state(self) -> None: def on_restore_instance_state(self) -> None: pass - def set_args(self, args: Optional[dict]) -> None: + def set_args(self, args: Any) -> None: _set_args(self, args) - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: + def _get_nav_args(self) -> Dict[str, Any]: + return self._args + + def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: raise RuntimeError("push() requires a native runtime (iOS or Android)") - def pop(self) -> None: + def _pop(self) -> None: raise RuntimeError("pop() requires a native runtime (iOS or Android)") def _attach_root(self, native_view: Any) -> None: @@ -467,3 +402,34 @@ def _attach_root(self, native_view: Any) -> None: def _detach_root(self, native_view: Any) -> None: pass + + +# ====================================================================== +# Public factory +# ====================================================================== + + +def create_page( + component_path: str, + native_instance: Any = None, + args_json: Optional[str] = None, +) -> _AppHost: + """Create a page host for a function component. + + Called by native templates (PageFragment.kt / ViewController.swift) + to bridge the native lifecycle to a ``@component`` function. + + Parameters + ---------- + component_path: + Dotted Python path to the component, e.g. ``"app.main_page.MainPage"``. + native_instance: + The native Activity (Android) or ViewController pointer (iOS). + args_json: + Optional JSON string of navigation arguments. + """ + component_func = _import_component(component_path) + host = _AppHost(native_instance, component_func) + if args_json: + _set_args(host, args_json) + return host diff --git a/src/pythonnative/style.py b/src/pythonnative/style.py index c8df328..7d645f4 100644 --- a/src/pythonnative/style.py +++ b/src/pythonnative/style.py @@ -1,7 +1,8 @@ -"""StyleSheet and theming support. +"""StyleSheet, style resolution, and theming support. Provides a :class:`StyleSheet` helper for creating and composing -reusable style dictionaries, plus a built-in theme context. +reusable style dictionaries, a :func:`resolve_style` utility for +flattening the ``style`` prop, and built-in theme contexts. Usage:: @@ -12,20 +13,39 @@ container={"padding": 16, "spacing": 12}, ) - pn.Text("Hello", **styles["title"]) - pn.Column(..., **styles["container"]) + pn.Text("Hello", style=styles["title"]) + pn.Column(..., style=styles["container"]) """ -from typing import Any, Dict +from typing import Any, Dict, List, Optional, Union from .hooks import Context, create_context +_StyleDict = Dict[str, Any] +StyleValue = Union[None, _StyleDict, List[Optional[_StyleDict]]] + + +def resolve_style(style: StyleValue) -> _StyleDict: + """Flatten a ``style`` prop into a single dict. + + Accepts ``None``, a single dict, or a list of dicts (later entries + override earlier ones, mirroring React Native's array style pattern). + """ + if style is None: + return {} + if isinstance(style, dict): + return dict(style) + result: _StyleDict = {} + for entry in style: + if entry: + result.update(entry) + return result + + # ====================================================================== # StyleSheet # ====================================================================== -_StyleDict = Dict[str, Any] - class StyleSheet: """Utility for creating and composing style dictionaries.""" 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 4c35fcb..af9bb3e 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt @@ -25,15 +25,8 @@ class PageFragment : Fragment() { val py = Python.getInstance() val pagePath = arguments?.getString("page_path") ?: "app.main_page.MainPage" val argsJson = arguments?.getString("args_json") - val moduleName = pagePath.substringBeforeLast('.') - val className = pagePath.substringAfterLast('.') - val pyModule = py.getModule(moduleName) - val pageClass = pyModule.get(className) - // Pass the hosting Activity as native_instance for context - page = pageClass?.call(requireActivity()) - if (!argsJson.isNullOrEmpty()) { - page?.callAttr("set_args", argsJson) - } + val pnPage = py.getModule("pythonnative.page") + page = pnPage.callAttr("create_page", pagePath, requireActivity(), argsJson) } catch (e: Exception) { Log.e(TAG, "Failed to instantiate page", e) } diff --git a/src/pythonnative/templates/ios_template/ios_template/ViewController.swift b/src/pythonnative/templates/ios_template/ios_template/ViewController.swift index 88e7afc..81e7c2d 100644 --- a/src/pythonnative/templates/ios_template/ios_template/ViewController.swift +++ b/src/pythonnative/templates/ios_template/ios_template/ViewController.swift @@ -85,28 +85,15 @@ class ViewController: UIViewController { // Determine which Python page to load let pagePath: String = requestedPagePath ?? "app.main_page.MainPage" do { - let moduleName = String(pagePath.split(separator: ".").dropLast().joined(separator: ".")) - let className = String(pagePath.split(separator: ".").last ?? "MainPage") - let pyModule = try Python.attemptImport(moduleName) - // Resolve class by name via builtins.getattr to avoid subscripting issues - let builtins = Python.import("builtins") - let getattrFn = builtins.getattr - let pageClass = try getattrFn.throwing.dynamicallyCall(withArguments: [pyModule, className]) - // Pass native pointer so Python Page can wrap via rubicon.objc + let pnPage = try Python.attemptImport("pythonnative.page") let ptr = Unmanaged.passUnretained(self).toOpaque() let addr = UInt(bitPattern: ptr) - let page = try pageClass.throwing.dynamicallyCall(withArguments: [addr]) - // If args provided, pass into Page via set_args(dict) - if let jsonStr = requestedPageArgsJSON { - let json = Python.import("json") - do { - let args = try json.loads.throwing.dynamicallyCall(withArguments: [jsonStr]) - _ = try page.set_args.throwing.dynamicallyCall(withArguments: [args]) - } catch { - NSLog("[PN] Failed to decode requestedPageArgsJSON: \(error)") - } - } - // Call on_create immediately so Python can insert its root view + let argsJson: PythonObject = (requestedPageArgsJSON != nil) + ? PythonObject(requestedPageArgsJSON!) + : Python.None + let page = try pnPage.create_page.throwing.dynamicallyCall( + withArguments: [pagePath, addr, argsJson] + ) _ = try page.on_create.throwing.dynamicallyCall(withArguments: []) return } catch { diff --git a/tests/test_cli.py b/tests/test_cli.py index 4a1eac9..ba6dcd6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,7 @@ def test_cli_init_and_clean() -> None: assert os.path.isfile(main_page_path) with open(main_page_path, "r", encoding="utf-8") as f: content = f.read() - assert "class MainPage(" in content + assert "def MainPage(" in content assert os.path.isfile(os.path.join(tmpdir, "pythonnative.json")) assert os.path.isfile(os.path.join(tmpdir, "requirements.txt")) assert os.path.isfile(os.path.join(tmpdir, ".gitignore")) diff --git a/tests/test_components.py b/tests/test_components.py index 353adc3..469c23c 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -33,8 +33,8 @@ def test_text_defaults() -> None: assert el.children == [] -def test_text_with_props() -> None: - el = Text("Hello", font_size=18, color="#FF0000", bold=True, text_align="center") +def test_text_with_style() -> None: + el = Text("Hello", style={"font_size": 18, "color": "#FF0000", "bold": True, "text_align": "center"}) assert el.props["text"] == "Hello" assert el.props["font_size"] == 18 assert el.props["color"] == "#FF0000" @@ -42,14 +42,14 @@ def test_text_with_props() -> None: assert el.props["text_align"] == "center" -def test_text_none_props_excluded() -> None: +def test_text_no_style_no_extra_props() -> None: el = Text("Hi") assert "font_size" not in el.props assert "color" not in el.props -def test_text_layout_props() -> None: - el = Text("Hi", width=100, height=50, flex=1, margin=8, align_self="center") +def test_text_layout_via_style() -> None: + el = Text("Hi", style={"width": 100, "height": 50, "flex": 1, "margin": 8, "align_self": "center"}) assert el.props["width"] == 100 assert el.props["height"] == 50 assert el.props["flex"] == 1 @@ -57,6 +57,15 @@ def test_text_layout_props() -> None: assert el.props["align_self"] == "center" +def test_text_style_list() -> None: + base = {"font_size": 16, "color": "#000"} + override = {"color": "#FFF", "bold": True} + el = Text("combo", style=[base, override]) + assert el.props["font_size"] == 16 + assert el.props["color"] == "#FFF" + assert el.props["bold"] is True + + # --------------------------------------------------------------------------- # Button # --------------------------------------------------------------------------- @@ -71,7 +80,7 @@ def test_button_defaults() -> None: def test_button_with_callback() -> None: cb = lambda: None # noqa: E731 - el = Button("Tap", on_click=cb, background_color="#123456") + el = Button("Tap", on_click=cb, style={"background_color": "#123456"}) assert el.props["title"] == "Tap" assert el.props["on_click"] is cb assert el.props["background_color"] == "#123456" @@ -88,32 +97,43 @@ def test_button_disabled() -> None: def test_column_with_children() -> None: - el = Column(Text("a"), Text("b"), spacing=10, padding=16, alignment="fill") + el = Column(Text("a"), Text("b"), style={"spacing": 10, "padding": 16, "align_items": "stretch"}) assert el.type == "Column" assert len(el.children) == 2 assert el.props["spacing"] == 10 assert el.props["padding"] == 16 - assert el.props["alignment"] == "fill" + assert el.props["align_items"] == "stretch" def test_row_with_children() -> None: - el = Row(Text("x"), Text("y"), spacing=5) + el = Row(Text("x"), Text("y"), style={"spacing": 5}) assert el.type == "Row" assert len(el.children) == 2 assert el.props["spacing"] == 5 -def test_column_no_spacing_omitted() -> None: +def test_column_no_style_empty_props() -> None: el = Column() - assert "spacing" not in el.props + assert el.props == {} -def test_column_layout_props() -> None: - el = Column(flex=2, margin={"horizontal": 8}) +def test_column_layout_via_style() -> None: + el = Column(style={"flex": 2, "margin": {"horizontal": 8}}) assert el.props["flex"] == 2 assert el.props["margin"] == {"horizontal": 8} +def test_column_justify_content() -> None: + el = Column(style={"justify_content": "center", "align_items": "center"}) + assert el.props["justify_content"] == "center" + assert el.props["align_items"] == "center" + + +def test_row_justify_content() -> None: + el = Row(style={"justify_content": "space_between"}) + assert el.props["justify_content"] == "space_between" + + # --------------------------------------------------------------------------- # ScrollView # --------------------------------------------------------------------------- @@ -158,7 +178,7 @@ def test_textinput_with_props() -> None: def test_image() -> None: - el = Image("icon.png", width=48, height=48) + el = Image("icon.png", style={"width": 48, "height": 48}) assert el.type == "Image" assert el.props["source"] == "icon.png" assert el.props["width"] == 48 @@ -222,7 +242,7 @@ def test_column_key() -> None: def test_view_container() -> None: child = Text("inside") - el = View(child, background_color="#FFF", padding=8, width=200) + el = View(child, style={"background_color": "#FFF", "padding": 8, "width": 200}) assert el.type == "View" assert len(el.children) == 1 assert el.props["background_color"] == "#FFF" @@ -231,7 +251,7 @@ def test_view_container() -> None: def test_safe_area_view() -> None: - el = SafeAreaView(Text("safe"), background_color="#000") + el = SafeAreaView(Text("safe"), style={"background_color": "#000"}) assert el.type == "SafeAreaView" assert len(el.children) == 1 diff --git a/tests/test_hooks.py b/tests/test_hooks.py index ca7520a..8fa017d 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -5,7 +5,9 @@ from pythonnative.element import Element from pythonnative.hooks import ( HookState, + NavigationHandle, Provider, + _NavigationContext, _set_hook_state, component, create_context, @@ -13,6 +15,7 @@ use_context, use_effect, use_memo, + use_navigation, use_ref, use_state, ) @@ -431,3 +434,32 @@ def themed() -> Element: el = Provider(theme, "dark", themed()) root = rec.mount(el) assert root.props["text"] == "dark" + + +# ====================================================================== +# use_navigation +# ====================================================================== + + +def test_use_navigation_reads_context() -> None: + class FakeHost: + def _get_nav_args(self) -> dict: + return {"id": 42} + + def _push(self, page: Any, args: Any = None) -> None: + pass + + def _pop(self) -> None: + pass + + handle = NavigationHandle(FakeHost()) + _NavigationContext._stack.append(handle) + hook_state = HookState() + _set_hook_state(hook_state) + try: + nav = use_navigation() + assert nav is handle + assert nav.get_args() == {"id": 42} + finally: + _set_hook_state(None) + _NavigationContext._stack.pop() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0e57dbb..24bcf62 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -21,7 +21,6 @@ def test_public_api_names() -> None: "FlatList", "Image", "Modal", - "Page", "Pressable", "ProgressBar", "Row", @@ -34,6 +33,8 @@ def test_public_api_names() -> None: "TextInput", "View", "WebView", + # Core + "create_page", # Hooks "component", "create_context", @@ -41,6 +42,7 @@ def test_public_api_names() -> None: "use_context", "use_effect", "use_memo", + "use_navigation", "use_ref", "use_state", "Provider", diff --git a/tests/test_style.py b/tests/test_style.py index 9837876..a0687d2 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -1,13 +1,35 @@ -"""Unit tests for StyleSheet and theming.""" +"""Unit tests for StyleSheet, resolve_style, and theming.""" from pythonnative.style import ( DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, StyleSheet, ThemeContext, + resolve_style, ) +def test_resolve_style_none() -> None: + assert resolve_style(None) == {} + + +def test_resolve_style_dict() -> None: + result = resolve_style({"font_size": 20, "color": "#000"}) + assert result == {"font_size": 20, "color": "#000"} + + +def test_resolve_style_list() -> None: + base = {"font_size": 16, "color": "#000"} + override = {"color": "#FFF", "bold": True} + result = resolve_style([base, override]) + assert result == {"font_size": 16, "color": "#FFF", "bold": True} + + +def test_resolve_style_list_with_none_entries() -> None: + result = resolve_style([None, {"a": 1}, None, {"b": 2}]) + assert result == {"a": 1, "b": 2} + + def test_stylesheet_create() -> None: styles = StyleSheet.create( heading={"font_size": 28, "bold": True}, From a7ef3d508e931c0a3ebe3ea89b50a4d5960dcced Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 3 Apr 2026 20:44:17 +0000 Subject: [PATCH 2/2] chore(release): v0.7.0 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- src/pythonnative/__init__.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d885a93..bc8d2c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # CHANGELOG +## v0.7.0 (2026-04-03) + +### Features + +- Replace class-based Page with function components, style prop, and use_navigation hook + ([`8103710`](https://github.com/pythonnative/pythonnative/commit/8103710aed5feb564583bb161cf81771669645fe)) + + ## v0.6.0 (2026-04-03) ### Build System diff --git a/pyproject.toml b/pyproject.toml index 6979cd1..3e7765e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.6.0" +version = "0.7.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 59e6892..f076dc3 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -14,7 +14,7 @@ def App(): ) """ -__version__ = "0.6.0" +__version__ = "0.7.0" from .components import ( ActivityIndicator,