From 3ac84b1a3f541b47121b46a687b78826f8d348f9 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 23 Oct 2025 12:37:48 -0700 Subject: [PATCH 01/34] docs(repo): simplify README with badges and one-paragraph overview --- README.md | 89 ++++++++++--------------------------------------------- 1 file changed, 15 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 4418c1b..31f005c 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,15 @@ -# PythonNative - -**PythonNative** is a cross-platform toolkit that allows you to create native Android and iOS apps using Python. Inspired by frameworks like React Native and NativeScript, PythonNative provides a Pythonic interface for building native UI elements, handling lifecycle events, and accessing platform-specific APIs. - -## Features - -- **Native UI Components**: Create and manage native buttons, labels, lists, and more, all from Python. -- **Cross-Platform**: Write once, run on both Android and iOS. -- **Lifecycle Management**: Handle app lifecycle events with ease. -- **Native API Access**: Access device features like Camera, Geolocation, and Notifications. -- **Powered by Proven Tools**: PythonNative integrates seamlessly with [Rubicon](https://beeware.org/project/projects/bridges/rubicon/) for iOS and [Chaquopy](https://chaquo.com/chaquopy/) for Android, ensuring robust native performance. - -## Quick Start - -### Installation - -First, install PythonNative via pip: - -```bash -pip install pythonnative -``` - -### Create Your First App - -Initialize a new PythonNative app: - -```bash -pn init my_app -``` - -Your app directory will look like this: - -```text -my_app/ -├── README.md -├── app -│ ├── __init__.py -│ ├── main_page.py -│ └── resources -├── pythonnative.json -├── requirements.txt -└── tests -``` - -### Writing Views - -In PythonNative, everything is a view. Here's a simple example of how to create a main page with a list view: - -```python -import pythonnative as pn - -class MainPage(pn.Page): - def __init__(self, native_instance): - super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack_view = pn.StackView(self.native_instance) - list_data = ["item_{}".format(i) for i in range(100)] - list_view = pn.ListView(self.native_instance, list_data) - stack_view.add_view(list_view) - self.set_root_view(stack_view) -``` - -### Run the app - -```bash -pn run android -pn run ios -``` - -## Documentation - -For detailed guides and API references, visit the [PythonNative documentation](https://docs.pythonnative.com/). +## PythonNative + +[![CI](https://github.com/pythonnative/pythonnative/actions/workflows/ci.yml/badge.svg)](https://github.com/pythonnative/pythonnative/actions/workflows/ci.yml) +[![Docs](https://github.com/pythonnative/pythonnative/actions/workflows/docs.yml/badge.svg)](https://github.com/pythonnative/pythonnative/actions/workflows/docs.yml) +[![Release](https://github.com/pythonnative/pythonnative/actions/workflows/release.yml/badge.svg)](https://github.com/pythonnative/pythonnative/actions/workflows/release.yml) +[![PyPI - Version](https://img.shields.io/pypi/v/pythonnative)](https://pypi.org/project/pythonnative/) +[![Python Versions](https://img.shields.io/pypi/pyversions/pythonnative)](https://pypi.org/project/pythonnative/) +[![License: MIT](https://img.shields.io/pypi/l/pythonnative)](LICENSE) +[![Docs Site](https://img.shields.io/website?url=https%3A%2F%2Fdocs.pythonnative.com&label=docs)](https://docs.pythonnative.com/) + +PythonNative is a cross‑platform toolkit for building native Android and iOS +apps in Python. It provides a Pythonic API for native UI components, +lifecycle events, and device capabilities, powered by Rubicon (iOS) and +Chaquopy (Android). For guides, API reference, and examples, see the +[docs](https://docs.pythonnative.com/). From 6962d3881bf091b3494fc2c964f7ea65a99ce606 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:00:14 -0700 Subject: [PATCH 02/34] feat(components,core)!: add layout/styling APIs and fluent setters --- docs/api/component-properties.md | 52 +++++++++ docs/guides/styling.md | 93 ++++++++++++++++ examples/hello-world/app/main_page.py | 20 +--- mkdocs.yml | 2 + src/pythonnative/button.py | 12 ++- src/pythonnative/cli/pn.py | 15 ++- src/pythonnative/label.py | 71 +++++++++++- src/pythonnative/list_view.py | 6 +- src/pythonnative/scroll_view.py | 19 +++- src/pythonnative/stack_view.py | 141 +++++++++++++++++++++++- src/pythonnative/text_field.py | 69 +++++++++++- src/pythonnative/text_view.py | 69 +++++++++++- src/pythonnative/view.py | 150 +++++++++++++++++++++++++- 13 files changed, 683 insertions(+), 36 deletions(-) create mode 100644 docs/api/component-properties.md create mode 100644 docs/guides/styling.md diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md new file mode 100644 index 0000000..2480b13 --- /dev/null +++ b/docs/api/component-properties.md @@ -0,0 +1,52 @@ +## Component Property Reference (v0.4.0) + +This page summarizes common properties and fluent setters added in v0.4.0. Unless noted, methods return `self` for chaining. + +### View (base) + +- `set_background_color(color)` + - Accepts ARGB int or `#RRGGBB` / `#AARRGGBB` string. + +- `set_padding(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` + - Android: applies padding in dp. + - iOS: currently a no-op for most views. + +- `set_margin(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` + - Android: applied when added to `StackView` (LinearLayout) as `LayoutParams` margins (dp). + - iOS: not currently applied. + +- `wrap_in_scroll()` → `ScrollView` + - Returns a `ScrollView` containing this view. + +### ScrollView + +- `ScrollView.wrap(view)` → `ScrollView` + - Class helper to wrap a single child. + +### StackView + +- `set_axis('vertical'|'horizontal')` +- `set_spacing(n)` + - Android: dp via divider drawable. + - iOS: `UIStackView.spacing` (points). +- `set_alignment('fill'|'center'|'leading'|'trailing'|'top'|'bottom')` + - Cross-axis alignment; top/bottom map appropriately for horizontal stacks. + +### Text components + +Applies to `Label`, `TextField`, `TextView`: + +- `set_text(text)` +- `set_text_color(color)` +- `set_text_size(size)` + +Platform notes: +- Android: `setTextColor(int)`, `setTextSize(sp)`. +- iOS: `setTextColor(UIColor)`, `UIFont.systemFont(ofSize:)`. + +### Button + +- `set_title(text)` +- `set_on_click(callback)` + + diff --git a/docs/guides/styling.md b/docs/guides/styling.md new file mode 100644 index 0000000..20de1b2 --- /dev/null +++ b/docs/guides/styling.md @@ -0,0 +1,93 @@ +## Styling + +This guide covers the lightweight styling APIs introduced in v0.4.0. + +The goal is to provide a small, predictable set of cross-platform styling hooks. Some features are Android-only today and will expand over time. + +### Colors + +- Use `set_background_color(color)` on any view. +- Color can be an ARGB int or a hex string like `#RRGGBB` or `#AARRGGBB`. + +```python +stack = pn.StackView().set_background_color("#FFF5F5F5") +``` + +### Padding and Margin + +- `set_padding(...)` is available on all views. + - Android: applies using `View.setPadding` with dp units. + - iOS: currently a no-op for most views; prefer container spacing (`StackView.set_spacing`) and layout. + +- `set_margin(...)` records margins on the child view. + - Android: applied automatically when added to a `StackView` (LinearLayout) via `LayoutParams.setMargins` (dp). + - iOS: not currently applied. + +`set_padding`/`set_margin` accept these parameters (integers): `all`, `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`. Individual sides override group values. + +```python +pn.Label("Name").set_margin(bottom=8) +pn.TextField().set_padding(horizontal=12, vertical=8) +``` + +### Text styling + +Text components expose: +- `set_text(text) -> self` +- `set_text_color(color) -> self` (hex or ARGB int) +- `set_text_size(size) -> self` (sp on Android; pt on iOS via system font) + +Applies to `Label`, `TextField`, and `TextView`. + +```python +pn.Label("Hello").set_text_color("#FF3366").set_text_size(18) +``` + +### StackView layout + +`StackView` (Android LinearLayout / iOS UIStackView) adds configuration helpers: + +- `set_axis('vertical'|'horizontal') -> self` +- `set_spacing(n) -> self` (dp on Android; points on iOS) +- `set_alignment(value) -> self` + - Cross-axis alignment. Supported values: `fill`, `center`, `leading`/`top`, `trailing`/`bottom`. + +```python +form = ( + pn.StackView() + .set_axis('vertical') + .set_spacing(8) + .set_alignment('fill') + .set_padding(all=16) +) +form.add_view(pn.Label("Username").set_margin(bottom=4)) +form.add_view(pn.TextField().set_padding(horizontal=12, vertical=8)) +``` + +### Scroll helpers + +Wrap any view in a `ScrollView` using either approach: + +```python +scroll = pn.ScrollView.wrap(form) +# or +scroll = form.wrap_in_scroll() +``` + +Attach the scroll view as your page root: + +```python +self.set_root_view(scroll) +``` + +### Fluent setters + +Most setters now return `self` for chaining, e.g.: + +```python +pn.Button("Tap me").set_on_click(lambda: print("hi")).set_padding(all=8) +``` + +Note: Where platform limitations exist, the methods are no-ops and still return `self`. + + diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 032ef3b..ad6f8e6 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -15,14 +15,9 @@ def __init__(self, native_instance): def on_create(self): super().on_create() - stack = pn.StackView() - # Ensure vertical stacking - try: - stack.native_instance.setAxis_(1) # 1 = vertical - except Exception: - pass - stack.add_view(pn.Label("Hello from PythonNative Demo!")) - button = pn.Button("Go to Second Page") + stack = pn.StackView().set_axis("vertical").set_spacing(12).set_alignment("fill").set_padding(all=16) + stack.add_view(pn.Label("Hello from PythonNative Demo!").set_text_size(18)) + button = pn.Button("Go to Second Page").set_padding(vertical=10, horizontal=14) def on_next(): # Visual confirmation that tap worked (iOS only) @@ -37,14 +32,9 @@ def on_next(): button.set_on_click(on_next) # Make the button visually obvious - try: - if UIColor is not None: - button.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - button.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass + button.set_background_color("#FF1E88E5") stack.add_view(button) - self.set_root_view(stack) + self.set_root_view(stack.wrap_in_scroll()) def on_start(self): super().on_start() diff --git a/mkdocs.yml b/mkdocs.yml index 9a29ca4..7aeff94 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,8 +24,10 @@ nav: - Android: guides/android.md - iOS: guides/ios.md - Navigation: guides/navigation.md + - Styling: guides/styling.md - API Reference: - Package: api/pythonnative.md + - Component Properties: api/component-properties.md - Meta: - Roadmap: meta/roadmap.md - Contributing: meta/contributing.md diff --git a/src/pythonnative/button.py b/src/pythonnative/button.py index 13e38b1..7adef43 100644 --- a/src/pythonnative/button.py +++ b/src/pythonnative/button.py @@ -43,13 +43,14 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class(context) self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str): self.native_instance.setText(title) + return self def get_title(self) -> str: return self.native_instance.getText().toString() - def set_on_click(self, callback: Callable[[], None]) -> None: + def set_on_click(self, callback: Callable[[], None]): class OnClickListener(dynamic_proxy(jclass("android.view.View").OnClickListener)): def __init__(self, callback): super().__init__() @@ -60,6 +61,7 @@ def onClick(self, view): listener = OnClickListener(callback) self.native_instance.setOnClickListener(listener) + return self else: # ======================================== @@ -93,13 +95,14 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str): self.native_instance.setTitle_forState_(title, 0) + return self def get_title(self) -> str: return self.native_instance.titleForState_(0) - def set_on_click(self, callback: Callable[[], None]) -> None: + def set_on_click(self, callback: Callable[[], None]): # Create a handler object with an Objective-C method `onTap:` and attach the Python callback handler = _PNButtonHandler.new() # Keep strong references to the handler and callback @@ -107,3 +110,4 @@ def set_on_click(self, callback: Callable[[], None]) -> None: handler._callback = callback # UIControlEventTouchUpInside = 1 << 6 self.native_instance.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) + return self diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index e053f8d..8281eae 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -55,12 +55,17 @@ def __init__(self, native_instance): def on_create(self): super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button clicked")) + stack = ( + pn.StackView() + .set_axis("vertical") + .set_spacing(12) + .set_alignment("fill") + .set_padding(all=16) + ) + stack.add_view(pn.Label("Hello from PythonNative!").set_text_size(18)) + button = pn.Button("Tap me").set_on_click(lambda: print("Button clicked")) stack.add_view(button) - self.set_root_view(stack) + self.set_root_view(stack.wrap_in_scroll()) """ ) diff --git a/src/pythonnative/label.py b/src/pythonnative/label.py index c34eec2..a65ea05 100644 --- a/src/pythonnative/label.py +++ b/src/pythonnative/label.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -21,6 +22,14 @@ def set_text(self, text: str) -> None: def get_text(self) -> str: pass + @abstractmethod + def set_text_color(self, color: Any) -> None: + pass + + @abstractmethod + def set_text_size(self, size: float) -> None: + pass + if IS_ANDROID: # ======================================== @@ -38,12 +47,37 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class(context) self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str): self.native_instance.setText(text) + return self def get_text(self) -> str: return self.native_instance.getText().toString() + def set_text_color(self, color: Any): + # Accept int ARGB or hex string + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + self.native_instance.setTextColor(color_int) + except Exception: + pass + return self + + def set_text_size(self, size_sp: float): + try: + self.native_instance.setTextSize(float(size_sp)) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -59,8 +93,41 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str): self.native_instance.setText_(text) + return self def get_text(self) -> str: return self.native_instance.text() + + def set_text_color(self, color: Any): + # Accept int ARGB or hex string + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + self.native_instance.setTextColor_(color_obj) + except Exception: + pass + return self + + def set_text_size(self, size: float): + try: + UIFont = ObjCClass("UIFont") + font = UIFont.systemFontOfSize_(float(size)) + self.native_instance.setFont_(font) + except Exception: + pass + return self diff --git a/src/pythonnative/list_view.py b/src/pythonnative/list_view.py index d2378d2..3fe2a1e 100644 --- a/src/pythonnative/list_view.py +++ b/src/pythonnative/list_view.py @@ -38,11 +38,12 @@ def __init__(self, context, data: list = []) -> None: self.native_instance = self.native_class(context) self.set_data(data) - def set_data(self, data: list) -> None: + def set_data(self, data: list): adapter = jclass("android.widget.ArrayAdapter")( self.context, jclass("android.R$layout").simple_list_item_1, data ) self.native_instance.setAdapter(adapter) + return self def get_data(self) -> list: adapter = self.native_instance.getAdapter() @@ -63,9 +64,10 @@ def __init__(self, data: list = []) -> None: self.native_instance = self.native_class.alloc().init() self.set_data(data) - def set_data(self, data: list) -> None: + def set_data(self, data: list): # Note: This is a simplified representation. Normally, you would need to create a UITableViewDataSource. self.native_instance.reloadData() + return self def get_data(self) -> list: # Note: This is a simplified representation. diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py index 1c19d62..13c5591 100644 --- a/src/pythonnative/scroll_view.py +++ b/src/pythonnative/scroll_view.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import Any, List -from .utils import IS_ANDROID +from .utils import IS_ANDROID, get_android_context from .view import ViewBase # ======================================== @@ -29,9 +29,10 @@ def add_view(self, view) -> None: from java import jclass class ScrollView(ScrollViewBase, ViewBase): - def __init__(self, context) -> None: + def __init__(self) -> None: super().__init__() self.native_class = jclass("android.widget.ScrollView") + context = get_android_context() self.native_instance = self.native_class(context) def add_view(self, view): @@ -42,6 +43,13 @@ def add_view(self, view): else: raise Exception("ScrollView can host only one direct child") + @staticmethod + def wrap(view: Any): + """Return a new ScrollView containing the provided view as its only child.""" + sv = ScrollView() + sv.add_view(view) + return sv + else: # ======================================== # iOS class @@ -61,3 +69,10 @@ def add_view(self, view): # Ensure view is a subview of scrollview if view.native_instance not in self.native_instance.subviews: self.native_instance.addSubview_(view.native_instance) + + @staticmethod + def wrap(view: Any): + """Return a new ScrollView containing the provided view as its only child.""" + sv = ScrollView() + sv.add_view(view) + return sv diff --git a/src/pythonnative/stack_view.py b/src/pythonnative/stack_view.py index 3fc57b1..004ca7e 100644 --- a/src/pythonnative/stack_view.py +++ b/src/pythonnative/stack_view.py @@ -19,6 +19,18 @@ def __init__(self) -> None: def add_view(self, view) -> None: pass + @abstractmethod + def set_axis(self, axis: str): + pass + + @abstractmethod + def set_spacing(self, spacing): + pass + + @abstractmethod + def set_alignment(self, alignment: str): + pass + if IS_ANDROID: # ======================================== @@ -35,11 +47,99 @@ def __init__(self) -> None: context = get_android_context() self.native_instance = self.native_class(context) self.native_instance.setOrientation(self.native_class.VERTICAL) + # Cache context and current orientation for spacing/alignment helpers + self._context = context + self._axis = "vertical" def add_view(self, view): self.views.append(view) + # Apply margins if the child has any recorded (supported for LinearLayout) + try: + lp = view.native_instance.getLayoutParams() + except Exception: + lp = None + if lp is None: + # Create default LayoutParams (WRAP_CONTENT) + layout_params = jclass("android.widget.LinearLayout$LayoutParams")(-2, -2) + else: + layout_params = lp + margin = getattr(view, "_pn_margin", None) + if margin is not None: + left, top, right, bottom = margin + # Convert dp to px + density = self._context.getResources().getDisplayMetrics().density + lpx = int(left * density) + tpx = int(top * density) + rpx = int(right * density) + bpx = int(bottom * density) + try: + layout_params.setMargins(lpx, tpx, rpx, bpx) + except Exception: + pass + try: + view.native_instance.setLayoutParams(layout_params) + except Exception: + pass self.native_instance.addView(view.native_instance) + def set_axis(self, axis: str): + """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" + axis_l = (axis or "").lower() + if axis_l not in ("vertical", "horizontal"): + return self + orientation = self.native_class.VERTICAL if axis_l == "vertical" else self.native_class.HORIZONTAL + self.native_instance.setOrientation(orientation) + self._axis = axis_l + return self + + def set_spacing(self, spacing_dp: int): + """Set spacing between children in dp (Android: uses LinearLayout dividers). Returns self.""" + try: + density = self._context.getResources().getDisplayMetrics().density + px = max(0, int(spacing_dp * density)) + # Use a transparent GradientDrawable with specified size as divider + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + drawable = GradientDrawable() + drawable.setColor(0x00000000) + if self._axis == "vertical": + drawable.setSize(1, px) + else: + drawable.setSize(px, 1) + self.native_instance.setShowDividers(self.native_class.SHOW_DIVIDER_MIDDLE) + self.native_instance.setDividerDrawable(drawable) + except Exception: + pass + return self + + def set_alignment(self, alignment: str): + """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" + try: + Gravity = jclass("android.view.Gravity") + a = (alignment or "").lower() + if self._axis == "vertical": + # Cross-axis is horizontal + if a in ("fill",): + self.native_instance.setGravity(Gravity.FILL_HORIZONTAL) + elif a in ("center", "centre"): + self.native_instance.setGravity(Gravity.CENTER_HORIZONTAL) + elif a in ("leading", "start", "left"): + self.native_instance.setGravity(Gravity.START) + elif a in ("trailing", "end", "right"): + self.native_instance.setGravity(Gravity.END) + else: + # Cross-axis is vertical + if a in ("fill",): + self.native_instance.setGravity(Gravity.FILL_VERTICAL) + elif a in ("center", "centre"): + self.native_instance.setGravity(Gravity.CENTER_VERTICAL) + elif a in ("top",): + self.native_instance.setGravity(Gravity.TOP) + elif a in ("bottom",): + self.native_instance.setGravity(Gravity.BOTTOM) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -53,8 +153,47 @@ def __init__(self) -> None: super().__init__() self.native_class = ObjCClass("UIStackView") self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - self.native_instance.setAxis_(0) # Set axis to vertical + # Default to vertical axis + self.native_instance.setAxis_(1) def add_view(self, view): self.views.append(view) self.native_instance.addArrangedSubview_(view.native_instance) + + def set_axis(self, axis: str): + """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" + axis_l = (axis or "").lower() + value = 1 if axis_l == "vertical" else 0 + try: + self.native_instance.setAxis_(value) + except Exception: + pass + return self + + def set_spacing(self, spacing: float): + """Set spacing between arranged subviews. Returns self.""" + try: + self.native_instance.setSpacing_(float(spacing)) + except Exception: + pass + return self + + def set_alignment(self, alignment: str): + """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" + a = (alignment or "").lower() + # UIStackViewAlignment: Fill=0, Leading/Top=1, Center=3, Trailing/Bottom=4 + mapping = { + "fill": 0, + "leading": 1, + "top": 1, + "center": 3, + "centre": 3, + "trailing": 4, + "bottom": 4, + } + value = mapping.get(a, 0) + try: + self.native_instance.setAlignment_(value) + except Exception: + pass + return self diff --git a/src/pythonnative/text_field.py b/src/pythonnative/text_field.py index fcf1288..f78ee90 100644 --- a/src/pythonnative/text_field.py +++ b/src/pythonnative/text_field.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -21,6 +22,14 @@ def set_text(self, text: str) -> None: def get_text(self) -> str: pass + @abstractmethod + def set_text_color(self, color: Any) -> None: + pass + + @abstractmethod + def set_text_size(self, size: float) -> None: + pass + if IS_ANDROID: # ======================================== @@ -39,12 +48,36 @@ def __init__(self, text: str = "") -> None: self.native_instance.setSingleLine(True) self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str): self.native_instance.setText(text) + return self def get_text(self) -> str: return self.native_instance.getText().toString() + def set_text_color(self, color: Any): + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + self.native_instance.setTextColor(color_int) + except Exception: + pass + return self + + def set_text_size(self, size_sp: float): + try: + self.native_instance.setTextSize(float(size_sp)) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -60,8 +93,40 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str): self.native_instance.setText_(text) + return self def get_text(self) -> str: return self.native_instance.text() + + def set_text_color(self, color: Any): + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + self.native_instance.setTextColor_(color_obj) + except Exception: + pass + return self + + def set_text_size(self, size: float): + try: + UIFont = ObjCClass("UIFont") + font = UIFont.systemFontOfSize_(float(size)) + self.native_instance.setFont_(font) + except Exception: + pass + return self diff --git a/src/pythonnative/text_view.py b/src/pythonnative/text_view.py index 8e9154d..6c0d4ec 100644 --- a/src/pythonnative/text_view.py +++ b/src/pythonnative/text_view.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -21,6 +22,14 @@ def set_text(self, text: str) -> None: def get_text(self) -> str: pass + @abstractmethod + def set_text_color(self, color: Any) -> None: + pass + + @abstractmethod + def set_text_size(self, size: float) -> None: + pass + if IS_ANDROID: # ======================================== @@ -42,12 +51,36 @@ def __init__(self, text: str = "") -> None: # self.native_instance.movementMethod = ScrollingMovementMethod() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str): self.native_instance.setText(text) + return self def get_text(self) -> str: return self.native_instance.getText().toString() + def set_text_color(self, color: Any): + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + self.native_instance.setTextColor(color_int) + except Exception: + pass + return self + + def set_text_size(self, size_sp: float): + try: + self.native_instance.setTextSize(float(size_sp)) + except Exception: + pass + return self + else: # ======================================== # iOS class @@ -63,8 +96,40 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str) -> None: + def set_text(self, text: str): self.native_instance.setText_(text) + return self def get_text(self) -> str: return self.native_instance.text() + + def set_text_color(self, color: Any): + if isinstance(color, str): + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + try: + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + self.native_instance.setTextColor_(color_obj) + except Exception: + pass + return self + + def set_text_size(self, size: float): + try: + UIFont = ObjCClass("UIFont") + font = UIFont.systemFontOfSize_(float(size)) + self.native_instance.setFont_(font) + except Exception: + pass + return self diff --git a/src/pythonnative/view.py b/src/pythonnative/view.py index 4afbbfe..bf6356f 100644 --- a/src/pythonnative/view.py +++ b/src/pythonnative/view.py @@ -1,5 +1,5 @@ from abc import ABC -from typing import Any +from typing import Any, Optional, Tuple # ======================================== # Base class @@ -11,6 +11,154 @@ def __init__(self) -> None: # Native bridge handles return types dynamically; these attributes are set at runtime. self.native_instance: Any = None self.native_class: Any = None + # Record margins for parents that can apply them (Android LinearLayout) + self._pn_margin: Optional[Tuple[int, int, int, int]] = None + + # ======================================== + # Lightweight style helpers + # ======================================== + + def set_background_color(self, color: Any): + """Set background color. Accepts platform color int or CSS-like hex string. Returns self.""" + try: + from .utils import IS_ANDROID + + if isinstance(color, str): + # Support formats: #RRGGBB or #AARRGGBB + c = color.strip() + if c.startswith("#"): + c = c[1:] + if len(c) == 6: + c = "FF" + c + color_int = int(c, 16) + else: + color_int = int(color) + + if IS_ANDROID: + # Android expects ARGB int + self.native_instance.setBackgroundColor(color_int) + else: + # iOS expects a UIColor + from rubicon.objc import ObjCClass + + UIColor = ObjCClass("UIColor") + a = ((color_int >> 24) & 0xFF) / 255.0 + r = ((color_int >> 16) & 0xFF) / 255.0 + g = ((color_int >> 8) & 0xFF) / 255.0 + b = (color_int & 0xFF) / 255.0 + try: + color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + except Exception: + color_obj = UIColor.blackColor() + try: + self.native_instance.setBackgroundColor_(color_obj) + except Exception: + try: + # Some UIKit classes expose 'backgroundColor' property + self.native_instance.setBackgroundColor_(color_obj) + except Exception: + pass + except Exception: + pass + return self + + def set_padding( + self, + left: Optional[int] = None, + top: Optional[int] = None, + right: Optional[int] = None, + bottom: Optional[int] = None, + all: Optional[int] = None, + horizontal: Optional[int] = None, + vertical: Optional[int] = None, + ): + """Set padding (dp on Android; best-effort on iOS where supported). Returns self. + + When provided, 'all' applies to all sides; 'horizontal' applies to left and right; + 'vertical' applies to top and bottom; individual overrides take precedence. + """ + try: + from .utils import IS_ANDROID, get_android_context + + left_value = left + top_value = top + right_value = right + bottom_value = bottom + if all is not None: + left_value = top_value = right_value = bottom_value = all + if horizontal is not None: + left_value = horizontal if left_value is None else left_value + right_value = horizontal if right_value is None else right_value + if vertical is not None: + top_value = vertical if top_value is None else top_value + bottom_value = vertical if bottom_value is None else bottom_value + left_value = left_value or 0 + top_value = top_value or 0 + right_value = right_value or 0 + bottom_value = bottom_value or 0 + + if IS_ANDROID: + density = get_android_context().getResources().getDisplayMetrics().density + lpx = int(left_value * density) + tpx = int(top_value * density) + rpx = int(right_value * density) + bpx = int(bottom_value * density) + self.native_instance.setPadding(lpx, tpx, rpx, bpx) + else: + # Best-effort: many UIKit views don't have direct padding; leave to containers (e.g. UIStackView) + # No-op by default. + pass + except Exception: + pass + return self + + def set_margin( + self, + left: Optional[int] = None, + top: Optional[int] = None, + right: Optional[int] = None, + bottom: Optional[int] = None, + all: Optional[int] = None, + horizontal: Optional[int] = None, + vertical: Optional[int] = None, + ): + """Record margins for this view (applied where supported). Returns self. + + Currently applied automatically when added to Android LinearLayout (StackView). + """ + try: + left_value = left + top_value = top + right_value = right + bottom_value = bottom + if all is not None: + left_value = top_value = right_value = bottom_value = all + if horizontal is not None: + left_value = horizontal if left_value is None else left_value + right_value = horizontal if right_value is None else right_value + if vertical is not None: + top_value = vertical if top_value is None else top_value + bottom_value = vertical if bottom_value is None else bottom_value + left_value = int(left_value or 0) + top_value = int(top_value or 0) + right_value = int(right_value or 0) + bottom_value = int(bottom_value or 0) + self._pn_margin = (left_value, top_value, right_value, bottom_value) + except Exception: + pass + return self + + def wrap_in_scroll(self): + """Return a ScrollView containing this view as its only child. Returns the ScrollView.""" + try: + # Local import to avoid circulars + from .scroll_view import ScrollView + + sv = ScrollView() + sv.add_view(self) + return sv + except Exception: + return None # @abstractmethod # def add_view(self, view): From 86e4ffc9e51810997006055434783416784c182f Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:44:32 -0700 Subject: [PATCH 03/34] refactor(core,components,examples): add annotations; tighten mypy --- examples/hello-world/app/main_page.py | 24 +++++++++-------- examples/hello-world/app/second_page.py | 24 +++++++++-------- examples/hello-world/app/third_page.py | 6 +++-- mypy.ini | 2 ++ src/pythonnative/button.py | 20 +++++++------- src/pythonnative/date_picker.py | 4 ++- src/pythonnative/label.py | 18 ++++++------- src/pythonnative/list_view.py | 9 ++++--- .../material_activity_indicator_view.py | 4 ++- src/pythonnative/material_button.py | 4 ++- src/pythonnative/material_progress_view.py | 4 ++- src/pythonnative/material_search_bar.py | 4 ++- src/pythonnative/material_switch.py | 4 ++- src/pythonnative/material_time_picker.py | 4 ++- src/pythonnative/page.py | 12 ++++----- src/pythonnative/picker_view.py | 4 ++- src/pythonnative/scroll_view.py | 10 +++---- src/pythonnative/search_bar.py | 4 ++- src/pythonnative/stack_view.py | 26 +++++++++---------- src/pythonnative/text_field.py | 18 ++++++------- src/pythonnative/text_view.py | 18 ++++++------- src/pythonnative/time_picker.py | 4 ++- src/pythonnative/view.py | 8 +++--- tests/test_cli.py | 7 ++--- tests/test_smoke.py | 2 +- 25 files changed, 137 insertions(+), 107 deletions(-) diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index ad6f8e6..2646880 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,3 +1,5 @@ +from typing import Any + import pythonnative as pn try: @@ -10,16 +12,16 @@ class MainPage(pn.Page): - def __init__(self, native_instance): + def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self): + def on_create(self) -> None: super().on_create() stack = pn.StackView().set_axis("vertical").set_spacing(12).set_alignment("fill").set_padding(all=16) stack.add_view(pn.Label("Hello from PythonNative Demo!").set_text_size(18)) button = pn.Button("Go to Second Page").set_padding(vertical=10, horizontal=14) - def on_next(): + def on_next() -> None: # Visual confirmation that tap worked (iOS only) try: if UIColor is not None: @@ -36,26 +38,26 @@ def on_next(): stack.add_view(button) self.set_root_view(stack.wrap_in_scroll()) - def on_start(self): + def on_start(self) -> None: super().on_start() - def on_resume(self): + def on_resume(self) -> None: super().on_resume() - def on_pause(self): + def on_pause(self) -> None: super().on_pause() - def on_stop(self): + def on_stop(self) -> None: super().on_stop() - def on_destroy(self): + def on_destroy(self) -> None: super().on_destroy() - def on_restart(self): + def on_restart(self) -> None: super().on_restart() - def on_save_instance_state(self): + def on_save_instance_state(self) -> None: super().on_save_instance_state() - def on_restore_instance_state(self): + def on_restore_instance_state(self) -> None: super().on_restore_instance_state() diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 99af521..3515527 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -1,3 +1,5 @@ +from typing import Any + import pythonnative as pn try: @@ -10,10 +12,10 @@ class SecondPage(pn.Page): - def __init__(self, native_instance): + def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self): + def on_create(self) -> None: super().on_create() stack_view = pn.StackView() # Read args passed from MainPage @@ -30,7 +32,7 @@ def on_create(self): except Exception: pass - def on_next(): + def on_next() -> None: # Visual confirmation that tap worked (iOS only) try: if UIColor is not None: @@ -47,26 +49,26 @@ def on_next(): stack_view.add_view(back_btn) self.set_root_view(stack_view) - def on_start(self): + def on_start(self) -> None: super().on_start() - def on_resume(self): + def on_resume(self) -> None: super().on_resume() - def on_pause(self): + def on_pause(self) -> None: super().on_pause() - def on_stop(self): + def on_stop(self) -> None: super().on_stop() - def on_destroy(self): + def on_destroy(self) -> None: super().on_destroy() - def on_restart(self): + def on_restart(self) -> None: super().on_restart() - def on_save_instance_state(self): + def on_save_instance_state(self) -> None: super().on_save_instance_state() - def on_restore_instance_state(self): + def on_restore_instance_state(self) -> None: super().on_restore_instance_state() diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index 6c06594..97b003f 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -1,3 +1,5 @@ +from typing import Any + import pythonnative as pn try: @@ -10,10 +12,10 @@ class ThirdPage(pn.Page): - def __init__(self, native_instance): + def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self): + def on_create(self) -> None: super().on_create() stack = pn.StackView() stack.add_view(pn.Label("This is the Third Page")) diff --git a/mypy.ini b/mypy.ini index ec0b8ad..333ddb6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,6 +8,8 @@ strict_optional = False pretty = True files = src, tests, examples exclude = (^build/|^examples/.*/build/) +disallow_untyped_defs = True +disallow_incomplete_defs = True [mypy-pythonnative.*] implicit_reexport = True diff --git a/src/pythonnative/button.py b/src/pythonnative/button.py index 7adef43..7c714a4 100644 --- a/src/pythonnative/button.py +++ b/src/pythonnative/button.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Callable, Optional +from typing import Any, Callable, Optional from .utils import IS_ANDROID, get_android_context from .view import ViewBase @@ -15,7 +15,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "ButtonBase": pass @abstractmethod @@ -23,7 +23,7 @@ def get_title(self) -> str: pass @abstractmethod - def set_on_click(self, callback: Callable[[], None]) -> None: + def set_on_click(self, callback: Callable[[], None]) -> "ButtonBase": pass @@ -43,20 +43,20 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class(context) self.set_title(title) - def set_title(self, title: str): + def set_title(self, title: str) -> "Button": self.native_instance.setText(title) return self def get_title(self) -> str: return self.native_instance.getText().toString() - def set_on_click(self, callback: Callable[[], None]): + def set_on_click(self, callback: Callable[[], None]) -> "Button": class OnClickListener(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback): + def __init__(self, callback: Callable[[], None]) -> None: super().__init__() self.callback = callback - def onClick(self, view): + def onClick(self, view: Any) -> None: self.callback() listener = OnClickListener(callback) @@ -79,7 +79,7 @@ class _PNButtonHandler(NSObject): # type: ignore[valid-type] _callback: Optional[Callable[[], None]] = None @objc_method - def onTap_(self, sender) -> None: + def onTap_(self, sender: Any) -> None: try: callback = self._callback if callback is not None: @@ -95,14 +95,14 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_title(title) - def set_title(self, title: str): + def set_title(self, title: str) -> "Button": self.native_instance.setTitle_forState_(title, 0) return self def get_title(self) -> str: return self.native_instance.titleForState_(0) - def set_on_click(self, callback: Callable[[], None]): + def set_on_click(self, callback: Callable[[], None]) -> "Button": # Create a handler object with an Objective-C method `onTap:` and attach the Python callback handler = _PNButtonHandler.new() # Keep strong references to the handler and callback diff --git a/src/pythonnative/date_picker.py b/src/pythonnative/date_picker.py index cb86006..7d161bd 100644 --- a/src/pythonnative/date_picker.py +++ b/src/pythonnative/date_picker.py @@ -28,10 +28,12 @@ def get_date(self) -> tuple: # https://developer.android.com/reference/android/widget/DatePicker # ======================================== + from typing import Any + from java import jclass class DatePicker(DatePickerBase, ViewBase): - def __init__(self, context, year: int = 0, month: int = 0, day: int = 0) -> None: + def __init__(self, context: Any, year: int = 0, month: int = 0, day: int = 0) -> None: super().__init__() self.native_class = jclass("android.widget.DatePicker") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/label.py b/src/pythonnative/label.py index a65ea05..d998b7d 100644 --- a/src/pythonnative/label.py +++ b/src/pythonnative/label.py @@ -15,7 +15,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "LabelBase": pass @abstractmethod @@ -23,11 +23,11 @@ def get_text(self) -> str: pass @abstractmethod - def set_text_color(self, color: Any) -> None: + def set_text_color(self, color: Any) -> "LabelBase": pass @abstractmethod - def set_text_size(self, size: float) -> None: + def set_text_size(self, size: float) -> "LabelBase": pass @@ -47,14 +47,14 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class(context) self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> "Label": self.native_instance.setText(text) return self def get_text(self) -> str: return self.native_instance.getText().toString() - def set_text_color(self, color: Any): + def set_text_color(self, color: Any) -> "Label": # Accept int ARGB or hex string if isinstance(color, str): c = color.strip() @@ -71,7 +71,7 @@ def set_text_color(self, color: Any): pass return self - def set_text_size(self, size_sp: float): + def set_text_size(self, size_sp: float) -> "Label": try: self.native_instance.setTextSize(float(size_sp)) except Exception: @@ -93,14 +93,14 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> "Label": self.native_instance.setText_(text) return self def get_text(self) -> str: return self.native_instance.text() - def set_text_color(self, color: Any): + def set_text_color(self, color: Any) -> "Label": # Accept int ARGB or hex string if isinstance(color, str): c = color.strip() @@ -123,7 +123,7 @@ def set_text_color(self, color: Any): pass return self - def set_text_size(self, size: float): + def set_text_size(self, size: float) -> "Label": try: UIFont = ObjCClass("UIFont") font = UIFont.systemFontOfSize_(float(size)) diff --git a/src/pythonnative/list_view.py b/src/pythonnative/list_view.py index 3fe2a1e..c4433e4 100644 --- a/src/pythonnative/list_view.py +++ b/src/pythonnative/list_view.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Any from .utils import IS_ANDROID from .view import ViewBase @@ -14,7 +15,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_data(self, data: list) -> None: + def set_data(self, data: list) -> "ListViewBase": pass @abstractmethod @@ -31,14 +32,14 @@ def get_data(self) -> list: from java import jclass class ListView(ListViewBase, ViewBase): - def __init__(self, context, data: list = []) -> None: + def __init__(self, context: Any, data: list = []) -> None: super().__init__() self.context = context self.native_class = jclass("android.widget.ListView") self.native_instance = self.native_class(context) self.set_data(data) - def set_data(self, data: list): + def set_data(self, data: list) -> "ListView": adapter = jclass("android.widget.ArrayAdapter")( self.context, jclass("android.R$layout").simple_list_item_1, data ) @@ -64,7 +65,7 @@ def __init__(self, data: list = []) -> None: self.native_instance = self.native_class.alloc().init() self.set_data(data) - def set_data(self, data: list): + def set_data(self, data: list) -> "ListView": # Note: This is a simplified representation. Normally, you would need to create a UITableViewDataSource. self.native_instance.reloadData() return self diff --git a/src/pythonnative/material_activity_indicator_view.py b/src/pythonnative/material_activity_indicator_view.py index a568ced..b619960 100644 --- a/src/pythonnative/material_activity_indicator_view.py +++ b/src/pythonnative/material_activity_indicator_view.py @@ -28,10 +28,12 @@ def stop_animating(self) -> None: # https://developer.android.com/reference/com/google/android/material/progressindicator/CircularProgressIndicator # ======================================== + from typing import Any + from java import jclass class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self, context) -> None: + def __init__(self, context: Any) -> None: super().__init__() self.native_class = jclass("com.google.android.material.progressindicator.CircularProgressIndicator") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/material_button.py b/src/pythonnative/material_button.py index 1db600a..65a317b 100644 --- a/src/pythonnative/material_button.py +++ b/src/pythonnative/material_button.py @@ -28,10 +28,12 @@ def get_title(self) -> str: # https://developer.android.com/reference/com/google/android/material/button/MaterialButton # ======================================== + from typing import Any + from java import jclass class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, context, title: str = "") -> None: + def __init__(self, context: Any, title: str = "") -> None: super().__init__() self.native_class = jclass("com.google.android.material.button.MaterialButton") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/material_progress_view.py b/src/pythonnative/material_progress_view.py index 23ca565..afc69c6 100644 --- a/src/pythonnative/material_progress_view.py +++ b/src/pythonnative/material_progress_view.py @@ -28,10 +28,12 @@ def get_progress(self) -> float: # https://developer.android.com/reference/com/google/android/material/progressindicator/LinearProgressIndicator # ======================================== + from typing import Any + from java import jclass class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self, context) -> None: + def __init__(self, context: Any) -> None: super().__init__() self.native_class = jclass("com.google.android.material.progressindicator.LinearProgressIndicator") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/material_search_bar.py b/src/pythonnative/material_search_bar.py index 0693323..9be2e6f 100644 --- a/src/pythonnative/material_search_bar.py +++ b/src/pythonnative/material_search_bar.py @@ -28,10 +28,12 @@ def get_query(self) -> str: # https://developer.android.com/reference/com/google/android/material/search/SearchBar # ======================================== + from typing import Any + from java import jclass class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, context, query: str = "") -> None: + def __init__(self, context: Any, query: str = "") -> None: super().__init__() self.native_class = jclass("com.google.android.material.search.SearchBar") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/material_switch.py b/src/pythonnative/material_switch.py index 21003b5..9ad31fe 100644 --- a/src/pythonnative/material_switch.py +++ b/src/pythonnative/material_switch.py @@ -28,10 +28,12 @@ def is_on(self) -> bool: # https://developer.android.com/reference/com/google/android/material/materialswitch/MaterialSwitch # ======================================== + from typing import Any + from java import jclass class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, context, value: bool = False) -> None: + def __init__(self, context: Any, value: bool = False) -> None: super().__init__() self.native_class = jclass("com.google.android.material.switch.MaterialSwitch") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/material_time_picker.py b/src/pythonnative/material_time_picker.py index 03d7303..7ca3965 100644 --- a/src/pythonnative/material_time_picker.py +++ b/src/pythonnative/material_time_picker.py @@ -28,10 +28,12 @@ def get_time(self) -> tuple: # https://developer.android.com/reference/com/google/android/material/timepicker/MaterialTimePicker # ======================================== + from typing import Any + from java import jclass class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, context, hour: int = 0, minute: int = 0) -> None: + def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: super().__init__() self.native_class = jclass("com.google.android.material.timepicker.MaterialTimePicker") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index e5734e6..77e283c 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -47,7 +47,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_root_view(self, view) -> None: + def set_root_view(self, view: Any) -> None: pass @abstractmethod @@ -104,7 +104,7 @@ def get_args(self) -> dict: return getattr(self, "_args", {}) # Back-compat: navigate_to delegates to push - def navigate_to(self, page) -> None: + def navigate_to(self, page: Any) -> None: self.push(page) pass @@ -118,7 +118,7 @@ def navigate_to(self, page) -> None: from java import jclass class Page(PageBase, ViewBase): - def __init__(self, native_instance) -> None: + def __init__(self, native_instance: Any) -> None: super().__init__() self.native_class = jclass("android.app.Activity") self.native_instance = native_instance @@ -127,7 +127,7 @@ def __init__(self, native_instance) -> None: set_android_context(native_instance) self._args: dict = {} - def set_root_view(self, view) -> None: + def set_root_view(self, view: Any) -> None: # In fragment-based navigation, attach child view to the current fragment container. try: from .utils import get_android_fragment_container @@ -264,7 +264,7 @@ def forward_lifecycle(native_addr: int, event: str) -> None: pass class Page(PageBase, ViewBase): - def __init__(self, native_instance) -> None: + def __init__(self, native_instance: Any) -> None: super().__init__() self.native_class = ObjCClass("UIViewController") # If Swift passed us an integer pointer, wrap it as an ObjCInstance. @@ -280,7 +280,7 @@ def __init__(self, native_instance) -> None: if self.native_instance is not None: _ios_register_page(self.native_instance, self) - def set_root_view(self, view) -> None: + def set_root_view(self, view: Any) -> None: # UIViewController.view is a property; access without calling. root_view = self.native_instance.view # Size the root child to fill the controller's view and enable autoresizing diff --git a/src/pythonnative/picker_view.py b/src/pythonnative/picker_view.py index fc7ca98..07a5c64 100644 --- a/src/pythonnative/picker_view.py +++ b/src/pythonnative/picker_view.py @@ -28,10 +28,12 @@ def get_selected(self) -> int: # https://developer.android.com/reference/android/widget/Spinner # ======================================== + from typing import Any + from java import jclass class PickerView(PickerViewBase, ViewBase): - def __init__(self, context, index: int = 0) -> None: + def __init__(self, context: Any, index: int = 0) -> None: super().__init__() self.native_class = jclass("android.widget.Spinner") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py index 13c5591..57593c1 100644 --- a/src/pythonnative/scroll_view.py +++ b/src/pythonnative/scroll_view.py @@ -16,7 +16,7 @@ def __init__(self) -> None: self.views: List[Any] = [] @abstractmethod - def add_view(self, view) -> None: + def add_view(self, view: Any) -> None: pass @@ -35,7 +35,7 @@ def __init__(self) -> None: context = get_android_context() self.native_instance = self.native_class(context) - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) # In Android, ScrollView can host only one direct child if len(self.views) == 1: @@ -44,7 +44,7 @@ def add_view(self, view): raise Exception("ScrollView can host only one direct child") @staticmethod - def wrap(view: Any): + def wrap(view: Any) -> "ScrollView": """Return a new ScrollView containing the provided view as its only child.""" sv = ScrollView() sv.add_view(view) @@ -64,14 +64,14 @@ def __init__(self) -> None: self.native_class = ObjCClass("UIScrollView") self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) # Ensure view is a subview of scrollview if view.native_instance not in self.native_instance.subviews: self.native_instance.addSubview_(view.native_instance) @staticmethod - def wrap(view: Any): + def wrap(view: Any) -> "ScrollView": """Return a new ScrollView containing the provided view as its only child.""" sv = ScrollView() sv.add_view(view) diff --git a/src/pythonnative/search_bar.py b/src/pythonnative/search_bar.py index 72609ff..257c401 100644 --- a/src/pythonnative/search_bar.py +++ b/src/pythonnative/search_bar.py @@ -28,10 +28,12 @@ def get_query(self) -> str: # https://developer.android.com/reference/android/widget/SearchView # ======================================== + from typing import Any + from java import jclass class SearchBar(SearchBarBase, ViewBase): - def __init__(self, context, query: str = "") -> None: + def __init__(self, context: Any, query: str = "") -> None: super().__init__() self.native_class = jclass("android.widget.SearchView") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/stack_view.py b/src/pythonnative/stack_view.py index 004ca7e..cb2273c 100644 --- a/src/pythonnative/stack_view.py +++ b/src/pythonnative/stack_view.py @@ -16,19 +16,19 @@ def __init__(self) -> None: self.views: List[Any] = [] @abstractmethod - def add_view(self, view) -> None: + def add_view(self, view: Any) -> None: pass @abstractmethod - def set_axis(self, axis: str): + def set_axis(self, axis: str) -> "StackViewBase": pass @abstractmethod - def set_spacing(self, spacing): + def set_spacing(self, spacing: float) -> "StackViewBase": pass @abstractmethod - def set_alignment(self, alignment: str): + def set_alignment(self, alignment: str) -> "StackViewBase": pass @@ -51,7 +51,7 @@ def __init__(self) -> None: self._context = context self._axis = "vertical" - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) # Apply margins if the child has any recorded (supported for LinearLayout) try: @@ -82,7 +82,7 @@ def add_view(self, view): pass self.native_instance.addView(view.native_instance) - def set_axis(self, axis: str): + def set_axis(self, axis: str) -> "StackView": """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" axis_l = (axis or "").lower() if axis_l not in ("vertical", "horizontal"): @@ -92,11 +92,11 @@ def set_axis(self, axis: str): self._axis = axis_l return self - def set_spacing(self, spacing_dp: int): + def set_spacing(self, spacing: float) -> "StackView": """Set spacing between children in dp (Android: uses LinearLayout dividers). Returns self.""" try: density = self._context.getResources().getDisplayMetrics().density - px = max(0, int(spacing_dp * density)) + px = max(0, int(spacing * density)) # Use a transparent GradientDrawable with specified size as divider GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") drawable = GradientDrawable() @@ -111,7 +111,7 @@ def set_spacing(self, spacing_dp: int): pass return self - def set_alignment(self, alignment: str): + def set_alignment(self, alignment: str) -> "StackView": """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" try: Gravity = jclass("android.view.Gravity") @@ -156,11 +156,11 @@ def __init__(self) -> None: # Default to vertical axis self.native_instance.setAxis_(1) - def add_view(self, view): + def add_view(self, view: Any) -> None: self.views.append(view) self.native_instance.addArrangedSubview_(view.native_instance) - def set_axis(self, axis: str): + def set_axis(self, axis: str) -> "StackView": """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" axis_l = (axis or "").lower() value = 1 if axis_l == "vertical" else 0 @@ -170,7 +170,7 @@ def set_axis(self, axis: str): pass return self - def set_spacing(self, spacing: float): + def set_spacing(self, spacing: float) -> "StackView": """Set spacing between arranged subviews. Returns self.""" try: self.native_instance.setSpacing_(float(spacing)) @@ -178,7 +178,7 @@ def set_spacing(self, spacing: float): pass return self - def set_alignment(self, alignment: str): + def set_alignment(self, alignment: str) -> "StackView": """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" a = (alignment or "").lower() # UIStackViewAlignment: Fill=0, Leading/Top=1, Center=3, Trailing/Bottom=4 diff --git a/src/pythonnative/text_field.py b/src/pythonnative/text_field.py index f78ee90..d6d1de1 100644 --- a/src/pythonnative/text_field.py +++ b/src/pythonnative/text_field.py @@ -15,7 +15,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextFieldBase": pass @abstractmethod @@ -23,11 +23,11 @@ def get_text(self) -> str: pass @abstractmethod - def set_text_color(self, color: Any) -> None: + def set_text_color(self, color: Any) -> "TextFieldBase": pass @abstractmethod - def set_text_size(self, size: float) -> None: + def set_text_size(self, size: float) -> "TextFieldBase": pass @@ -48,14 +48,14 @@ def __init__(self, text: str = "") -> None: self.native_instance.setSingleLine(True) self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> "TextField": self.native_instance.setText(text) return self def get_text(self) -> str: return self.native_instance.getText().toString() - def set_text_color(self, color: Any): + def set_text_color(self, color: Any) -> "TextField": if isinstance(color, str): c = color.strip() if c.startswith("#"): @@ -71,7 +71,7 @@ def set_text_color(self, color: Any): pass return self - def set_text_size(self, size_sp: float): + def set_text_size(self, size_sp: float) -> "TextField": try: self.native_instance.setTextSize(float(size_sp)) except Exception: @@ -93,14 +93,14 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> "TextField": self.native_instance.setText_(text) return self def get_text(self) -> str: return self.native_instance.text() - def set_text_color(self, color: Any): + def set_text_color(self, color: Any) -> "TextField": if isinstance(color, str): c = color.strip() if c.startswith("#"): @@ -122,7 +122,7 @@ def set_text_color(self, color: Any): pass return self - def set_text_size(self, size: float): + def set_text_size(self, size: float) -> "TextField": try: UIFont = ObjCClass("UIFont") font = UIFont.systemFontOfSize_(float(size)) diff --git a/src/pythonnative/text_view.py b/src/pythonnative/text_view.py index 6c0d4ec..1e997bf 100644 --- a/src/pythonnative/text_view.py +++ b/src/pythonnative/text_view.py @@ -15,7 +15,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_text(self, text: str) -> None: + def set_text(self, text: str) -> "TextViewBase": pass @abstractmethod @@ -23,11 +23,11 @@ def get_text(self) -> str: pass @abstractmethod - def set_text_color(self, color: Any) -> None: + def set_text_color(self, color: Any) -> "TextViewBase": pass @abstractmethod - def set_text_size(self, size: float) -> None: + def set_text_size(self, size: float) -> "TextViewBase": pass @@ -51,14 +51,14 @@ def __init__(self, text: str = "") -> None: # self.native_instance.movementMethod = ScrollingMovementMethod() self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> "TextView": self.native_instance.setText(text) return self def get_text(self) -> str: return self.native_instance.getText().toString() - def set_text_color(self, color: Any): + def set_text_color(self, color: Any) -> "TextView": if isinstance(color, str): c = color.strip() if c.startswith("#"): @@ -74,7 +74,7 @@ def set_text_color(self, color: Any): pass return self - def set_text_size(self, size_sp: float): + def set_text_size(self, size_sp: float) -> "TextView": try: self.native_instance.setTextSize(float(size_sp)) except Exception: @@ -96,14 +96,14 @@ def __init__(self, text: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_text(text) - def set_text(self, text: str): + def set_text(self, text: str) -> "TextView": self.native_instance.setText_(text) return self def get_text(self) -> str: return self.native_instance.text() - def set_text_color(self, color: Any): + def set_text_color(self, color: Any) -> "TextView": if isinstance(color, str): c = color.strip() if c.startswith("#"): @@ -125,7 +125,7 @@ def set_text_color(self, color: Any): pass return self - def set_text_size(self, size: float): + def set_text_size(self, size: float) -> "TextView": try: UIFont = ObjCClass("UIFont") font = UIFont.systemFontOfSize_(float(size)) diff --git a/src/pythonnative/time_picker.py b/src/pythonnative/time_picker.py index b12a395..4088f7c 100644 --- a/src/pythonnative/time_picker.py +++ b/src/pythonnative/time_picker.py @@ -28,10 +28,12 @@ def get_time(self) -> tuple: # https://developer.android.com/reference/android/widget/TimePicker # ======================================== + from typing import Any + from java import jclass class TimePicker(TimePickerBase, ViewBase): - def __init__(self, context, hour: int = 0, minute: int = 0) -> None: + def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: super().__init__() self.native_class = jclass("android.widget.TimePicker") self.native_instance = self.native_class(context) diff --git a/src/pythonnative/view.py b/src/pythonnative/view.py index bf6356f..abef7f6 100644 --- a/src/pythonnative/view.py +++ b/src/pythonnative/view.py @@ -18,7 +18,7 @@ def __init__(self) -> None: # Lightweight style helpers # ======================================== - def set_background_color(self, color: Any): + def set_background_color(self, color: Any) -> "ViewBase": """Set background color. Accepts platform color int or CSS-like hex string. Returns self.""" try: from .utils import IS_ANDROID @@ -71,7 +71,7 @@ def set_padding( all: Optional[int] = None, horizontal: Optional[int] = None, vertical: Optional[int] = None, - ): + ) -> "ViewBase": """Set padding (dp on Android; best-effort on iOS where supported). Returns self. When provided, 'all' applies to all sides; 'horizontal' applies to left and right; @@ -121,7 +121,7 @@ def set_margin( all: Optional[int] = None, horizontal: Optional[int] = None, vertical: Optional[int] = None, - ): + ) -> "ViewBase": """Record margins for this view (applied where supported). Returns self. Currently applied automatically when added to Android LinearLayout (StackView). @@ -148,7 +148,7 @@ def set_margin( pass return self - def wrap_in_scroll(self): + def wrap_in_scroll(self) -> Any: """Return a ScrollView containing this view as its only child. Returns the ScrollView.""" try: # Local import to avoid circulars diff --git a/tests/test_cli.py b/tests/test_cli.py index f9b0eaf..4a1eac9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,14 +3,15 @@ import subprocess import sys import tempfile +from typing import List -def run_pn(args, cwd): +def run_pn(args: List[str], cwd: str) -> subprocess.CompletedProcess[str]: cmd = [sys.executable, "-m", "pythonnative.cli.pn"] + args return subprocess.run(cmd, cwd=cwd, check=False, capture_output=True, text=True) -def test_cli_init_and_clean(): +def test_cli_init_and_clean() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: # init @@ -40,7 +41,7 @@ def test_cli_init_and_clean(): shutil.rmtree(tmpdir, ignore_errors=True) -def test_cli_run_prepare_only_android_and_ios(): +def test_cli_run_prepare_only_android_and_ios() -> None: tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") try: # init to create app scaffold diff --git a/tests/test_smoke.py b/tests/test_smoke.py index f5c04c4..d346c62 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,2 +1,2 @@ -def test_pytest_workflow_smoke(): +def test_pytest_workflow_smoke() -> None: assert 2 + 2 == 4 From d236d899690a4033effdcab4862a556a742fa6d1 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 29 Oct 2025 13:55:18 -0700 Subject: [PATCH 04/34] feat(components): standardize fluent setters and align base signatures --- src/pythonnative/date_picker.py | 8 +++++--- src/pythonnative/image_view.py | 8 +++++--- src/pythonnative/material_button.py | 8 +++++--- src/pythonnative/material_date_picker.py | 8 +++++--- src/pythonnative/material_progress_view.py | 8 +++++--- src/pythonnative/material_search_bar.py | 8 +++++--- src/pythonnative/material_switch.py | 8 +++++--- src/pythonnative/material_time_picker.py | 8 +++++--- src/pythonnative/picker_view.py | 8 +++++--- src/pythonnative/progress_view.py | 8 +++++--- src/pythonnative/search_bar.py | 8 +++++--- src/pythonnative/switch.py | 8 +++++--- src/pythonnative/time_picker.py | 8 +++++--- src/pythonnative/web_view.py | 8 +++++--- 14 files changed, 70 insertions(+), 42 deletions(-) diff --git a/src/pythonnative/date_picker.py b/src/pythonnative/date_picker.py index 7d161bd..f357e03 100644 --- a/src/pythonnative/date_picker.py +++ b/src/pythonnative/date_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "DatePickerBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any, year: int = 0, month: int = 0, day: int = 0) -> self.native_instance = self.native_class(context) self.set_date(year, month, day) - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "DatePicker": self.native_instance.updateDate(year, month, day) + return self def get_date(self) -> tuple: year = self.native_instance.getYear() @@ -65,9 +66,10 @@ def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: self.native_instance = self.native_class.alloc().init() self.set_date(year, month, day) - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "DatePicker": date = datetime(year, month, day) self.native_instance.setDate_(date) + return self def get_date(self) -> tuple: date = self.native_instance.date() diff --git a/src/pythonnative/image_view.py b/src/pythonnative/image_view.py index 78cb1ff..c3b3d1a 100644 --- a/src/pythonnative/image_view.py +++ b/src/pythonnative/image_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_image(self, image: str) -> None: + def set_image(self, image: str) -> "ImageViewBase": pass @abstractmethod @@ -40,9 +40,10 @@ def __init__(self, image: str = "") -> None: if image: self.set_image(image) - def set_image(self, image: str) -> None: + def set_image(self, image: str) -> "ImageView": bitmap = BitmapFactory.decodeFile(image) self.native_instance.setImageBitmap(bitmap) + return self def get_image(self) -> str: # Please note that this is a simplistic representation, getting image from ImageView @@ -66,10 +67,11 @@ def __init__(self, image: str = "") -> None: if image: self.set_image(image) - def set_image(self, image: str) -> None: + def set_image(self, image: str) -> "ImageView": ns_str = NSString.alloc().initWithUTF8String_(image) ui_image = UIImage.imageNamed_(ns_str) self.native_instance.setImage_(ui_image) + return self def get_image(self) -> str: # Similar to Android, getting the image from UIImageView isn't straightforward. diff --git a/src/pythonnative/material_button.py b/src/pythonnative/material_button.py index 65a317b..5816e30 100644 --- a/src/pythonnative/material_button.py +++ b/src/pythonnative/material_button.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "MaterialButtonBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any, title: str = "") -> None: self.native_instance = self.native_class(context) self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "MaterialButton": self.native_instance.setText(title) + return self def get_title(self) -> str: return self.native_instance.getText().toString() @@ -60,8 +61,9 @@ def __init__(self, title: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_title(title) - def set_title(self, title: str) -> None: + def set_title(self, title: str) -> "MaterialButton": self.native_instance.setTitle_forState_(title, 0) + return self def get_title(self) -> str: return self.native_instance.titleForState_(0) diff --git a/src/pythonnative/material_date_picker.py b/src/pythonnative/material_date_picker.py index 0eadeec..f39ba3e 100644 --- a/src/pythonnative/material_date_picker.py +++ b/src/pythonnative/material_date_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "MaterialDatePickerBase": pass @abstractmethod @@ -38,7 +38,7 @@ def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: self.set_date(year, month, day) self.native_instance = self.builder.build() - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": # MaterialDatePicker uses milliseconds since epoch to set date from java.util import Calendar @@ -46,6 +46,7 @@ def set_date(self, year: int, month: int, day: int) -> None: cal.set(year, month, day) milliseconds = cal.getTimeInMillis() self.builder.setSelection(milliseconds) + return self def get_date(self) -> tuple: # Convert selection (milliseconds since epoch) back to a date @@ -76,9 +77,10 @@ def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: self.native_instance = self.native_class.alloc().init() self.set_date(year, month, day) - def set_date(self, year: int, month: int, day: int) -> None: + def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": date = datetime(year, month, day) self.native_instance.setDate_(date) + return self def get_date(self) -> tuple: date = self.native_instance.date() diff --git a/src/pythonnative/material_progress_view.py b/src/pythonnative/material_progress_view.py index afc69c6..2b76275 100644 --- a/src/pythonnative/material_progress_view.py +++ b/src/pythonnative/material_progress_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "MaterialProgressViewBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any) -> None: self.native_instance = self.native_class(context) self.native_instance.setIndeterminate(False) - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "MaterialProgressView": self.native_instance.setProgress(int(progress * 100)) + return self def get_progress(self) -> float: return self.native_instance.getProgress() / 100.0 @@ -61,8 +62,9 @@ def __init__(self) -> None: 0 ) # 0: UIProgressViewStyleDefault - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "MaterialProgressView": self.native_instance.setProgress_animated_(progress, False) + return self def get_progress(self) -> float: return self.native_instance.progress() diff --git a/src/pythonnative/material_search_bar.py b/src/pythonnative/material_search_bar.py index 9be2e6f..950161c 100644 --- a/src/pythonnative/material_search_bar.py +++ b/src/pythonnative/material_search_bar.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "MaterialSearchBarBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any, query: str = "") -> None: self.native_instance = self.native_class(context) self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "MaterialSearchBar": self.native_instance.setQuery(query, False) + return self def get_query(self) -> str: return self.native_instance.getQuery().toString() @@ -60,8 +61,9 @@ def __init__(self, query: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "MaterialSearchBar": self.native_instance.set_searchText_(query) + return self def get_query(self) -> str: return self.native_instance.searchText() diff --git a/src/pythonnative/material_switch.py b/src/pythonnative/material_switch.py index 9ad31fe..20e9bab 100644 --- a/src/pythonnative/material_switch.py +++ b/src/pythonnative/material_switch.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "MaterialSwitchBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any, value: bool = False) -> None: self.native_instance = self.native_class(context) self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "MaterialSwitch": self.native_instance.setChecked(value) + return self def is_on(self) -> bool: return self.native_instance.isChecked() @@ -60,8 +61,9 @@ def __init__(self, value: bool = False) -> None: self.native_instance = self.native_class.alloc().init() self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "MaterialSwitch": self.native_instance.setOn_animated_(value, False) + return self def is_on(self) -> bool: return self.native_instance.isOn() diff --git a/src/pythonnative/material_time_picker.py b/src/pythonnative/material_time_picker.py index 7ca3965..5829ca7 100644 --- a/src/pythonnative/material_time_picker.py +++ b/src/pythonnative/material_time_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "MaterialTimePickerBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: self.native_instance = self.native_class(context) self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": self.native_instance.setTime(hour, minute) + return self def get_time(self) -> tuple: hour = self.native_instance.getHour() @@ -65,9 +66,10 @@ def __init__(self, hour: int = 0, minute: int = 0) -> None: self.native_instance.setDatePickerMode_(1) # Setting mode to Time self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": t = time(hour, minute) self.native_instance.setTime_(t) + return self def get_time(self) -> tuple: t = self.native_instance.time() diff --git a/src/pythonnative/picker_view.py b/src/pythonnative/picker_view.py index 07a5c64..7f1ae15 100644 --- a/src/pythonnative/picker_view.py +++ b/src/pythonnative/picker_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_selected(self, index: int) -> None: + def set_selected(self, index: int) -> "PickerViewBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any, index: int = 0) -> None: self.native_instance = self.native_class(context) self.set_selected(index) - def set_selected(self, index: int) -> None: + def set_selected(self, index: int) -> "PickerView": self.native_instance.setSelection(index) + return self def get_selected(self) -> int: return self.native_instance.getSelectedItemPosition() @@ -60,8 +61,9 @@ def __init__(self, index: int = 0) -> None: self.native_instance = self.native_class.alloc().init() self.set_selected(index) - def set_selected(self, index: int) -> None: + def set_selected(self, index: int) -> "PickerView": self.native_instance.selectRow_inComponent_animated_(index, 0, False) + return self def get_selected(self) -> int: return self.native_instance.selectedRowInComponent_(0) diff --git a/src/pythonnative/progress_view.py b/src/pythonnative/progress_view.py index c1a08b3..5587170 100644 --- a/src/pythonnative/progress_view.py +++ b/src/pythonnative/progress_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "ProgressViewBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self) -> None: self.native_instance = self.native_class(context, None, jclass("android.R$attr").progressBarStyleHorizontal) self.native_instance.setIndeterminate(False) - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "ProgressView": self.native_instance.setProgress(int(progress * 100)) + return self def get_progress(self) -> float: return self.native_instance.getProgress() / 100.0 @@ -61,8 +62,9 @@ def __init__(self) -> None: 0 ) # 0: UIProgressViewStyleDefault - def set_progress(self, progress: float) -> None: + def set_progress(self, progress: float) -> "ProgressView": self.native_instance.setProgress_animated_(progress, False) + return self def get_progress(self) -> float: return self.native_instance.progress() diff --git a/src/pythonnative/search_bar.py b/src/pythonnative/search_bar.py index 257c401..ae33da1 100644 --- a/src/pythonnative/search_bar.py +++ b/src/pythonnative/search_bar.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "SearchBarBase": pass @abstractmethod @@ -39,8 +39,9 @@ def __init__(self, context: Any, query: str = "") -> None: self.native_instance = self.native_class(context) self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "SearchBar": self.native_instance.setQuery(query, False) + return self def get_query(self) -> str: return self.native_instance.getQuery().toString() @@ -60,8 +61,9 @@ def __init__(self, query: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.set_query(query) - def set_query(self, query: str) -> None: + def set_query(self, query: str) -> "SearchBar": self.native_instance.set_text_(query) + return self def get_query(self) -> str: return self.native_instance.text() diff --git a/src/pythonnative/switch.py b/src/pythonnative/switch.py index bdd38bc..55d95ba 100644 --- a/src/pythonnative/switch.py +++ b/src/pythonnative/switch.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "SwitchBase": pass @abstractmethod @@ -38,8 +38,9 @@ def __init__(self, value: bool = False) -> None: self.native_instance = self.native_class(context) self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "Switch": self.native_instance.setChecked(value) + return self def is_on(self) -> bool: return self.native_instance.isChecked() @@ -59,8 +60,9 @@ def __init__(self, value: bool = False) -> None: self.native_instance = self.native_class.alloc().init() self.set_on(value) - def set_on(self, value: bool) -> None: + def set_on(self, value: bool) -> "Switch": self.native_instance.setOn_animated_(value, False) + return self def is_on(self) -> bool: return self.native_instance.isOn() diff --git a/src/pythonnative/time_picker.py b/src/pythonnative/time_picker.py index 4088f7c..d9085b9 100644 --- a/src/pythonnative/time_picker.py +++ b/src/pythonnative/time_picker.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "TimePickerBase": pass @abstractmethod @@ -39,9 +39,10 @@ def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: self.native_instance = self.native_class(context) self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "TimePicker": self.native_instance.setHour(hour) self.native_instance.setMinute(minute) + return self def get_time(self) -> tuple: hour = self.native_instance.getHour() @@ -66,9 +67,10 @@ def __init__(self, hour: int = 0, minute: int = 0) -> None: self.native_instance.setDatePickerMode_(1) # Setting mode to Time self.set_time(hour, minute) - def set_time(self, hour: int, minute: int) -> None: + def set_time(self, hour: int, minute: int) -> "TimePicker": t = time(hour, minute) self.native_instance.setTime_(t) + return self def get_time(self) -> tuple: t = self.native_instance.time() diff --git a/src/pythonnative/web_view.py b/src/pythonnative/web_view.py index ee3a16e..b72d51b 100644 --- a/src/pythonnative/web_view.py +++ b/src/pythonnative/web_view.py @@ -14,7 +14,7 @@ def __init__(self) -> None: super().__init__() @abstractmethod - def load_url(self, url: str) -> None: + def load_url(self, url: str) -> "WebViewBase": pass @@ -34,8 +34,9 @@ def __init__(self, url: str = "") -> None: self.native_instance = self.native_class(context) self.load_url(url) - def load_url(self, url: str) -> None: + def load_url(self, url: str) -> "WebView": self.native_instance.loadUrl(url) + return self else: # ======================================== @@ -52,7 +53,8 @@ def __init__(self, url: str = "") -> None: self.native_instance = self.native_class.alloc().init() self.load_url(url) - def load_url(self, url: str) -> None: + def load_url(self, url: str) -> "WebView": ns_url = NSURL.URLWithString_(url) request = NSURLRequest.requestWithURL_(ns_url) self.native_instance.loadRequest_(request) + return self From 593fee4fcf66678cb026de58115f959633d859b4 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:18:14 -0700 Subject: [PATCH 05/34] refactor(components): declare abstract static wrap in ScrollViewBase --- src/pythonnative/scroll_view.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py index 57593c1..a079610 100644 --- a/src/pythonnative/scroll_view.py +++ b/src/pythonnative/scroll_view.py @@ -19,6 +19,12 @@ def __init__(self) -> None: def add_view(self, view: Any) -> None: pass + @staticmethod + @abstractmethod + def wrap(view: Any) -> "ScrollViewBase": + """Return a new ScrollView containing the provided view as its only child.""" + pass + if IS_ANDROID: # ======================================== From d7ac93be202161a5c8328816a5c6ff8a96dde1d5 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 29 Oct 2025 22:45:23 -0700 Subject: [PATCH 06/34] fix(components,templates): restore hello-world on iOS and Android --- src/pythonnative/button.py | 2 +- src/pythonnative/scroll_view.py | 21 +++++++++++++++++-- .../android_template/app/build.gradle | 4 ++-- .../app/src/main/res/navigation/nav_graph.xml | 2 +- .../templates/android_template/build.gradle | 6 +++--- .../gradle/wrapper/gradle-wrapper.properties | 2 +- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/pythonnative/button.py b/src/pythonnative/button.py index 7c714a4..9d96904 100644 --- a/src/pythonnative/button.py +++ b/src/pythonnative/button.py @@ -79,7 +79,7 @@ class _PNButtonHandler(NSObject): # type: ignore[valid-type] _callback: Optional[Callable[[], None]] = None @objc_method - def onTap_(self, sender: Any) -> None: + def onTap_(self, sender: object) -> None: try: callback = self._callback if callback is not None: diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py index a079610..b532cfe 100644 --- a/src/pythonnative/scroll_view.py +++ b/src/pythonnative/scroll_view.py @@ -72,9 +72,26 @@ def __init__(self) -> None: def add_view(self, view: Any) -> None: self.views.append(view) - # Ensure view is a subview of scrollview - if view.native_instance not in self.native_instance.subviews: + # Add as subview and size child to fill scroll view by default so content is visible + try: self.native_instance.addSubview_(view.native_instance) + except Exception: + pass + # Default layout: if the child has no size yet, size it to fill the scroll view + # and enable flexible width/height. If the child is already sized explicitly, + # leave it unchanged. + try: + frame = getattr(view.native_instance, "frame") + size = getattr(frame, "size", None) + width = getattr(size, "width", 0) if size is not None else 0 + height = getattr(size, "height", 0) if size is not None else 0 + if width <= 0 or height <= 0: + bounds = self.native_instance.bounds + view.native_instance.setFrame_(bounds) + # UIViewAutoresizingFlexibleWidth (2) | UIViewAutoresizingFlexibleHeight (16) + view.native_instance.setAutoresizingMask_(2 | 16) + except Exception: + pass @staticmethod def wrap(view: Any) -> "ScrollView": diff --git a/src/pythonnative/templates/android_template/app/build.gradle b/src/pythonnative/templates/android_template/app/build.gradle index 2fb8251..f23ab96 100644 --- a/src/pythonnative/templates/android_template/app/build.gradle +++ b/src/pythonnative/templates/android_template/app/build.gradle @@ -6,12 +6,12 @@ plugins { android { namespace 'com.pythonnative.android_template' - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "com.pythonnative.android_template" minSdk 24 - targetSdk 33 + targetSdk 34 versionCode 1 versionName "1.0" diff --git a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml index cbf90d7..182bed8 100644 --- a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +++ b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml @@ -16,7 +16,7 @@ + app:nullable="true" /> diff --git a/src/pythonnative/templates/android_template/build.gradle b/src/pythonnative/templates/android_template/build.gradle index ff78675..3d20b92 100644 --- a/src/pythonnative/templates/android_template/build.gradle +++ b/src/pythonnative/templates/android_template/build.gradle @@ -1,7 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.0.2' apply false - id 'com.android.library' version '8.0.2' apply false - id 'org.jetbrains.kotlin.android' version '1.8.20' apply false + id 'com.android.application' version '8.2.2' apply false + id 'com.android.library' version '8.2.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false id 'com.chaquo.python' version '14.0.2' apply false } \ No newline at end of file diff --git a/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties b/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties index f53bdea..bfe5fe7 100644 --- a/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +++ b/src/pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Jun 19 11:09:16 PDT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 0711683f5b56751027bb1a5a63ee2d9afcd4b620 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:35:16 -0800 Subject: [PATCH 07/34] ci(workflows): add semantic-release pipeline and PR commit linting --- .commitlintrc.yml | 30 ++++++++++++++++ .github/workflows/pr-lint.yml | 46 ++++++++++++++++++++++++ .github/workflows/release.yml | 66 +++++++++++++++++++++++++++++------ CONTRIBUTING.md | 26 ++++++++++---- pyproject.toml | 27 ++++++++++++++ 5 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 .commitlintrc.yml create mode 100644 .github/workflows/pr-lint.yml diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..455e892 --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,30 @@ +extends: + - '@commitlint/config-conventional' + +rules: + type-enum: + - 2 + - always + - - build + - chore + - ci + - docs + - feat + - fix + - perf + - refactor + - revert + - style + - test + scope-case: + - 0 + subject-case: + - 0 + header-max-length: + - 1 + - always + - 100 + body-max-line-length: + - 0 + footer-max-line-length: + - 0 diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml new file mode 100644 index 0000000..f616d1a --- /dev/null +++ b/.github/workflows/pr-lint.yml @@ -0,0 +1,46 @@ +name: PR Lint + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + pr-title: + name: PR title (Conventional Commits) + runs-on: ubuntu-latest + steps: + - name: Validate PR title + uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + build + chore + ci + docs + feat + fix + perf + refactor + revert + style + test + requireScope: false + subjectPattern: ^[a-z].+[^.]$ + subjectPatternError: | + Subject "{subject}" must start with a lowercase letter and must not + end with a period. + Example: "feat(cli): add init subcommand" + + commits: + name: Commit messages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07450cc..38c8bfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,31 +1,77 @@ -name: Release to PyPI +name: Release on: push: - tags: - - 'v*.*.*' + branches: [main] + workflow_dispatch: + +# ┌─────────────────────────────────────────────────────────────────┐ +# │ DRAFT_RELEASE │ +# │ │ +# │ "true" → GitHub Releases are created as drafts and PyPI │ +# │ publishing is skipped (burn-in / review period). │ +# │ "false" → GitHub Releases are published immediately and the │ +# │ package is uploaded to PyPI. │ +# │ │ +# │ Flip to "false" once you have verified the release output. │ +# └─────────────────────────────────────────────────────────────────┘ +env: + DRAFT_RELEASE: "true" jobs: - build-and-publish: + release: + name: Semantic Release runs-on: ubuntu-latest + if: >- + github.event_name == 'workflow_dispatch' || + !startsWith(github.event.head_commit.message, 'chore(release):') + concurrency: + group: release + cancel-in-progress: false permissions: - contents: read + contents: write id-token: write + steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' + + - name: Install build tools + run: python -m pip install -U pip build + + - name: Python Semantic Release + id: release + uses: python-semantic-release/python-semantic-release@v9 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build package + if: steps.release.outputs.released == 'true' + run: python -m build - - name: Build sdist and wheel + - name: Create GitHub Release + if: steps.release.outputs.released == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - python -m pip install -U pip build - python -m build + if [ "$DRAFT_RELEASE" = "true" ]; then + DRAFT_FLAG="--draft" + fi + gh release create "${{ steps.release.outputs.tag }}" \ + ${DRAFT_FLAG:-} \ + --title "${{ steps.release.outputs.tag }}" \ + --generate-notes \ + dist/* - - name: Publish package to PyPI + - name: Publish to PyPI + if: steps.release.outputs.released == 'true' && env.DRAFT_RELEASE != 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0298c8..fe5b6ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,19 +205,26 @@ Co-authored-by: Name ## Pull request checklist +- PR title: Conventional Commits format (CI-enforced by `pr-lint.yml`). - Tests: added/updated; `pytest` passes. - Lint/format: `ruff check .`, `black` pass. -- Docs: update `README.md` and any Django docs pages if behavior changes. +- Docs: update `README.md` if behavior changes. - Templates: update `templates/` if generator output changes. - No generated artifacts committed. ## Versioning and releases -- The library version is tracked in `pyproject.toml` (`project.version`). Use SemVer. -- Workflow: - - Contributors: branch off `main` (or `dev` if used) and open PRs. - - Maintainer (release): bump version, tag, and publish to PyPI. - - Tag on `main`: `git tag -a vX.Y.Z -m "Release vX.Y.Z" && git push --tags`. +- The version is tracked in `pyproject.toml` (`project.version`) and mirrored in `src/pythonnative/__init__.py` as `__version__`. Both files are updated automatically by [python-semantic-release](https://python-semantic-release.readthedocs.io/). +- **Automated release pipeline** (on every merge to `main`): + 1. `python-semantic-release` scans Conventional Commit messages since the last tag. + 2. It determines the next SemVer bump: `feat` → **minor**, `fix`/`perf` → **patch**, `BREAKING CHANGE` → **major** (minor while version < 1.0). + 3. Version files are updated, `CHANGELOG.md` is generated, and a tagged release commit (`chore(release): vX.Y.Z`) is pushed. + 4. A GitHub Release is created with auto-generated release notes and the built sdist/wheel attached. + 5. When drafts are disabled, the package is also published to PyPI via Trusted Publishing. +- **Draft / published toggle**: the `DRAFT_RELEASE` variable at the top of `.github/workflows/release.yml` controls release mode. Set to `"true"` (the default) for draft GitHub Releases with PyPI publishing skipped; flip to `"false"` to publish releases and upload to PyPI immediately. +- Commit types that trigger a release: `feat` (minor), `fix` and `perf` (patch), `BREAKING CHANGE` (major). All other types (`build`, `chore`, `ci`, `docs`, `refactor`, `revert`, `style`, `test`) are recorded in the changelog but do **not** trigger a release on their own. +- Tag format: `v`-prefixed (e.g., `v0.4.0`). +- Manual version bumps are no longer needed — just merge PRs with valid Conventional Commit titles. For ad-hoc runs, use the workflow's **Run workflow** button (`workflow_dispatch`). ### Branch naming (suggested) @@ -249,6 +256,13 @@ release/v0.2.0 hotfix/cli-regression ``` +### CI + +- **CI** (`ci.yml`): runs formatter, linter, type checker, and tests on every push and PR. +- **PR Lint** (`pr-lint.yml`): validates the PR title against Conventional Commits format (protects squash merges) and checks individual commit messages via commitlint (protects rebase merges). Recommended: add the **PR title** job as a required status check in branch-protection settings. +- **Release** (`release.yml`): runs on merge to `main`; computes version, generates changelog, tags, creates GitHub Release, and (when `DRAFT_RELEASE` is `"false"`) publishes to PyPI. +- **Docs** (`docs.yml`): deploys documentation to GitHub Pages on push to `main`. + ## Security and provenance - Avoid bundling secrets or credentials in templates or code. diff --git a/pyproject.toml b/pyproject.toml index 0cdbd85..5c99353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,3 +88,30 @@ ignore = [] [tool.black] line-length = 120 target-version = ['py39'] + +# ── Semantic Release ──────────────────────────────────────────────── + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +version_variables = ["src/pythonnative/__init__.py:__version__"] +commit_message = "chore(release): v{version}" +tag_format = "v{version}" +major_on_zero = false + +[tool.semantic_release.branches.main] +match = "main" +prerelease = false + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" +exclude_commit_patterns = [ + "^chore\\(release\\):", +] + +[tool.semantic_release.commit_parser_options] +allowed_tags = [ + "build", "chore", "ci", "docs", "feat", "fix", + "perf", "refactor", "revert", "style", "test", +] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] From 1cd5393e7bf20d5350052cfaa81fd511dc4ca3ca Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:16:31 -0800 Subject: [PATCH 08/34] ci(workflows): fix duplicate release, and use changelog for release notes --- .github/workflows/release.yml | 58 +++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38c8bfe..470fdf4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,17 +3,19 @@ name: Release on: push: branches: [main] + release: + types: [published] workflow_dispatch: # ┌─────────────────────────────────────────────────────────────────┐ # │ DRAFT_RELEASE │ # │ │ -# │ "true" → GitHub Releases are created as drafts and PyPI │ -# │ publishing is skipped (burn-in / review period). │ -# │ "false" → GitHub Releases are published immediately and the │ -# │ package is uploaded to PyPI. │ +# │ "true" → GitHub Releases are created as drafts; review them │ +# │ on GitHub, then click "Publish" to trigger PyPI. │ +# │ "false" → GitHub Releases are published immediately, which │ +# │ automatically triggers the PyPI publish job. │ # │ │ -# │ Flip to "false" once you have verified the release output. │ +# │ Flip to "false" once you are comfortable with the pipeline. │ # └─────────────────────────────────────────────────────────────────┘ env: DRAFT_RELEASE: "true" @@ -22,9 +24,7 @@ jobs: release: name: Semantic Release runs-on: ubuntu-latest - if: >- - github.event_name == 'workflow_dispatch' || - !startsWith(github.event.head_commit.message, 'chore(release):') + if: github.event_name != 'release' concurrency: group: release cancel-in-progress: false @@ -51,11 +51,21 @@ jobs: uses: python-semantic-release/python-semantic-release@v9 with: github_token: ${{ secrets.GITHUB_TOKEN }} + vcs_release: "false" - name: Build package if: steps.release.outputs.released == 'true' run: python -m build + - name: Extract changelog for this release + if: steps.release.outputs.released == 'true' + id: changelog + run: | + TAG="${{ steps.release.outputs.tag }}" + VER="${TAG#v}" + # Extract the section between this version's header and the next + awk "/^## v${VER//./\\.} /{found=1; next} /^## v[0-9]/{if(found) exit} found" CHANGELOG.md > /tmp/release_notes.md + - name: Create GitHub Release if: steps.release.outputs.released == 'true' env: @@ -67,11 +77,39 @@ jobs: gh release create "${{ steps.release.outputs.tag }}" \ ${DRAFT_FLAG:-} \ --title "${{ steps.release.outputs.tag }}" \ - --generate-notes \ + --notes-file /tmp/release_notes.md \ dist/* - - name: Publish to PyPI + - name: Publish to PyPI (non-draft) if: steps.release.outputs.released == 'true' && env.DRAFT_RELEASE != 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true + + pypi-publish: + name: Publish to PyPI + runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' + permissions: + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build tools + run: python -m pip install -U pip build + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true From 11d50a75dff850a3855a299f38f5885cf15cefc6 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:24:57 -0800 Subject: [PATCH 09/34] ci(workflows): append detailed changes link to release notes --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 470fdf4..137ffb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,12 @@ jobs: VER="${TAG#v}" # Extract the section between this version's header and the next awk "/^## v${VER//./\\.} /{found=1; next} /^## v[0-9]/{if(found) exit} found" CHANGELOG.md > /tmp/release_notes.md + PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]' | sed -n '2p') + if [ -n "$PREV_TAG" ]; then + REPO="${{ github.repository }}" + printf '\n---\n\n**Detailed Changes**: [%s...%s](https://github.com/%s/compare/%s...%s)\n' \ + "$PREV_TAG" "$TAG" "$REPO" "$PREV_TAG" "$TAG" >> /tmp/release_notes.md + fi - name: Create GitHub Release if: steps.release.outputs.released == 'true' From 2766f244f84d359e1ae74a4b029e0701fad4b0be Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 5 Mar 2026 03:32:00 -0800 Subject: [PATCH 10/34] ci(workflows): simplify release pipeline to use python-semantic-release defaults --- .github/workflows/release.yml | 76 +---------------------------------- 1 file changed, 1 insertion(+), 75 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 137ffb9..20ac529 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,28 +3,12 @@ name: Release on: push: branches: [main] - release: - types: [published] workflow_dispatch: -# ┌─────────────────────────────────────────────────────────────────┐ -# │ DRAFT_RELEASE │ -# │ │ -# │ "true" → GitHub Releases are created as drafts; review them │ -# │ on GitHub, then click "Publish" to trigger PyPI. │ -# │ "false" → GitHub Releases are published immediately, which │ -# │ automatically triggers the PyPI publish job. │ -# │ │ -# │ Flip to "false" once you are comfortable with the pipeline. │ -# └─────────────────────────────────────────────────────────────────┘ -env: - DRAFT_RELEASE: "true" - jobs: release: name: Semantic Release runs-on: ubuntu-latest - if: github.event_name != 'release' concurrency: group: release cancel-in-progress: false @@ -51,71 +35,13 @@ jobs: uses: python-semantic-release/python-semantic-release@v9 with: github_token: ${{ secrets.GITHUB_TOKEN }} - vcs_release: "false" - name: Build package if: steps.release.outputs.released == 'true' run: python -m build - - name: Extract changelog for this release - if: steps.release.outputs.released == 'true' - id: changelog - run: | - TAG="${{ steps.release.outputs.tag }}" - VER="${TAG#v}" - # Extract the section between this version's header and the next - awk "/^## v${VER//./\\.} /{found=1; next} /^## v[0-9]/{if(found) exit} found" CHANGELOG.md > /tmp/release_notes.md - PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]' | sed -n '2p') - if [ -n "$PREV_TAG" ]; then - REPO="${{ github.repository }}" - printf '\n---\n\n**Detailed Changes**: [%s...%s](https://github.com/%s/compare/%s...%s)\n' \ - "$PREV_TAG" "$TAG" "$REPO" "$PREV_TAG" "$TAG" >> /tmp/release_notes.md - fi - - - name: Create GitHub Release - if: steps.release.outputs.released == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [ "$DRAFT_RELEASE" = "true" ]; then - DRAFT_FLAG="--draft" - fi - gh release create "${{ steps.release.outputs.tag }}" \ - ${DRAFT_FLAG:-} \ - --title "${{ steps.release.outputs.tag }}" \ - --notes-file /tmp/release_notes.md \ - dist/* - - - name: Publish to PyPI (non-draft) - if: steps.release.outputs.released == 'true' && env.DRAFT_RELEASE != 'true' - uses: pypa/gh-action-pypi-publish@release/v1 - with: - skip-existing: true - - pypi-publish: - name: Publish to PyPI - runs-on: ubuntu-latest - if: github.event_name == 'release' && github.event.action == 'published' - permissions: - id-token: write - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.tag_name }} - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install build tools - run: python -m pip install -U pip build - - - name: Build package - run: python -m build - - name: Publish to PyPI + if: steps.release.outputs.released == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true From 7c083f4e38367c6cd4163e0be8c78da1fdf8d3da Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:37:14 -0700 Subject: [PATCH 11/34] docs(repo): rewrite README with banner, structured sections, and badges --- .gitignore | 3 ++ LICENSE | 2 +- README.md | 95 ++++++++++++++++++++++++++++++++++------- docs/assets/banner.jpg | Bin 0 -> 217297 bytes 4 files changed, 84 insertions(+), 16 deletions(-) create mode 100644 docs/assets/banner.jpg diff --git a/.gitignore b/.gitignore index c5de671..4f1c160 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,6 @@ cython_debug/ # Metadata *_metadata.json + +# macOS +.DS_Store diff --git a/LICENSE b/LICENSE index 18fff31..ad12cea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 PythonNative +Copyright (c) 2026 Owen Carey Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 31f005c..28f1672 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,80 @@ -## PythonNative - -[![CI](https://github.com/pythonnative/pythonnative/actions/workflows/ci.yml/badge.svg)](https://github.com/pythonnative/pythonnative/actions/workflows/ci.yml) -[![Docs](https://github.com/pythonnative/pythonnative/actions/workflows/docs.yml/badge.svg)](https://github.com/pythonnative/pythonnative/actions/workflows/docs.yml) -[![Release](https://github.com/pythonnative/pythonnative/actions/workflows/release.yml/badge.svg)](https://github.com/pythonnative/pythonnative/actions/workflows/release.yml) -[![PyPI - Version](https://img.shields.io/pypi/v/pythonnative)](https://pypi.org/project/pythonnative/) -[![Python Versions](https://img.shields.io/pypi/pyversions/pythonnative)](https://pypi.org/project/pythonnative/) -[![License: MIT](https://img.shields.io/pypi/l/pythonnative)](LICENSE) -[![Docs Site](https://img.shields.io/website?url=https%3A%2F%2Fdocs.pythonnative.com&label=docs)](https://docs.pythonnative.com/) - -PythonNative is a cross‑platform toolkit for building native Android and iOS -apps in Python. It provides a Pythonic API for native UI components, -lifecycle events, and device capabilities, powered by Rubicon (iOS) and -Chaquopy (Android). For guides, API reference, and examples, see the -[docs](https://docs.pythonnative.com/). +

+ PythonNative +

+ +

+ Build native Android and iOS apps in Python. +

+ +

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

+ +

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

+ +--- + +## Overview + +PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a Pythonic API for native UI components, lifecycle events, and device capabilities, powered by Chaquopy on Android and rubicon-objc on iOS. Write your app once in Python and run it on both platforms with genuinely native interfaces. + +## Features + +- **Cross-platform native UI:** Build Android and iOS apps from a single Python codebase with truly native rendering. +- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. +- **Unified component API:** Components like `Page`, `StackView`, `Label`, `Button`, and `WebView` share a consistent interface across platforms. +- **CLI scaffolding:** `pn init` creates a ready-to-run project structure; `pn run android` and `pn run ios` build and launch your app. +- **Page lifecycle:** Hooks for `on_create`, `on_start`, `on_resume`, `on_pause`, `on_stop`, and `on_destroy`, with state save and restore. +- **Navigation:** Push and pop screens with argument passing for multi-page apps. +- **Rich component set:** Core views (Label, Button, TextField, ImageView, WebView, Switch, DatePicker, and more) plus Material Design variants. +- **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. + +## Quick Start + +### Installation + +```bash +pip install pythonnative +``` + +### Usage + +```python +import pythonnative as pn + + +class MainPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + + def on_create(self): + super().on_create() + stack = pn.StackView() + stack.add_view(pn.Label("Hello from PythonNative!")) + button = pn.Button("Tap me") + button.set_on_click(lambda: print("Button tapped")) + stack.add_view(button) + self.set_root_view(stack) +``` + +## Documentation + +Visit [docs.pythonnative.com](https://docs.pythonnative.com/) for the full documentation, including getting started guides, platform-specific instructions for Android and iOS, API reference, and working examples. + +## Contributing + +Contributions are welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, coding standards, and guidelines for submitting pull requests. + +## License + +[MIT](LICENSE) diff --git a/docs/assets/banner.jpg b/docs/assets/banner.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d7c4c0477c6f669e85980679ed5147f469cccdbf GIT binary patch literal 217297 zcmb5VcQ~8x8#bO05nJrth)}ymj2dl>5JhcGj0!^Sq7+rFy+;zGMytf$L{VwA)M%_$ zteP!~qOH=U`}529J%0bZf4)zi0cHSJ7MA~=%mc(c*&u9eAP^fTJ3AP{&B@Kp#mU9R!z;kg!wco*;^G(KhYAV_ z3k!4eiHeB`i3tb^3;pjRKvw1)5E};@8;1}N7mv{Y^YPyx019Dg0HauePyh=Q$O;Ai zHv*7h#tC8t{x9DD|6m0GSwL)Hb_fS2bKILB0AytWF{5T@=Kz5r06rjqg%t#41Iuc% z3z!Hxct=2l;7KG7EmOzpt|4JLpU7k~Lj<91=0u@x9?4&Oc8oMfyZCmCD(E<;6xPg& z5mJZeYQ;}SMW+>)JowC9LYjF+mj5dRW(3Ry{}%#t*peA7GXz!;1jNR|_P<;Be}cv4NaSc zi|0LgR14~TQi1_V-WE#F$|KWd!L=CMuv$-6OqZZt7Ux-ooNzV{t$=FEz$wNsfj z@~oVrg=ezQjHrqjYVluBlxQa8Ta_JvHAit0JX_D2K(~88+WmMk+VU#sMB)m&8~LZ& z)JK{735{pJCjabK68M6+0gQy=Yf=3WTBeBGw2&mCmrqi)S)RP6CW4(i4VY#)&GZOJ z#{5+NtYvmFa~%5Ioeey$ZkB#P%mPM1BB%l^wh-3)S1#Rsc~|4^Q^^jy3ki|!Z}8_n z9jn#fH(6{h+*X==RR8W!Pj36s=yo7D*g10>5wZYqPiusRDg1z;=@k@$wPqI*J{8)R zO)%|*`K!oGJIAteT|QiCem%u)@blqc{@l&YYZCd9ujxDb-~F4$4(_RTJn9X&I*mUJ z_;bm&VV|eR?fu%5{{X%fPZw&0_Ew&TJb81CcacwLDRNmVq1k5Zf5neh%%-AbEf<3c00{IF_ie1+(l3s{I5M!4v zw9&g@$HTNX>en3Zlp*JBEAuWP<=#mLZ<6xeEP1k>=A1d9_+z%_qskB=_>Nv?D|tml zD_%_b&yLh3KcR%`e~Y&452M{VuiJidC>1^^QH5Zah|e!yu)<0HUsQ4kM;95wAdgF9YZi(pKBmgd0~VMsy_G0giUZKs;^+83UeDr2oxs?lxZtRC6NCX+ zXNI*3RLV(5>C2x(olApB1tdy*$3S84Ygq$Ik5o<*F+P(u@WD-5&>anTz6s_JR3&n* zRs+UXs`%+Lmb)h~b1Q=?FbxFb%Lc>;B#Na1b-D7KQE#ZlF>RYB`Fdp5<@@Rxe`C}y zf4?7dDJ$}drJvK|x{PV@m>>w2;qQw+>nm~6T1rjNE+~j5W=p6HRJ3fl4xa5>w`t`F zZLclk=I>v?X+mWg5TM8r(iug8uBBUA!UK;$d`t;aRA1PP(aG+K%Ly*Hjd)Jg2WN zf-f|+^WpD^`=|JXll1kCw3W3Sap=zrI3x?^t5TKn6GKm14$1@4o=X=&+~xT4!8~27 z z==Jmk{H2>WY5{aA-uw9Ex=gkHzvgy5uQ&g!J3mZ4-3l@rJ97}Z^5^#E<{1vT2lUlD zamBC9cb7f4UUB{?rrsS6r{W!_qPAYBz~#@dDWQjOP|Y&iw_d_QvWa#Q(n1U{aJb(J zB3NEJ^cYYM#cgeET5%Nnh3#*nq}ByZLA%Q!lgcIvrv13u)jWaoaINJ6J(0*mkj>{w z;vm9xX#BE+H1K(~85W-2J>U=rkOjDm)a1(`o5}@Ozw2%pV7Ue*-gp0>-Ec-EOT(|RNuZHx6kDr0kaLb}0Y(;!RiPre3uzi4Q zP9WiDCc^jDcB*)u$R`It4Iwe!D~d+|&yd{+Uw1@YSZ(~wEr6dtD6ztK35E$Ea_n=s za1N)*OYm=oB1UCI5-F)fvekd`ZC9kGU;c8WJc2Q2hM29yQUXcaUjsn*`w@aku6z}lO0grJX zK*|oHGJq^9KG-5L3uza3`$KHtTdngWyp?|ot@=`tGzmMN#@*(IwkhG1pSwy*M6bOu z-ju&l!0Ri1yoVJPyZ_R8*1o8JjI`J_U6TVi

X)$oT+zgsx+_!Y#@Kwj+w5YQv(* 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 Vqd+34D`9vpH%M~%V8H*@{|`ntU0nbG literal 0 HcmV?d00001 From e00f10ef85e741fd882f9bf22dcde42eacc53fe5 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 18 Mar 2026 05:46:36 +0000 Subject: [PATCH 12/34] chore(release): v0.4.0 --- CHANGELOG.md | 163 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/pythonnative/__init__.py | 2 +- 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..430eac1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,163 @@ +# CHANGELOG + + +## v0.4.0 (2026-03-18) + +### Bug Fixes + +- **components,templates**: Restore hello-world on iOS and Android + ([`d7ac93b`](https://github.com/pythonnative/pythonnative/commit/d7ac93be202161a5c8328816a5c6ff8a96dde1d5)) + +### Continuous Integration + +- **workflows**: Add semantic-release pipeline and PR commit linting + ([`0711683`](https://github.com/pythonnative/pythonnative/commit/0711683f5b56751027bb1a5a63ee2d9afcd4b620)) + +- **workflows**: Append detailed changes link to release notes + ([`11d50a7`](https://github.com/pythonnative/pythonnative/commit/11d50a75dff850a3855a299f38f5885cf15cefc6)) + +- **workflows**: Fix duplicate release, and use changelog for release notes + ([`1cd5393`](https://github.com/pythonnative/pythonnative/commit/1cd5393e7bf20d5350052cfaa81fd511dc4ca3ca)) + +- **workflows**: Simplify release pipeline to use python-semantic-release defaults + ([`2766f24`](https://github.com/pythonnative/pythonnative/commit/2766f244f84d359e1ae74a4b029e0701fad4b0be)) + +### Documentation + +- **repo**: Rewrite README with banner, structured sections, and badges + ([`7c083f4`](https://github.com/pythonnative/pythonnative/commit/7c083f4e38367c6cd4163e0be8c78da1fdf8d3da)) + +- **repo**: Simplify README with badges and one-paragraph overview + ([`3ac84b1`](https://github.com/pythonnative/pythonnative/commit/3ac84b1a3f541b47121b46a687b78826f8d348f9)) + +### Features + +- **components**: Standardize fluent setters and align base signatures + ([`d236d89`](https://github.com/pythonnative/pythonnative/commit/d236d899690a4033effdcab4862a556a742fa6d1)) + +- **components,core**: Add layout/styling APIs and fluent setters + ([`6962d38`](https://github.com/pythonnative/pythonnative/commit/6962d3881bf091b3494fc2c964f7ea65a99ce606)) + +### Refactoring + +- **components**: Declare abstract static wrap in ScrollViewBase + ([`593fee4`](https://github.com/pythonnative/pythonnative/commit/593fee4fcf66678cb026de58115f959633d859b4)) + +- **core,components,examples**: Add annotations; tighten mypy + ([`86e4ffc`](https://github.com/pythonnative/pythonnative/commit/86e4ffc9e51810997006055434783416784c182f)) + + +## v0.3.0 (2025-10-22) + +### Build System + +- **repo**: Remove invalid PyPI classifier + ([`c8552e1`](https://github.com/pythonnative/pythonnative/commit/c8552e137e0176c0f5c61193e786429e2e93ac7c)) + +### Chores + +- **experiments**: Remove experiments directory + ([`caf6993`](https://github.com/pythonnative/pythonnative/commit/caf69936e085a3f487123ebcb3a6d807fefcc66c)) + +- **repo,core,mkdocs**: Bump version to 0.3.0 + ([`64d7c1c`](https://github.com/pythonnative/pythonnative/commit/64d7c1cfb448797305efc7f4014e56584f92fc1a)) + +### Documentation + +- **mkdocs**: Add Architecture page + ([`6d61ffc`](https://github.com/pythonnative/pythonnative/commit/6d61ffc64ca5db8ae688d09a748ddda2a1bc0af6)) + +### Features + +- **core,templates**: Add push/pop navigation and lifecycle wiring + ([`06ea22d`](https://github.com/pythonnative/pythonnative/commit/06ea22d215a1700685a7ca8070ca2189895ed25c)) + +- **templates,core**: Adopt Fragment-based Android navigation + ([`7a3a695`](https://github.com/pythonnative/pythonnative/commit/7a3a695477ece3cf76afd00f203523990f8789df)) + + +## v0.2.0 (2025-10-14) + +### Build System + +- **templates,cli**: Ship template dirs with package; drop zip artifacts + ([`7725b14`](https://github.com/pythonnative/pythonnative/commit/7725b1462c42d89f27fb4d3d733e73177c55d8ac)) + +### Chores + +- Clean up + ([`6c7a882`](https://github.com/pythonnative/pythonnative/commit/6c7a882895691903457a0a94d33192b6018c77fd)) + +- **core,components,cli**: Align lint, typing, and tests with CI + ([`30037d1`](https://github.com/pythonnative/pythonnative/commit/30037d17ad397952a88e3dfeb8bd003ced7319d8)) + +- **experiments**: Remove unused experiment directories + ([`db06fd1`](https://github.com/pythonnative/pythonnative/commit/db06fd101789392deee8c37263a61ee4d7106853)) + +- **repo,ci,docs**: Rename demo to examples/hello-world and update refs + ([`6d5b78e`](https://github.com/pythonnative/pythonnative/commit/6d5b78ea7dce66b5031b952928aed8d4a713fae8)) + +- **repo,core,mkdocs**: Bump version to 0.2.0 + ([`d3f8d31`](https://github.com/pythonnative/pythonnative/commit/d3f8d31942c3ca5d1657024e3a5cb332787afcd8)) + +- **templates**: Scrub DEVELOPMENT_TEAM from iOS template + ([`64ab266`](https://github.com/pythonnative/pythonnative/commit/64ab2666fe09f036934d3922ab55e8e599df3c35)) + +### Continuous Integration + +- **workflows,mkdocs**: Set CNAME to docs.pythonnative.com for docs deploy + ([`401a076`](https://github.com/pythonnative/pythonnative/commit/401a076dcb1fe0c19771f4a19141ee8da28c80e2)) + +### Documentation + +- **mkdocs**: Add roadmap and link in nav + ([`16ede97`](https://github.com/pythonnative/pythonnative/commit/16ede972d41b549853962c7056b65558c9ebd2f5)) + +- **mkdocs**: Update Getting Started, Hello World, Components, and platform guides + ([`f3a03b0`](https://github.com/pythonnative/pythonnative/commit/f3a03b01986365063535a2f336793cc6f21836db)) + +- **repo**: Add CONTRIBUTING.md + ([`f61cb85`](https://github.com/pythonnative/pythonnative/commit/f61cb85301c7bff57299b4c814319e9262f0f5ef)) + +### Features + +- Update README + ([`e839585`](https://github.com/pythonnative/pythonnative/commit/e8395855acf5d38a0e5987475900f4eeb1eee313)) + +- **cli,mkdocs,tests**: Add pn init/run/clean; use bundled templates + ([`9c61757`](https://github.com/pythonnative/pythonnative/commit/9c61757713fe60b5e98756f552681a782f397f3a)) + +- **cli,templates**: Auto-select iOS sim; guard PythonKit + ([`7b7c59c`](https://github.com/pythonnative/pythonnative/commit/7b7c59c262f2510a5fb46e455c13a2fc56086845)) + +- **cli,templates**: Bundle offline templates; add run --prepare-only + ([`d9dd821`](https://github.com/pythonnative/pythonnative/commit/d9dd821bc18289f1f1a367e737cfe7d5bfaf6ee3)) + +- **cli,templates**: Dev-first templates; stage in-repo lib for pn run + ([`b3dd731`](https://github.com/pythonnative/pythonnative/commit/b3dd731bd5efcca8e1a47f8f888fc6123854a40c)) + +- **cli,templates,core**: Bootstrap entrypoint; pn run shows Hello UI + ([`2805e1d`](https://github.com/pythonnative/pythonnative/commit/2805e1d5c6a58eb718b94ba0ce57c1078a08d578)) + +- **cli,templates,core**: Fetch iOS Python runtime and bootstrap PythonKit + ([`bcc0916`](https://github.com/pythonnative/pythonnative/commit/bcc0916a5b7427874ab7a5971a6a9941c4222c77)) + +- **components,utils**: Unify constructors; set Android context + ([`4c06b67`](https://github.com/pythonnative/pythonnative/commit/4c06b67214ea7fc4530a0d39b7105cfb62d20cf5)) + +- **repo,mkdocs,workflows**: Migrate to src layout; add pyproject and docs scaffold + ([`f273922`](https://github.com/pythonnative/pythonnative/commit/f273922e8a0494df7ba2cd59a3ad2ef54f918d3e)) + +### Refactoring + +- **cli**: Make pn.py typing py3.9-compatible and wrap long lines + ([`b38da78`](https://github.com/pythonnative/pythonnative/commit/b38da78dac52e42968efa6f4115b6b84de65b3b5)) + +- **components,core**: Align component names with docs + ([`a326ceb`](https://github.com/pythonnative/pythonnative/commit/a326ceb23c2cfaba409f11451a1c0000f0afbf5e)) + + +## v0.1.0 (2025-10-14) + + +## v0.0.1 (2025-10-14) diff --git a/pyproject.toml b/pyproject.toml index 5c99353..95432b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.3.0" +version = "0.4.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 1e2530d..a185962 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -1,7 +1,7 @@ from importlib import import_module from typing import Any, Dict -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = [ "ActivityIndicatorView", From b6b77216305202ea0c5197b29e725e14cbe99b5e Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:28:50 -0700 Subject: [PATCH 13/34] feat(core)!: replace imperative widget API with declarative component model and reconciler --- README.md | 50 +- docs/api/component-properties.md | 116 ++- docs/api/pythonnative.md | 36 +- docs/concepts/architecture.md | 63 +- docs/concepts/components.md | 129 ++- docs/examples.md | 55 +- docs/examples/hello-world.md | 22 +- docs/getting-started.md | 29 +- docs/guides/navigation.md | 83 +- docs/guides/styling.md | 107 +-- docs/index.md | 22 +- examples/hello-world/app/main_page.py | 78 +- examples/hello-world/app/second_page.py | 80 +- examples/hello-world/app/third_page.py | 32 +- mypy.ini | 2 +- src/pythonnative/__init__.py | 108 +-- src/pythonnative/activity_indicator_view.py | 71 -- src/pythonnative/button.py | 113 --- src/pythonnative/cli/pn.py | 29 +- src/pythonnative/components.py | 241 ++++++ src/pythonnative/date_picker.py | 76 -- src/pythonnative/element.py | 47 + src/pythonnative/image_view.py | 78 -- src/pythonnative/label.py | 133 --- src/pythonnative/list_view.py | 76 -- .../material_activity_indicator_view.py | 71 -- src/pythonnative/material_button.py | 69 -- src/pythonnative/material_date_picker.py | 87 -- src/pythonnative/material_progress_view.py | 70 -- src/pythonnative/material_search_bar.py | 69 -- src/pythonnative/material_switch.py | 69 -- src/pythonnative/material_time_picker.py | 76 -- src/pythonnative/native_views.py | 800 ++++++++++++++++++ src/pythonnative/page.py | 566 +++++++------ src/pythonnative/picker_view.py | 69 -- src/pythonnative/progress_view.py | 70 -- src/pythonnative/reconciler.py | 129 +++ src/pythonnative/scroll_view.py | 101 --- src/pythonnative/search_bar.py | 69 -- src/pythonnative/stack_view.py | 199 ----- src/pythonnative/switch.py | 68 -- .../android_template/PageFragment.kt | 3 +- src/pythonnative/text_field.py | 132 --- src/pythonnative/text_view.py | 135 --- src/pythonnative/time_picker.py | 77 -- src/pythonnative/utils.py | 50 +- src/pythonnative/view.py | 173 ---- src/pythonnative/web_view.py | 60 -- tests/test_components.py | 194 +++++ tests/test_element.py | 71 ++ tests/test_reconciler.py | 280 ++++++ tests/test_smoke.py | 34 +- 52 files changed, 2697 insertions(+), 2970 deletions(-) delete mode 100644 src/pythonnative/activity_indicator_view.py delete mode 100644 src/pythonnative/button.py create mode 100644 src/pythonnative/components.py delete mode 100644 src/pythonnative/date_picker.py create mode 100644 src/pythonnative/element.py delete mode 100644 src/pythonnative/image_view.py delete mode 100644 src/pythonnative/label.py delete mode 100644 src/pythonnative/list_view.py delete mode 100644 src/pythonnative/material_activity_indicator_view.py delete mode 100644 src/pythonnative/material_button.py delete mode 100644 src/pythonnative/material_date_picker.py delete mode 100644 src/pythonnative/material_progress_view.py delete mode 100644 src/pythonnative/material_search_bar.py delete mode 100644 src/pythonnative/material_switch.py delete mode 100644 src/pythonnative/material_time_picker.py create mode 100644 src/pythonnative/native_views.py delete mode 100644 src/pythonnative/picker_view.py delete mode 100644 src/pythonnative/progress_view.py create mode 100644 src/pythonnative/reconciler.py delete mode 100644 src/pythonnative/scroll_view.py delete mode 100644 src/pythonnative/search_bar.py delete mode 100644 src/pythonnative/stack_view.py delete mode 100644 src/pythonnative/switch.py delete mode 100644 src/pythonnative/text_field.py delete mode 100644 src/pythonnative/text_view.py delete mode 100644 src/pythonnative/time_picker.py delete mode 100644 src/pythonnative/view.py delete mode 100644 src/pythonnative/web_view.py create mode 100644 tests/test_components.py create mode 100644 tests/test_element.py create mode 100644 tests/test_reconciler.py diff --git a/README.md b/README.md index 28f1672..d5c0706 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,17 @@ ## Overview -PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a Pythonic API for native UI components, lifecycle events, and device capabilities, powered by Chaquopy on Android and rubicon-objc on iOS. Write your app once in Python and run it on both platforms with genuinely native interfaces. +PythonNative is a cross-platform toolkit for building native Android and iOS apps in Python. It provides a **declarative, React-like component model** with automatic reconciliation, powered by Chaquopy on Android and rubicon-objc on iOS. Describe your UI as a tree of elements, manage state with `set_state()`, and let PythonNative handle creating and updating native views. ## Features -- **Cross-platform native UI:** Build Android and iOS apps from a single Python codebase with truly native rendering. +- **Declarative UI:** Describe *what* your UI should look like with element functions (`Text`, `Button`, `Column`, `Row`, etc.). PythonNative creates and updates native views automatically. +- **Reactive state:** Call `self.set_state(key=value)` and the framework re-renders only what changed — no manual view mutation. +- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation. - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. -- **Unified component API:** Components like `Page`, `StackView`, `Label`, `Button`, and `WebView` share a consistent interface across platforms. -- **CLI scaffolding:** `pn init` creates a ready-to-run project structure; `pn run android` and `pn run ios` build and launch your app. -- **Page lifecycle:** Hooks for `on_create`, `on_start`, `on_resume`, `on_pause`, `on_stop`, and `on_destroy`, with state save and restore. +- **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app. - **Navigation:** Push and pop screens with argument passing for multi-page apps. -- **Rich component set:** Core views (Label, Button, TextField, ImageView, WebView, Switch, DatePicker, and more) plus Material Design variants. -- **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. +- **Bundled templates:** Android Gradle and iOS Xcode templates are included — scaffolding requires no network access. ## Quick Start @@ -56,17 +55,36 @@ import pythonnative as pn class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button tapped")) - stack.add_view(button) - self.set_root_view(stack) + self.state = {"count": 0} + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button( + "Tap me", + on_click=lambda: self.set_state(count=self.state["count"] + 1), + ), + spacing=12, + padding=16, + ) ``` +### Available Components + +| Component | Description | +|---|---| +| `Text` | Display text | +| `Button` | Tappable button with `on_click` callback | +| `Column` / `Row` | Vertical / horizontal layout containers | +| `ScrollView` | Scrollable wrapper | +| `TextInput` | Text entry field with `on_change` callback | +| `Image` | Display images | +| `Switch` | Toggle with `on_change` callback | +| `ProgressBar` | Determinate progress (0.0–1.0) | +| `ActivityIndicator` | Indeterminate loading spinner | +| `WebView` | Embedded web content | +| `Spacer` | Empty space | + ## Documentation Visit [docs.pythonnative.com](https://docs.pythonnative.com/) for the full documentation, including getting started guides, platform-specific instructions for Android and iOS, API reference, and working examples. diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index 2480b13..c6dd7ed 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -1,52 +1,100 @@ -## Component Property Reference (v0.4.0) +# Component Property Reference -This page summarizes common properties and fluent setters added in v0.4.0. Unless noted, methods return `self` for chaining. +All style and behaviour properties are passed as keyword arguments to element functions. -### View (base) +## Text -- `set_background_color(color)` - - Accepts ARGB int or `#RRGGBB` / `#AARRGGBB` string. +```python +pn.Text(text, font_size=None, color=None, bold=False, text_align=None, + background_color=None, max_lines=None) +``` -- `set_padding(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` - - Android: applies padding in dp. - - iOS: currently a no-op for most views. +- `text` — display string +- `font_size` — size in sp (Android) / pt (iOS) +- `color` — text colour (`#RRGGBB` or `#AARRGGBB`) +- `bold` — bold weight +- `text_align` — `"left"`, `"center"`, or `"right"` +- `background_color` — view background +- `max_lines` — limit visible lines -- `set_margin(*, all=None, horizontal=None, vertical=None, left=None, top=None, right=None, bottom=None)` - - Android: applied when added to `StackView` (LinearLayout) as `LayoutParams` margins (dp). - - iOS: not currently applied. +## Button -- `wrap_in_scroll()` → `ScrollView` - - Returns a `ScrollView` containing this view. +```python +pn.Button(title, on_click=None, color=None, background_color=None, + font_size=None, enabled=True) +``` -### ScrollView +- `title` — button label +- `on_click` — callback `() -> None` +- `color` — title text colour +- `background_color` — button background +- `enabled` — interactive state -- `ScrollView.wrap(view)` → `ScrollView` - - Class helper to wrap a single child. +## Column / Row -### StackView +```python +pn.Column(*children, spacing=0, padding=None, alignment=None, background_color=None) +pn.Row(*children, spacing=0, padding=None, alignment=None, background_color=None) +``` -- `set_axis('vertical'|'horizontal')` -- `set_spacing(n)` - - Android: dp via divider drawable. - - iOS: `UIStackView.spacing` (points). -- `set_alignment('fill'|'center'|'leading'|'trailing'|'top'|'bottom')` - - Cross-axis alignment; top/bottom map appropriately for horizontal stacks. +- `spacing` — gap between children (dp / pt) +- `padding` — inner padding (int for all sides, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) +- `alignment` — cross-axis: `"fill"`, `"center"`, `"leading"`, `"trailing"`, `"start"`, `"end"`, `"top"`, `"bottom"` +- `background_color` — container background -### Text components +## ScrollView -Applies to `Label`, `TextField`, `TextView`: +```python +pn.ScrollView(child, background_color=None) +``` -- `set_text(text)` -- `set_text_color(color)` -- `set_text_size(size)` +## TextInput -Platform notes: -- Android: `setTextColor(int)`, `setTextSize(sp)`. -- iOS: `setTextColor(UIColor)`, `UIFont.systemFont(ofSize:)`. +```python +pn.TextInput(value="", placeholder="", on_change=None, secure=False, + font_size=None, color=None, background_color=None) +``` -### Button +- `on_change` — callback `(str) -> None` receiving new text -- `set_title(text)` -- `set_on_click(callback)` +## Image +```python +pn.Image(source="", width=None, height=None, scale_type=None, background_color=None) +``` +## Switch + +```python +pn.Switch(value=False, on_change=None) +``` + +- `on_change` — callback `(bool) -> None` + +## ProgressBar + +```python +pn.ProgressBar(value=0.0, background_color=None) +``` + +- `value` — 0.0 to 1.0 + +## ActivityIndicator + +```python +pn.ActivityIndicator(animating=True) +``` + +## WebView + +```python +pn.WebView(url="") +``` + +## Spacer + +```python +pn.Spacer(size=None) +``` + +- `size` — fixed dimension in dp / pt diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 0233735..8147bf9 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -1,11 +1,33 @@ # pythonnative package -API reference will be generated here via mkdocstrings. +## Public API -Key flags and helpers: +### Page -- `pythonnative.utils.IS_ANDROID`: platform flag with robust detection for Chaquopy/Android. -- `pythonnative.utils.get_android_context()`: returns the current Android Activity/Context when running on Android. -- `pythonnative.utils.set_android_context(ctx)`: set by `pythonnative.Page` on Android; you generally don’t call this directly. -- `pythonnative.utils.get_android_fragment_container()`: returns the current Fragment container `ViewGroup` used for page rendering. -- `pythonnative.utils.set_android_fragment_container(viewGroup)`: set by the host `PageFragment`; you generally don’t call this directly. +`pythonnative.Page` — base class for screens. Subclass it, implement `render()`, and use `set_state()` to trigger re-renders. + +### Element functions + +- `pythonnative.Text`, `Button`, `Column`, `Row`, `ScrollView`, `TextInput`, `Image`, `Switch`, `ProgressBar`, `ActivityIndicator`, `WebView`, `Spacer` + +Each returns an `Element` descriptor. See the Component Property Reference for full signatures. + +### Element + +`pythonnative.Element` — the descriptor type returned by element functions. You generally don't create these directly. + +## Internal helpers + +- `pythonnative.utils.IS_ANDROID` — platform flag with robust detection for Chaquopy/Android. +- `pythonnative.utils.get_android_context()` — returns the current Android `Activity`/`Context` when running on Android. +- `pythonnative.utils.set_android_context(ctx)` — set by `Page` on Android; you generally don't call this directly. +- `pythonnative.utils.get_android_fragment_container()` — returns the current Fragment container `ViewGroup` used for page rendering. +- `pythonnative.utils.set_android_fragment_container(viewGroup)` — set by the host `PageFragment`; you generally don't call this directly. + +## Reconciler + +`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Used internally by `Page`. + +## Native view registry + +`pythonnative.native_views.NativeViewRegistry` — maps element type names to platform-specific handlers. Use `set_registry()` to inject a mock for testing. diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index c7726dc..9ab94b0 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -1,58 +1,47 @@ # Architecture -PythonNative maps Python directly to native platform APIs. Conceptually, it is closer to NativeScript's dynamic bindings than to React Native's bridge-and-module approach. +PythonNative combines **direct native bindings** with a **declarative reconciler**, giving you React-like ergonomics while calling native platform APIs synchronously from Python. ## High-level model -- Direct bindings: call native APIs synchronously from Python. - - iOS: rubicon-objc exposes Objective-C/Swift classes (e.g., UIViewController, UIButton, WKWebView) and lets you create dynamic Objective-C subclasses and selectors. - - Android: Chaquopy exposes Java classes (e.g., android.widget.Button, android.webkit.WebView) via the java bridge so you can construct and call methods directly. -- Shared Python API: components like Page, StackView, Label, Button, and WebView have a small, consistent surface. Platform-specific behavior is chosen at import time using pythonnative.utils.IS_ANDROID. -- Thin native bootstrap: the host app remains native (Android Activity or iOS UIViewController). It passes a live instance/pointer into Python, and Python drives the UI from there. +1. **Declarative element tree:** Your `Page.render()` method returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes). +2. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by `set_state`), it diffs the new tree against the old one and applies the minimal set of native mutations. +3. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: + - **iOS:** rubicon-objc exposes Objective-C/Swift classes (`UILabel`, `UIButton`, `UIStackView`, etc.). + - **Android:** Chaquopy exposes Java classes (`android.widget.TextView`, `android.widget.Button`, etc.) via the JNI bridge. +4. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It passes a live instance/pointer into Python, and Python drives the UI through the reconciler. + +## How it works + +``` +Page.render() → Element tree → Reconciler → Native views + ↑ +Page.set_state() → re-render → diff → patch native views +``` + +The reconciler uses **positional diffing** (comparing children by index). When a child at a given position has the same element type, its props are updated in-place on the native view. When the type changes, the old native view is destroyed and a new one is created. ## Comparison -- Versus React Native: RN typically exposes capabilities via native modules/TurboModules and a bridge. PythonNative does not require authoring such modules for most APIs; you can access platform classes directly from Python. -- Versus NativeScript: similar philosophy—dynamic, synchronous access to Obj-C/Java from the scripting runtime. +- **Versus React Native:** RN uses JSX + a JavaScript bridge + Yoga layout. PythonNative uses Python + direct native calls + platform layout managers. No JS bridge, no serialisation overhead. +- **Versus NativeScript:** Similar philosophy (direct, synchronous native access), but PythonNative adds a declarative reconciler layer that NativeScript does not have by default. +- **Versus the old imperative API:** The previous PythonNative API required manual `add_view()` calls and explicit setter methods. The new declarative model handles view lifecycle automatically. ## iOS flow (Rubicon-ObjC) - The iOS template (Swift + PythonKit) boots Python and instantiates your `MainPage` with the current `UIViewController` pointer. -- In Python, Rubicon wraps the pointer; you then interact with UIKit classes directly. - -```python -from rubicon.objc import ObjCClass, ObjCInstance - -UIButton = ObjCClass("UIButton") -vc = ObjCInstance(native_ptr) # passed from Swift template -button = UIButton.alloc().init() -# Configure target/action via a dynamic Objective-C subclass (see Button implementation) -``` +- `Page.on_create()` calls `render()`, the reconciler creates UIKit views, and attaches them to the controller's view. +- State changes trigger `render()` again; the reconciler patches UIKit views in-place. ## Android flow (Chaquopy) -- The Android template (Kotlin + Chaquopy) initializes Python in MainActivity and provides the current Activity/Context to Python. -- Components acquire the Context implicitly and construct real Android views. - -```python -from java import jclass -from pythonnative.utils import get_android_context - -WebViewClass = jclass("android.webkit.WebView") -context = get_android_context() -webview = WebViewClass(context) -webview.loadUrl("https://example.com") -``` - -## Key implications - -- Synchronous native calls: no JS bridge; Python calls are direct. -- Lifecycle rules remain native: Activities/ViewControllers are created by the OS. Python receives and controls them; it does not instantiate Android Activities directly. -- Small, growing surface: the shared Python API favors clarity and consistency, expanding progressively. +- The Android template (Kotlin + Chaquopy) initializes Python in `MainActivity` and passes the `Activity` to Python. +- `PageFragment` calls `on_create()` on the Python `Page`, which renders and attaches views to the fragment container. +- State changes trigger re-render; the reconciler patches Android views in-place. ## Navigation model overview -- See the Navigation guide for full details and comparisons with other frameworks. +- See the Navigation guide for full details. - iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. - Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph. diff --git a/docs/concepts/components.md b/docs/concepts/components.md index e9d12ca..016c21c 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -1,51 +1,118 @@ # Components -High-level overview of PythonNative components and how they map to native UI. +PythonNative uses a **declarative component model** inspired by React. You describe *what* the UI should look like, and the framework handles creating and updating native views. -## Constructor pattern +## Element functions -- All core components share a consistent, contextless constructor on both platforms. -- On Android, a `Context` is acquired implicitly from the current `Activity` set by `pn.Page`. -- On iOS, UIKit classes are allocated/initialized directly. - -Examples: +UI is built with element-creating functions. Each returns a lightweight `Element` descriptor — no native objects are created until the reconciler mounts the tree. ```python import pythonnative as pn +pn.Text("Hello", font_size=18, color="#333333") +pn.Button("Tap me", on_click=lambda: print("tapped")) +pn.Column( + pn.Text("First"), + pn.Text("Second"), + spacing=8, + padding=16, +) +``` + +### Available components + +**Layout:** + +- `Column(*children, spacing, padding, alignment, background_color)` — vertical stack +- `Row(*children, spacing, padding, alignment, background_color)` — horizontal stack +- `ScrollView(child, background_color)` — scrollable container +- `Spacer(size)` — empty space + +**Display:** + +- `Text(text, font_size, color, bold, text_align, background_color, max_lines)` — text display +- `Image(source, width, height, scale_type)` — image display +- `WebView(url)` — embedded web content + +**Input:** + +- `Button(title, on_click, color, background_color, font_size, enabled)` — tappable button +- `TextInput(value, placeholder, on_change, secure, font_size, color)` — text entry +- `Switch(value, on_change)` — toggle switch + +**Feedback:** + +- `ProgressBar(value)` — determinate progress (0.0–1.0) +- `ActivityIndicator(animating)` — indeterminate spinner + +## Page — the root component + +Each screen is a `Page` subclass with a `render()` method that returns an element tree: + +```python class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) + self.state = {"name": "World"} + + def render(self): + return pn.Text(f"Hello, {self.state['name']}!", font_size=24) +``` + +## State and re-rendering + +Call `self.set_state(key=value)` to update state. The framework automatically calls `render()` again and applies only the differences to the native views: + +```python +class CounterPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + self.state = {"count": 0} + + def increment(self): + self.set_state(count=self.state["count"] + 1) - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello")) - stack.add_view(pn.Button("Tap me")) - stack.add_view(pn.TextField("initial")) - self.set_root_view(stack) + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button("Increment", on_click=self.increment), + spacing=12, + ) ``` -Notes: -- `pn.Page` stores the Android `Activity` so components like `pn.Button()` and `pn.Label()` can construct their native counterparts. -- If you construct views before the `Page` is created on Android, a runtime error will be raised because no `Context` is available. +## Reusable components as functions -## Core components +For reusable UI pieces, use regular Python functions that return elements: -Stabilized with contextless constructors on both platforms: +```python +def greeting_card(name, on_tap): + return pn.Column( + pn.Text(f"Hello, {name}!", font_size=20, bold=True), + pn.Button("Say hi", on_click=on_tap), + spacing=8, + padding=12, + ) -- `Page` -- `StackView` -- `Label`, `Button` -- `ImageView` -- `TextField`, `TextView` -- `Switch` -- `ProgressView`, `ActivityIndicatorView` -- `WebView` +class MainPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + return pn.Column( + greeting_card("Alice", lambda: print("Hi Alice")), + greeting_card("Bob", lambda: print("Hi Bob")), + spacing=16, + ) +``` -APIs are intentionally small and grow progressively in later releases. Properties and setters are kept consistent where supported by both platforms. +## Platform detection -## Platform detection and Android context +Use `pythonnative.utils.IS_ANDROID` when you need platform-specific logic: + +```python +from pythonnative.utils import IS_ANDROID + +title = "Android App" if IS_ANDROID else "iOS App" +``` -- Use `pythonnative.utils.IS_ANDROID` for platform checks when needed. -- On Android, `Page` records the current `Activity` so child views can acquire a `Context` implicitly. Constructing views before `Page` initialization will raise. +On Android, `Page` records the current `Activity` so component constructors can acquire a `Context` implicitly. Constructing views before `Page` initialisation will raise. diff --git a/docs/examples.md b/docs/examples.md index 821ee4a..0f007dc 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,3 +1,56 @@ # Examples -A collection of simple examples showing PythonNative components and patterns. +A collection of examples showing PythonNative's declarative component model and patterns. + +## Quick counter + +```python +import pythonnative as pn + + +class CounterPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + self.state = {"count": 0} + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button( + "Increment", + on_click=lambda: self.set_state(count=self.state["count"] + 1), + ), + spacing=12, + padding=16, + ) +``` + +## Reusable components + +```python +def labeled_input(label, placeholder=""): + return pn.Column( + pn.Text(label, font_size=14, bold=True), + pn.TextInput(placeholder=placeholder), + spacing=4, + ) + + +class FormPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + return pn.ScrollView( + pn.Column( + pn.Text("Sign Up", font_size=24, bold=True), + labeled_input("Name", "Enter your name"), + labeled_input("Email", "you@example.com"), + pn.Button("Submit", on_click=lambda: print("submitted")), + spacing=12, + padding=16, + ) + ) +``` + +See `examples/hello-world/` for a full multi-page demo with navigation. diff --git a/docs/examples/hello-world.md b/docs/examples/hello-world.md index b939317..56f07ac 100644 --- a/docs/examples/hello-world.md +++ b/docs/examples/hello-world.md @@ -1,6 +1,6 @@ # Hello World -Create a simple page with a label and a button. +Create a simple page with a counter that increments on tap. ```python import pythonnative as pn @@ -9,16 +9,18 @@ import pythonnative as pn class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) + self.state = {"count": 0} - def on_create(self): - super().on_create() - stack = pn.StackView() - label = pn.Label("Hello, world!") - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Hello tapped")) - stack.add_view(label) - stack.add_view(button) - self.set_root_view(stack) + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button( + "Tap me", + on_click=lambda: self.set_state(count=self.state["count"] + 1), + ), + spacing=12, + padding=16, + ) ``` Run it: diff --git a/docs/getting-started.md b/docs/getting-started.md index f35ff23..538f28e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ This scaffolds: - `requirements.txt` - `.gitignore` -A minimal `app/main_page.py` looks like (no bootstrap needed): +A minimal `app/main_page.py` looks like: ```python import pythonnative as pn @@ -27,17 +27,26 @@ import pythonnative as pn class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("Hello from PythonNative!")) - button = pn.Button("Tap me") - button.set_on_click(lambda: print("Button clicked")) - stack.add_view(button) - self.set_root_view(stack) + self.state = {"count": 0} + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button( + "Tap me", + on_click=lambda: self.set_state(count=self.state["count"] + 1), + ), + spacing=12, + padding=16, + ) ``` +Key ideas: + +- **`render()`** returns an element tree describing the UI. PythonNative creates and updates native views automatically. +- **`self.state`** holds your page's data. Call **`self.set_state(key=value)`** to update it — the UI re-renders automatically. +- Element functions like `pn.Text(...)`, `pn.Button(...)`, `pn.Column(...)` create lightweight descriptions, not native objects. + ## Run on a platform ```bash diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 7ae0f4c..6887360 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -1,43 +1,56 @@ # Navigation -This guide shows how to navigate between pages and handle lifecycle events. +This guide shows how to navigate between pages and pass data. ## Push / Pop -Use `push` and `pop` on your `Page` to change screens. You can pass a dotted path string or a class reference. +Use `push` and `pop` on your `Page` to change screens. Pass a dotted path string or a class reference, with optional `args`. ```python import pythonnative as pn + class MainPage(pn.Page): - def on_create(self): - stack = pn.StackView() - btn = pn.Button("Go next") - btn.set_on_click(lambda: self.push("app.second_page.SecondPage", args={"message": "Hello"})) - stack.add_view(btn) - self.set_root_view(stack) + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + return pn.Column( + pn.Text("Main Page", font_size=24), + pn.Button( + "Go next", + on_click=lambda: self.push( + "app.second_page.SecondPage", + args={"message": "Hello from Main"}, + ), + ), + spacing=12, + padding=16, + ) ``` -On the target page: +On the target page, retrieve args with `self.get_args()`: ```python class SecondPage(pn.Page): - def on_create(self): - args = self.get_args() - message = args.get("message", "Second") - stack = pn.StackView() - stack.add_view(pn.Label(message)) - back = pn.Button("Back") - back.set_on_click(lambda: self.pop()) - stack.add_view(back) - self.set_root_view(stack) + def __init__(self, native_instance): + super().__init__(native_instance) + + def render(self): + message = self.get_args().get("message", "Second Page") + return pn.Column( + pn.Text(message, font_size=20), + pn.Button("Back", on_click=self.pop), + spacing=12, + padding=16, + ) ``` ## Lifecycle PythonNative forwards lifecycle events from the host: -- `on_create` +- `on_create` — triggers the initial `render()` - `on_start` - `on_resume` - `on_pause` @@ -47,7 +60,7 @@ PythonNative forwards lifecycle events from the host: - `on_save_instance_state` - `on_restore_instance_state` -Android uses a single `MainActivity` hosting a `NavHostFragment` and a generic `PageFragment` per page. iOS forwards `viewWillAppear`/`viewWillDisappear` via an internal registry. +Override any of these on your `Page` subclass to respond to lifecycle changes. ## Notes @@ -60,36 +73,14 @@ Android uses a single `MainActivity` hosting a `NavHostFragment` and a generic ` - Each PythonNative page is hosted by a Swift `ViewController` instance. - Pages are pushed and popped on a root `UINavigationController`. - Lifecycle is forwarded from Swift to the registered Python page instance. -- Root view wiring: `Page.set_root_view` sizes and inserts the Python-native view into the controller’s view. - -Why this matches iOS conventions -- iOS apps commonly model screens as `UIViewController`s and use `UINavigationController` for hierarchical navigation. -- The approach integrates cleanly with add-to-app and system behaviors (e.g., state restoration). ### Android (single Activity, Fragment stack) - Single host `MainActivity` sets a `NavHostFragment` containing a navigation graph. - Each PythonNative page is represented by a generic `PageFragment` which instantiates the Python page and attaches its root view. - `push`/`pop` delegate to `NavController` (via a small `Navigator` helper). -- Arguments (`page_path`, `args_json`) live in Fragment arguments and restore across configuration changes and process death. - -Why this matches Android conventions -- Modern Android apps favor one Activity with many Fragments, using Jetpack Navigation for back stack, transitions, and deep links. -- It simplifies lifecycle, back handling, and state compared to one-Activity-per-screen. +- Arguments live in Fragment arguments and restore across configuration changes. ## Comparison to other frameworks -- React Native - - Android: single `Activity`, screens managed via `Fragment`s (e.g., `react-native-screens`). - - iOS: screens map to `UIViewController`s pushed on `UINavigationController`. -- .NET MAUI / Xamarin.Forms - - Android: single `Activity`, pages via Fragments/Navigation. - - iOS: pages map to `UIViewController`s on a `UINavigationController`. -- NativeScript - - Android: single `Activity`, pages as `Fragment`s. - - iOS: pages as `UIViewController`s on `UINavigationController`. -- Flutter (special case) - - Android: single `Activity` (`FlutterActivity`/`FlutterFragmentActivity`). - - iOS: `FlutterViewController` hosts Flutter’s internal navigator; add-to-app can push multiple `FlutterViewController`s. - -Bottom line -- iOS: one host VC class, many instances on a `UINavigationController`. -- Android: one host `Activity`, many `Fragment`s with Jetpack Navigation. +- **React Native:** Android: single `Activity`, screens managed via `Fragment`s. iOS: screens map to `UIViewController`s pushed on `UINavigationController`. +- **NativeScript:** Android: single `Activity`, pages as `Fragment`s. iOS: pages as `UIViewController`s on `UINavigationController`. +- **Flutter:** Android: single `Activity`. iOS: `FlutterViewController` hosts Flutter's navigator. diff --git a/docs/guides/styling.md b/docs/guides/styling.md index 20de1b2..000e40f 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -1,93 +1,70 @@ -## Styling +# Styling -This guide covers the lightweight styling APIs introduced in v0.4.0. +Style properties are passed as keyword arguments to element functions. This replaces the old fluent setter pattern. -The goal is to provide a small, predictable set of cross-platform styling hooks. Some features are Android-only today and will expand over time. +## Colors -### Colors - -- Use `set_background_color(color)` on any view. -- Color can be an ARGB int or a hex string like `#RRGGBB` or `#AARRGGBB`. +Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color props: ```python -stack = pn.StackView().set_background_color("#FFF5F5F5") +pn.Text("Hello", color="#FF3366") +pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") +pn.Column(pn.Text("Content"), background_color="#FFF5F5F5") ``` -### Padding and Margin - -- `set_padding(...)` is available on all views. - - Android: applies using `View.setPadding` with dp units. - - iOS: currently a no-op for most views; prefer container spacing (`StackView.set_spacing`) and layout. - -- `set_margin(...)` records margins on the child view. - - Android: applied automatically when added to a `StackView` (LinearLayout) via `LayoutParams.setMargins` (dp). - - iOS: not currently applied. +## Text styling -`set_padding`/`set_margin` accept these parameters (integers): `all`, `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`. Individual sides override group values. +`Text` and `Button` accept `font_size`, `color`, `bold`, and `text_align`: ```python -pn.Label("Name").set_margin(bottom=8) -pn.TextField().set_padding(horizontal=12, vertical=8) +pn.Text("Title", font_size=24, bold=True, text_align="center") +pn.Text("Subtitle", font_size=14, color="#666666") ``` -### Text styling +## Layout with Column and Row -Text components expose: -- `set_text(text) -> self` -- `set_text_color(color) -> self` (hex or ARGB int) -- `set_text_size(size) -> self` (sp on Android; pt on iOS via system font) - -Applies to `Label`, `TextField`, and `TextView`. +`Column` (vertical) and `Row` (horizontal) replace the old `StackView`: ```python -pn.Label("Hello").set_text_color("#FF3366").set_text_size(18) +pn.Column( + pn.Text("Username"), + pn.TextInput(placeholder="Enter username"), + pn.Text("Password"), + pn.TextInput(placeholder="Enter password", secure=True), + pn.Button("Login", on_click=handle_login), + spacing=8, + padding=16, + alignment="fill", +) ``` -### StackView layout +### Spacing -`StackView` (Android LinearLayout / iOS UIStackView) adds configuration helpers: - -- `set_axis('vertical'|'horizontal') -> self` -- `set_spacing(n) -> self` (dp on Android; points on iOS) -- `set_alignment(value) -> self` - - Cross-axis alignment. Supported values: `fill`, `center`, `leading`/`top`, `trailing`/`bottom`. - -```python -form = ( - pn.StackView() - .set_axis('vertical') - .set_spacing(8) - .set_alignment('fill') - .set_padding(all=16) -) -form.add_view(pn.Label("Username").set_margin(bottom=4)) -form.add_view(pn.TextField().set_padding(horizontal=12, vertical=8)) -``` +- `spacing=N` sets the gap between children in dp (Android) / points (iOS). -### Scroll helpers +### Padding -Wrap any view in a `ScrollView` using either approach: +- `padding=16` — all sides +- `padding={"horizontal": 12, "vertical": 8}` — per axis +- `padding={"left": 8, "top": 16, "right": 8, "bottom": 16}` — per side -```python -scroll = pn.ScrollView.wrap(form) -# or -scroll = form.wrap_in_scroll() -``` +Android: applied via `setPadding` in dp. iOS: best-effort via layout margins. -Attach the scroll view as your page root: +### Alignment -```python -self.set_root_view(scroll) -``` +Cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"`. -### Fluent setters +## ScrollView -Most setters now return `self` for chaining, e.g.: +Wrap content in a `ScrollView`: ```python -pn.Button("Tap me").set_on_click(lambda: print("hi")).set_padding(all=8) +pn.ScrollView( + pn.Column( + pn.Text("Item 1"), + pn.Text("Item 2"), + # ... many items + spacing=8, + ) +) ``` - -Note: Where platform limitations exist, the methods are no-ops and still return `self`. - - diff --git a/docs/index.md b/docs/index.md index 97b6b2b..96ac99d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,23 @@ # PythonNative -Build native Android and iOS apps with Python. PythonNative provides a Pythonic API for native UI components and a simple CLI to scaffold and run projects. +Build native Android and iOS apps with Python using a declarative, React-like component model. + +PythonNative provides a Pythonic API for native UI components, a virtual view tree with automatic reconciliation, and a simple CLI to scaffold and run projects. + +```python +import pythonnative as pn + + +class MainPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + self.state = {"count": 0} + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button("Increment", on_click=lambda: self.set_state(count=self.state["count"] + 1)), + spacing=12, + padding=16, + ) +``` diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 2646880..9faa74f 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -2,62 +2,30 @@ import pythonnative as pn -try: - # Optional: used for styling below; safe if rubicon isn't available - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - class MainPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - - def on_create(self) -> None: - super().on_create() - stack = pn.StackView().set_axis("vertical").set_spacing(12).set_alignment("fill").set_padding(all=16) - stack.add_view(pn.Label("Hello from PythonNative Demo!").set_text_size(18)) - button = pn.Button("Go to Second Page").set_padding(vertical=10, horizontal=14) - - def on_next() -> None: - # Visual confirmation that tap worked (iOS only) - try: - if UIColor is not None: - button.native_instance.setBackgroundColor_(UIColor.systemGreenColor()) - button.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - # Demonstrate passing args - self.push("app.second_page.SecondPage", args={"message": "Greetings from MainPage"}) - - button.set_on_click(on_next) - # Make the button visually obvious - button.set_background_color("#FF1E88E5") - stack.add_view(button) - self.set_root_view(stack.wrap_in_scroll()) - - def on_start(self) -> None: - super().on_start() - - def on_resume(self) -> None: - super().on_resume() - - def on_pause(self) -> None: - super().on_pause() - - def on_stop(self) -> None: - super().on_stop() - - def on_destroy(self) -> None: - super().on_destroy() - - def on_restart(self) -> None: - super().on_restart() - - def on_save_instance_state(self) -> None: - super().on_save_instance_state() - - def on_restore_instance_state(self) -> None: - super().on_restore_instance_state() + self.state = {"count": 0} + + def increment(self) -> None: + self.set_state(count=self.state["count"] + 1) + + def render(self) -> pn.Element: + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative Demo!", font_size=24, bold=True), + pn.Text(f"Tapped {self.state['count']} times", font_size=16), + pn.Button("Tap me", on_click=self.increment, background_color="#FF1E88E5"), + pn.Button( + "Go to Second Page", + on_click=lambda: self.push( + "app.second_page.SecondPage", + args={"message": "Greetings from MainPage"}, + ), + ), + spacing=12, + padding=16, + alignment="fill", + ) + ) diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 3515527..bd8d2f1 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -2,73 +2,23 @@ import pythonnative as pn -try: - # Optional: iOS styling support (safe if rubicon isn't available) - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - class SecondPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self) -> None: - super().on_create() - stack_view = pn.StackView() - # Read args passed from MainPage - args = self.get_args() - message = args.get("message", "Second page!") - stack_view.add_view(pn.Label(message)) - # Navigate to Third Page - to_third_btn = pn.Button("Go to Third Page") - # Style button on iOS similar to MainPage - try: - if UIColor is not None: - to_third_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - - def on_next() -> None: - # Visual confirmation that tap worked (iOS only) - try: - if UIColor is not None: - to_third_btn.native_instance.setBackgroundColor_(UIColor.systemGreenColor()) - to_third_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - self.push("app.third_page.ThirdPage", args={"from": "Second"}) - - to_third_btn.set_on_click(on_next) - stack_view.add_view(to_third_btn) - back_btn = pn.Button("Back") - back_btn.set_on_click(lambda: self.pop()) - stack_view.add_view(back_btn) - self.set_root_view(stack_view) - - def on_start(self) -> None: - super().on_start() - - def on_resume(self) -> None: - super().on_resume() - - def on_pause(self) -> None: - super().on_pause() - - def on_stop(self) -> None: - super().on_stop() - - def on_destroy(self) -> None: - super().on_destroy() - - def on_restart(self) -> None: - super().on_restart() - - def on_save_instance_state(self) -> None: - super().on_save_instance_state() - - def on_restore_instance_state(self) -> None: - super().on_restore_instance_state() + def render(self) -> pn.Element: + message = self.get_args().get("message", "Second Page") + return pn.ScrollView( + pn.Column( + pn.Text(message, font_size=20), + pn.Button( + "Go to Third Page", + on_click=lambda: self.push("app.third_page.ThirdPage"), + ), + pn.Button("Back", on_click=self.pop), + spacing=12, + padding=16, + alignment="fill", + ) + ) diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index 97b003f..efd3d5c 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -2,31 +2,17 @@ import pythonnative as pn -try: - # Optional: iOS styling support (safe if rubicon isn't available) - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") -except Exception: # pragma: no cover - UIColor = None - class ThirdPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - def on_create(self) -> None: - super().on_create() - stack = pn.StackView() - stack.add_view(pn.Label("This is the Third Page")) - back_btn = pn.Button("Back") - # Style button on iOS similar to MainPage - try: - if UIColor is not None: - back_btn.native_instance.setBackgroundColor_(UIColor.systemBlueColor()) - back_btn.native_instance.setTitleColor_forState_(UIColor.whiteColor(), 0) - except Exception: - pass - back_btn.set_on_click(lambda: self.pop()) - stack.add_view(back_btn) - self.set_root_view(stack) + def render(self) -> pn.Element: + return pn.Column( + pn.Text("Third Page", font_size=24, bold=True), + pn.Text("You navigated two levels deep."), + pn.Button("Back to Second", on_click=self.pop), + spacing=12, + padding=16, + alignment="fill", + ) diff --git a/mypy.ini b/mypy.ini index 333ddb6..9657f4d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -15,5 +15,5 @@ disallow_incomplete_defs = True implicit_reexport = True disable_error_code = attr-defined,no-redef -[mypy-pythonnative.button] +[mypy-pythonnative.native_views] disable_error_code = misc diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index a185962..b63c9cf 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -1,74 +1,54 @@ -from importlib import import_module -from typing import Any, Dict +"""PythonNative — declarative native UI for Android and iOS. + +Public API:: + + import pythonnative as pn + + class MainPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + self.state = {"count": 0} + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button("Increment", on_click=lambda: self.set_state(count=self.state["count"] + 1)), + spacing=12, + ) +""" __version__ = "0.4.0" +from .components import ( + ActivityIndicator, + Button, + Column, + Image, + ProgressBar, + Row, + ScrollView, + Spacer, + Switch, + Text, + TextInput, + WebView, +) +from .element import Element +from .page import Page + __all__ = [ - "ActivityIndicatorView", + "ActivityIndicator", "Button", - "DatePicker", - "ImageView", - "Label", - "ListView", - "MaterialActivityIndicatorView", - "MaterialButton", - "MaterialDatePicker", - "MaterialProgressView", - "MaterialSearchBar", - "MaterialSwitch", - "MaterialTimePicker", - "MaterialBottomNavigationView", - "MaterialToolbar", + "Column", + "Element", + "Image", "Page", - "PickerView", - "ProgressView", + "ProgressBar", + "Row", "ScrollView", - "SearchBar", - "StackView", + "Spacer", "Switch", - "TextField", - "TextView", - "TimePicker", + "Text", + "TextInput", "WebView", ] - -_NAME_TO_MODULE: Dict[str, str] = { - "ActivityIndicatorView": ".activity_indicator_view", - "Button": ".button", - "DatePicker": ".date_picker", - "ImageView": ".image_view", - "Label": ".label", - "ListView": ".list_view", - "MaterialActivityIndicatorView": ".material_activity_indicator_view", - "MaterialButton": ".material_button", - "MaterialDatePicker": ".material_date_picker", - "MaterialProgressView": ".material_progress_view", - "MaterialSearchBar": ".material_search_bar", - "MaterialSwitch": ".material_switch", - "MaterialTimePicker": ".material_time_picker", - "MaterialBottomNavigationView": ".material_bottom_navigation_view", - "MaterialToolbar": ".material_toolbar", - "Page": ".page", - "PickerView": ".picker_view", - "ProgressView": ".progress_view", - "ScrollView": ".scroll_view", - "SearchBar": ".search_bar", - "StackView": ".stack_view", - "Switch": ".switch", - "TextField": ".text_field", - "TextView": ".text_view", - "TimePicker": ".time_picker", - "WebView": ".web_view", -} - - -def __getattr__(name: str) -> Any: - module_path = _NAME_TO_MODULE.get(name) - if not module_path: - raise AttributeError(f"module 'pythonnative' has no attribute {name!r}") - module = import_module(module_path, package=__name__) - return getattr(module, name) - - -def __dir__() -> Any: - return sorted(list(globals().keys()) + __all__) diff --git a/src/pythonnative/activity_indicator_view.py b/src/pythonnative/activity_indicator_view.py deleted file mode 100644 index 722924e..0000000 --- a/src/pythonnative/activity_indicator_view.py +++ /dev/null @@ -1,71 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ActivityIndicatorViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def start_animating(self) -> None: - pass - - @abstractmethod - def stop_animating(self) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ProgressBar - # ======================================== - - from java import jclass - - class ActivityIndicatorView(ActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ProgressBar") - # self.native_instance = self.native_class(context, None, android.R.attr.progressBarStyleLarge) - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(True) - - def start_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.VISIBLE) - return - - def stop_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.GONE) - return - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiactivityindicatorview - # ======================================== - - from rubicon.objc import ObjCClass - - class ActivityIndicatorView(ActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIActivityIndicatorView") - self.native_instance = self.native_class.alloc().initWithActivityIndicatorStyle_( - 0 - ) # 0: UIActivityIndicatorViewStyleLarge - self.native_instance.hidesWhenStopped = True - - def start_animating(self) -> None: - self.native_instance.startAnimating() - - def stop_animating(self) -> None: - self.native_instance.stopAnimating() diff --git a/src/pythonnative/button.py b/src/pythonnative/button.py deleted file mode 100644 index 9d96904..0000000 --- a/src/pythonnative/button.py +++ /dev/null @@ -1,113 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Callable, Optional - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ButtonBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_title(self, title: str) -> "ButtonBase": - pass - - @abstractmethod - def get_title(self) -> str: - pass - - @abstractmethod - def set_on_click(self, callback: Callable[[], None]) -> "ButtonBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Button - # ======================================== - - from java import dynamic_proxy, jclass - - class Button(ButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.Button") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_title(title) - - def set_title(self, title: str) -> "Button": - self.native_instance.setText(title) - return self - - def get_title(self) -> str: - return self.native_instance.getText().toString() - - def set_on_click(self, callback: Callable[[], None]) -> "Button": - class OnClickListener(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback: Callable[[], None]) -> None: - super().__init__() - self.callback = callback - - def onClick(self, view: Any) -> None: - self.callback() - - listener = OnClickListener(callback) - self.native_instance.setOnClickListener(listener) - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uibutton - # ======================================== - - from rubicon.objc import SEL, ObjCClass, objc_method - - NSObject = ObjCClass("NSObject") - - # Mypy cannot understand Rubicon's dynamic subclassing; ignore the base type here. - class _PNButtonHandler(NSObject): # type: ignore[valid-type] - # Set by the Button when wiring up the target/action callback. - _callback: Optional[Callable[[], None]] = None - - @objc_method - def onTap_(self, sender: object) -> None: - try: - callback = self._callback - if callback is not None: - callback() - except Exception: - # Swallow exceptions to avoid crashing the app; logging is handled at higher levels - pass - - class Button(ButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIButton") - self.native_instance = self.native_class.alloc().init() - self.set_title(title) - - def set_title(self, title: str) -> "Button": - self.native_instance.setTitle_forState_(title, 0) - return self - - def get_title(self) -> str: - return self.native_instance.titleForState_(0) - - def set_on_click(self, callback: Callable[[], None]) -> "Button": - # Create a handler object with an Objective-C method `onTap:` and attach the Python callback - handler = _PNButtonHandler.new() - # Keep strong references to the handler and callback - self._click_handler = handler - handler._callback = callback - # UIControlEventTouchUpInside = 1 << 6 - self.native_instance.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) - return self diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 8281eae..22848c4 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -41,7 +41,6 @@ def init_project(args: argparse.Namespace) -> None: os.makedirs(app_dir, exist_ok=True) - # Minimal hello world app scaffold (no bootstrap function; host instantiates Page directly) main_page_py = os.path.join(app_dir, "main_page.py") if not os.path.exists(main_page_py) or args.force: with open(main_page_py, "w", encoding="utf-8") as f: @@ -52,20 +51,22 @@ def init_project(args: argparse.Namespace) -> None: class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - - def on_create(self): - super().on_create() - stack = ( - pn.StackView() - .set_axis("vertical") - .set_spacing(12) - .set_alignment("fill") - .set_padding(all=16) + self.state = {"count": 0} + + def increment(self): + self.set_state(count=self.state["count"] + 1) + + def render(self): + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative!", font_size=24, bold=True), + pn.Text(f"Tapped {self.state['count']} times"), + pn.Button("Tap me", on_click=self.increment), + spacing=12, + padding=16, + alignment="fill", + ) ) - stack.add_view(pn.Label("Hello from PythonNative!").set_text_size(18)) - button = pn.Button("Tap me").set_on_click(lambda: print("Button clicked")) - stack.add_view(button) - self.set_root_view(stack.wrap_in_scroll()) """ ) diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py new file mode 100644 index 0000000..ad57eb5 --- /dev/null +++ b/src/pythonnative/components.py @@ -0,0 +1,241 @@ +"""Built-in element-creating functions for declarative UI composition. + +Each function returns an :class:`Element` describing a native UI widget. +These are pure data — no native views are created until the reconciler +mounts the element tree. + +Naming follows React Native conventions: + +- ``Text`` (was *Label*) +- ``Button`` +- ``Column`` / ``Row`` (was *StackView* vertical/horizontal) +- ``ScrollView`` +- ``TextInput`` (was *TextField*) +- ``Image`` (was *ImageView*) +- ``Switch`` +- ``ProgressBar`` (was *ProgressView*) +- ``ActivityIndicator`` (was *ActivityIndicatorView*) +- ``WebView`` +- ``Spacer`` (new) +""" + +from typing import Any, Callable, Dict, Optional, Union + +from .element import Element + + +def _filter_none(**kwargs: Any) -> Dict[str, Any]: + """Return *kwargs* with ``None``-valued entries removed.""" + return {k: v for k, v in kwargs.items() if v is not None} + + +# --------------------------------------------------------------------------- +# Leaf components +# --------------------------------------------------------------------------- + + +def Text( + text: str = "", + *, + font_size: Optional[float] = None, + color: Optional[str] = None, + bold: bool = False, + text_align: Optional[str] = None, + background_color: Optional[str] = None, + max_lines: Optional[int] = None, + key: Optional[str] = None, +) -> Element: + """Display text.""" + props = _filter_none( + text=text, + font_size=font_size, + color=color, + bold=bold or None, + text_align=text_align, + background_color=background_color, + max_lines=max_lines, + ) + return Element("Text", props, [], key=key) + + +def Button( + title: str = "", + *, + on_click: Optional[Callable[[], None]] = None, + color: Optional[str] = None, + background_color: Optional[str] = None, + font_size: Optional[float] = None, + enabled: bool = True, + key: Optional[str] = None, +) -> Element: + """Create a tappable button.""" + props: Dict[str, Any] = {"title": title} + if on_click is not None: + props["on_click"] = on_click + if color is not None: + props["color"] = color + if background_color is not None: + props["background_color"] = background_color + if font_size is not None: + props["font_size"] = font_size + if not enabled: + props["enabled"] = False + return Element("Button", props, [], key=key) + + +def TextInput( + *, + value: str = "", + placeholder: str = "", + on_change: Optional[Callable[[str], None]] = None, + secure: bool = False, + font_size: Optional[float] = None, + color: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Create a single-line text entry field.""" + props: Dict[str, Any] = {"value": value} + if placeholder: + props["placeholder"] = placeholder + if on_change is not None: + props["on_change"] = on_change + if secure: + props["secure"] = True + if font_size is not None: + props["font_size"] = font_size + if color is not None: + props["color"] = color + if background_color is not None: + props["background_color"] = background_color + return Element("TextInput", props, [], key=key) + + +def Image( + source: str = "", + *, + width: Optional[float] = None, + height: Optional[float] = None, + scale_type: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Display an image from a resource path or URL.""" + props = _filter_none( + source=source or None, + width=width, + height=height, + scale_type=scale_type, + background_color=background_color, + ) + return Element("Image", props, [], key=key) + + +def Switch( + *, + value: bool = False, + on_change: Optional[Callable[[bool], None]] = None, + key: Optional[str] = None, +) -> Element: + """Create a toggle switch.""" + props: Dict[str, Any] = {"value": value} + if on_change is not None: + props["on_change"] = on_change + return Element("Switch", props, [], key=key) + + +def ProgressBar( + *, + value: float = 0.0, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Show determinate progress (0.0 – 1.0).""" + props = _filter_none(value=value, background_color=background_color) + return Element("ProgressBar", props, [], key=key) + + +def ActivityIndicator( + *, + animating: bool = True, + key: Optional[str] = None, +) -> Element: + """Show an indeterminate loading spinner.""" + return Element("ActivityIndicator", {"animating": animating}, [], key=key) + + +def WebView( + *, + url: str = "", + key: Optional[str] = None, +) -> Element: + """Embed web content.""" + props: Dict[str, Any] = {} + if url: + props["url"] = url + return Element("WebView", props, [], key=key) + + +def Spacer( + *, + size: Optional[float] = None, + key: Optional[str] = None, +) -> Element: + """Insert empty space with an optional fixed size.""" + props = _filter_none(size=size) + return Element("Spacer", props, [], key=key) + + +# --------------------------------------------------------------------------- +# Container components +# --------------------------------------------------------------------------- + +PaddingValue = Union[int, float, Dict[str, Union[int, float]]] + + +def Column( + *children: Element, + spacing: float = 0, + padding: Optional[PaddingValue] = None, + alignment: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Arrange children vertically.""" + props = _filter_none( + spacing=spacing or None, + padding=padding, + alignment=alignment, + background_color=background_color, + ) + return Element("Column", props, list(children), key=key) + + +def Row( + *children: Element, + spacing: float = 0, + padding: Optional[PaddingValue] = None, + alignment: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Arrange children horizontally.""" + props = _filter_none( + spacing=spacing or None, + padding=padding, + alignment=alignment, + background_color=background_color, + ) + return Element("Row", props, list(children), key=key) + + +def ScrollView( + child: Optional[Element] = None, + *, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Wrap a single child in a scrollable container.""" + children = [child] if child is not None else [] + props = _filter_none(background_color=background_color) + return Element("ScrollView", props, children, key=key) diff --git a/src/pythonnative/date_picker.py b/src/pythonnative/date_picker.py deleted file mode 100644 index f357e03..0000000 --- a/src/pythonnative/date_picker.py +++ /dev/null @@ -1,76 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class DatePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_date(self, year: int, month: int, day: int) -> "DatePickerBase": - pass - - @abstractmethod - def get_date(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/DatePicker - # ======================================== - - from typing import Any - - from java import jclass - - class DatePicker(DatePickerBase, ViewBase): - def __init__(self, context: Any, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.DatePicker") - self.native_instance = self.native_class(context) - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> "DatePicker": - self.native_instance.updateDate(year, month, day) - return self - - def get_date(self) -> tuple: - year = self.native_instance.getYear() - month = self.native_instance.getMonth() - day = self.native_instance.getDayOfMonth() - return year, month, day - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import datetime - - from rubicon.objc import ObjCClass - - class DatePicker(DatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> "DatePicker": - date = datetime(year, month, day) - self.native_instance.setDate_(date) - return self - - def get_date(self) -> tuple: - date = self.native_instance.date() - return date.year, date.month, date.day diff --git a/src/pythonnative/element.py b/src/pythonnative/element.py new file mode 100644 index 0000000..8930386 --- /dev/null +++ b/src/pythonnative/element.py @@ -0,0 +1,47 @@ +"""Lightweight element descriptors for the virtual view tree. + +An Element is an immutable description of a UI node — analogous to a React +element. It captures a type name, a props dictionary, and an ordered list +of children without creating any native platform objects. The reconciler +consumes these trees to determine what native views must be created, +updated, or removed. +""" + +from typing import Any, Dict, List, Optional + + +class Element: + """Immutable description of a single UI node.""" + + __slots__ = ("type", "props", "children", "key") + + def __init__( + self, + type_name: str, + props: Dict[str, Any], + children: List["Element"], + key: Optional[str] = None, + ) -> None: + self.type = type_name + self.props = props + self.children = children + self.key = key + + def __repr__(self) -> str: + return f"Element({self.type!r}, props={set(self.props)}, children={len(self.children)})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Element): + return NotImplemented + return ( + self.type == other.type + and self.props == other.props + and self.children == other.children + and self.key == other.key + ) + + def __ne__(self, other: object) -> bool: + result = self.__eq__(other) + if result is NotImplemented: + return result + return not result diff --git a/src/pythonnative/image_view.py b/src/pythonnative/image_view.py deleted file mode 100644 index c3b3d1a..0000000 --- a/src/pythonnative/image_view.py +++ /dev/null @@ -1,78 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ImageViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_image(self, image: str) -> "ImageViewBase": - pass - - @abstractmethod - def get_image(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ImageView - # ======================================== - - from android.graphics import BitmapFactory - from java import jclass - - class ImageView(ImageViewBase, ViewBase): - def __init__(self, image: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.ImageView") - context = get_android_context() - self.native_instance = self.native_class(context) - if image: - self.set_image(image) - - def set_image(self, image: str) -> "ImageView": - bitmap = BitmapFactory.decodeFile(image) - self.native_instance.setImageBitmap(bitmap) - return self - - def get_image(self) -> str: - # Please note that this is a simplistic representation, getting image from ImageView - # in Android would require converting Drawable to Bitmap and then to File - return "Image file path in Android" - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiimageview - # ======================================== - - from rubicon.objc import ObjCClass - from rubicon.objc.api import NSString, UIImage - - class ImageView(ImageViewBase, ViewBase): - def __init__(self, image: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIImageView") - self.native_instance = self.native_class.alloc().init() - if image: - self.set_image(image) - - def set_image(self, image: str) -> "ImageView": - ns_str = NSString.alloc().initWithUTF8String_(image) - ui_image = UIImage.imageNamed_(ns_str) - self.native_instance.setImage_(ui_image) - return self - - def get_image(self) -> str: - # Similar to Android, getting the image from UIImageView isn't straightforward. - return "Image file name in iOS" diff --git a/src/pythonnative/label.py b/src/pythonnative/label.py deleted file mode 100644 index d998b7d..0000000 --- a/src/pythonnative/label.py +++ /dev/null @@ -1,133 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class LabelBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> "LabelBase": - pass - - @abstractmethod - def get_text(self) -> str: - pass - - @abstractmethod - def set_text_color(self, color: Any) -> "LabelBase": - pass - - @abstractmethod - def set_text_size(self, size: float) -> "LabelBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/TextView - # ======================================== - - from java import jclass - - class Label(LabelBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.TextView") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_text(text) - - def set_text(self, text: str) -> "Label": - self.native_instance.setText(text) - return self - - def get_text(self) -> str: - return self.native_instance.getText().toString() - - def set_text_color(self, color: Any) -> "Label": - # Accept int ARGB or hex string - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - self.native_instance.setTextColor(color_int) - except Exception: - pass - return self - - def set_text_size(self, size_sp: float) -> "Label": - try: - self.native_instance.setTextSize(float(size_sp)) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uilabel - # ======================================== - - from rubicon.objc import ObjCClass - - class Label(LabelBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UILabel") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> "Label": - self.native_instance.setText_(text) - return self - - def get_text(self) -> str: - return self.native_instance.text() - - def set_text_color(self, color: Any) -> "Label": - # Accept int ARGB or hex string - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - self.native_instance.setTextColor_(color_obj) - except Exception: - pass - return self - - def set_text_size(self, size: float) -> "Label": - try: - UIFont = ObjCClass("UIFont") - font = UIFont.systemFontOfSize_(float(size)) - self.native_instance.setFont_(font) - except Exception: - pass - return self diff --git a/src/pythonnative/list_view.py b/src/pythonnative/list_view.py deleted file mode 100644 index c4433e4..0000000 --- a/src/pythonnative/list_view.py +++ /dev/null @@ -1,76 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ListViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_data(self, data: list) -> "ListViewBase": - pass - - @abstractmethod - def get_data(self) -> list: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ListView - # ======================================== - - from java import jclass - - class ListView(ListViewBase, ViewBase): - def __init__(self, context: Any, data: list = []) -> None: - super().__init__() - self.context = context - self.native_class = jclass("android.widget.ListView") - self.native_instance = self.native_class(context) - self.set_data(data) - - def set_data(self, data: list) -> "ListView": - adapter = jclass("android.widget.ArrayAdapter")( - self.context, jclass("android.R$layout").simple_list_item_1, data - ) - self.native_instance.setAdapter(adapter) - return self - - def get_data(self) -> list: - adapter = self.native_instance.getAdapter() - return [adapter.getItem(i) for i in range(adapter.getCount())] - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitableview - # ======================================== - - from rubicon.objc import ObjCClass - - class ListView(ListViewBase, ViewBase): - def __init__(self, data: list = []) -> None: - super().__init__() - self.native_class = ObjCClass("UITableView") - self.native_instance = self.native_class.alloc().init() - self.set_data(data) - - def set_data(self, data: list) -> "ListView": - # Note: This is a simplified representation. Normally, you would need to create a UITableViewDataSource. - self.native_instance.reloadData() - return self - - def get_data(self) -> list: - # Note: This is a simplified representation. - # Normally, you would need to get data from the UITableViewDataSource. - return [] diff --git a/src/pythonnative/material_activity_indicator_view.py b/src/pythonnative/material_activity_indicator_view.py deleted file mode 100644 index b619960..0000000 --- a/src/pythonnative/material_activity_indicator_view.py +++ /dev/null @@ -1,71 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialActivityIndicatorViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def start_animating(self) -> None: - pass - - @abstractmethod - def stop_animating(self) -> None: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/progressindicator/CircularProgressIndicator - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self, context: Any) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.progressindicator.CircularProgressIndicator") - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(True) - - def start_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.VISIBLE) - return - - def stop_animating(self) -> None: - # self.native_instance.setVisibility(android.view.View.GONE) - return - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiactivityindicatorview - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialActivityIndicatorView(MaterialActivityIndicatorViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIActivityIndicatorView") - self.native_instance = self.native_class.alloc().initWithActivityIndicatorStyle_( - 0 - ) # 0: UIActivityIndicatorViewStyleLarge - self.native_instance.hidesWhenStopped = True - - def start_animating(self) -> None: - self.native_instance.startAnimating() - - def stop_animating(self) -> None: - self.native_instance.stopAnimating() diff --git a/src/pythonnative/material_button.py b/src/pythonnative/material_button.py deleted file mode 100644 index 5816e30..0000000 --- a/src/pythonnative/material_button.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialButtonBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_title(self, title: str) -> "MaterialButtonBase": - pass - - @abstractmethod - def get_title(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/button/MaterialButton - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, context: Any, title: str = "") -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.button.MaterialButton") - self.native_instance = self.native_class(context) - self.set_title(title) - - def set_title(self, title: str) -> "MaterialButton": - self.native_instance.setText(title) - return self - - def get_title(self) -> str: - return self.native_instance.getText().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uibutton - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialButton(MaterialButtonBase, ViewBase): - def __init__(self, title: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UIButton") # Apple does not have a direct equivalent for MaterialButton - self.native_instance = self.native_class.alloc().init() - self.set_title(title) - - def set_title(self, title: str) -> "MaterialButton": - self.native_instance.setTitle_forState_(title, 0) - return self - - def get_title(self) -> str: - return self.native_instance.titleForState_(0) diff --git a/src/pythonnative/material_date_picker.py b/src/pythonnative/material_date_picker.py deleted file mode 100644 index f39ba3e..0000000 --- a/src/pythonnative/material_date_picker.py +++ /dev/null @@ -1,87 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialDatePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_date(self, year: int, month: int, day: int) -> "MaterialDatePickerBase": - pass - - @abstractmethod - def get_date(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/datepicker/MaterialDatePicker - # ======================================== - - from java import jclass - - class MaterialDatePicker(MaterialDatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.datepicker.MaterialDatePicker") - self.builder = self.native_class.Builder.datePicker() - self.set_date(year, month, day) - self.native_instance = self.builder.build() - - def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": - # MaterialDatePicker uses milliseconds since epoch to set date - from java.util import Calendar - - cal = Calendar.getInstance() - cal.set(year, month, day) - milliseconds = cal.getTimeInMillis() - self.builder.setSelection(milliseconds) - return self - - def get_date(self) -> tuple: - # Convert selection (milliseconds since epoch) back to a date - from java.util import Calendar - - cal = Calendar.getInstance() - cal.setTimeInMillis(self.native_instance.getSelection()) - return ( - cal.get(Calendar.YEAR), - cal.get(Calendar.MONTH), - cal.get(Calendar.DAY_OF_MONTH), - ) - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import datetime - - from rubicon.objc import ObjCClass - - class MaterialDatePicker(MaterialDatePickerBase, ViewBase): - def __init__(self, year: int = 0, month: int = 0, day: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.set_date(year, month, day) - - def set_date(self, year: int, month: int, day: int) -> "MaterialDatePicker": - date = datetime(year, month, day) - self.native_instance.setDate_(date) - return self - - def get_date(self) -> tuple: - date = self.native_instance.date() - return date.year, date.month, date.day diff --git a/src/pythonnative/material_progress_view.py b/src/pythonnative/material_progress_view.py deleted file mode 100644 index 2b76275..0000000 --- a/src/pythonnative/material_progress_view.py +++ /dev/null @@ -1,70 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialProgressViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_progress(self, progress: float) -> "MaterialProgressViewBase": - pass - - @abstractmethod - def get_progress(self) -> float: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/progressindicator/LinearProgressIndicator - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self, context: Any) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.progressindicator.LinearProgressIndicator") - self.native_instance = self.native_class(context) - self.native_instance.setIndeterminate(False) - - def set_progress(self, progress: float) -> "MaterialProgressView": - self.native_instance.setProgress(int(progress * 100)) - return self - - def get_progress(self) -> float: - return self.native_instance.getProgress() / 100.0 - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiprogressview - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialProgressView(MaterialProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIProgressView") - self.native_instance = self.native_class.alloc().initWithProgressViewStyle_( - 0 - ) # 0: UIProgressViewStyleDefault - - def set_progress(self, progress: float) -> "MaterialProgressView": - self.native_instance.setProgress_animated_(progress, False) - return self - - def get_progress(self) -> float: - return self.native_instance.progress() diff --git a/src/pythonnative/material_search_bar.py b/src/pythonnative/material_search_bar.py deleted file mode 100644 index 950161c..0000000 --- a/src/pythonnative/material_search_bar.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialSearchBarBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_query(self, query: str) -> "MaterialSearchBarBase": - pass - - @abstractmethod - def get_query(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/search/SearchBar - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, context: Any, query: str = "") -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.search.SearchBar") - self.native_instance = self.native_class(context) - self.set_query(query) - - def set_query(self, query: str) -> "MaterialSearchBar": - self.native_instance.setQuery(query, False) - return self - - def get_query(self) -> str: - return self.native_instance.getQuery().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uisearchbar - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialSearchBar(MaterialSearchBarBase, ViewBase): - def __init__(self, query: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UISearchBar") - self.native_instance = self.native_class.alloc().init() - self.set_query(query) - - def set_query(self, query: str) -> "MaterialSearchBar": - self.native_instance.set_searchText_(query) - return self - - def get_query(self) -> str: - return self.native_instance.searchText() diff --git a/src/pythonnative/material_switch.py b/src/pythonnative/material_switch.py deleted file mode 100644 index 20e9bab..0000000 --- a/src/pythonnative/material_switch.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialSwitchBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_on(self, value: bool) -> "MaterialSwitchBase": - pass - - @abstractmethod - def is_on(self) -> bool: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/materialswitch/MaterialSwitch - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, context: Any, value: bool = False) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.switch.MaterialSwitch") - self.native_instance = self.native_class(context) - self.set_on(value) - - def set_on(self, value: bool) -> "MaterialSwitch": - self.native_instance.setChecked(value) - return self - - def is_on(self) -> bool: - return self.native_instance.isChecked() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiswitch - # ======================================== - - from rubicon.objc import ObjCClass - - class MaterialSwitch(MaterialSwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = ObjCClass("UISwitch") - self.native_instance = self.native_class.alloc().init() - self.set_on(value) - - def set_on(self, value: bool) -> "MaterialSwitch": - self.native_instance.setOn_animated_(value, False) - return self - - def is_on(self) -> bool: - return self.native_instance.isOn() diff --git a/src/pythonnative/material_time_picker.py b/src/pythonnative/material_time_picker.py deleted file mode 100644 index 5829ca7..0000000 --- a/src/pythonnative/material_time_picker.py +++ /dev/null @@ -1,76 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class MaterialTimePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_time(self, hour: int, minute: int) -> "MaterialTimePickerBase": - pass - - @abstractmethod - def get_time(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/com/google/android/material/timepicker/MaterialTimePicker - # ======================================== - - from typing import Any - - from java import jclass - - class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = jclass("com.google.android.material.timepicker.MaterialTimePicker") - self.native_instance = self.native_class(context) - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": - self.native_instance.setTime(hour, minute) - return self - - def get_time(self) -> tuple: - hour = self.native_instance.getHour() - minute = self.native_instance.getMinute() - return hour, minute - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import time - - from rubicon.objc import ObjCClass - - class MaterialTimePicker(MaterialTimePickerBase, ViewBase): - def __init__(self, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.native_instance.setDatePickerMode_(1) # Setting mode to Time - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "MaterialTimePicker": - t = time(hour, minute) - self.native_instance.setTime_(t) - return self - - def get_time(self) -> tuple: - t = self.native_instance.time() - return t.hour, t.minute diff --git a/src/pythonnative/native_views.py b/src/pythonnative/native_views.py new file mode 100644 index 0000000..2efab0e --- /dev/null +++ b/src/pythonnative/native_views.py @@ -0,0 +1,800 @@ +"""Platform-specific native view creation and update logic. + +This module replaces the old per-widget files. All platform-branching +lives here, guarded behind lazy imports so the module can be imported +on desktop for testing. +""" + +from typing import Any, Callable, Dict, Optional, Union + +from .utils import IS_ANDROID + +# ====================================================================== +# Abstract handler protocol +# ====================================================================== + + +class ViewHandler: + """Protocol for creating, updating, and managing children of a native view type.""" + + def create(self, props: Dict[str, Any]) -> Any: + raise NotImplementedError + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + raise NotImplementedError + + def add_child(self, parent: Any, child: Any) -> None: + pass + + def remove_child(self, parent: Any, child: Any) -> None: + pass + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + self.add_child(parent, child) + + +# ====================================================================== +# Registry +# ====================================================================== + + +class NativeViewRegistry: + """Maps element type names to platform-specific :class:`ViewHandler` instances.""" + + def __init__(self) -> None: + self._handlers: Dict[str, ViewHandler] = {} + + def register(self, type_name: str, handler: ViewHandler) -> None: + self._handlers[type_name] = handler + + def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: + handler = self._handlers.get(type_name) + if handler is None: + raise ValueError(f"Unknown element type: {type_name!r}") + return handler.create(props) + + def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: + handler = self._handlers.get(type_name) + if handler is not None: + handler.update(native_view, changed_props) + + def add_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.add_child(parent, child) + + def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.remove_child(parent, child) + + def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.insert_child(parent, child, index) + + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def parse_color_int(color: Union[str, int]) -> int: + """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. + + Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values + with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. + """ + if isinstance(color, int): + val = color + else: + c = color.strip().lstrip("#") + if len(c) == 6: + c = "FF" + c + val = int(c, 16) + if val > 0x7FFFFFFF: + val -= 0x100000000 + return val + + +def _resolve_padding( + padding: Any, +) -> tuple: + """Normalise various padding representations to ``(left, top, right, bottom)``.""" + if padding is None: + return (0, 0, 0, 0) + if isinstance(padding, (int, float)): + v = int(padding) + return (v, v, v, v) + if isinstance(padding, dict): + h = int(padding.get("horizontal", 0)) + v = int(padding.get("vertical", 0)) + left = int(padding.get("left", h)) + right = int(padding.get("right", h)) + top = int(padding.get("top", v)) + bottom = int(padding.get("bottom", v)) + a = int(padding.get("all", 0)) + if a: + left = left or a + right = right or a + top = top or a + bottom = bottom or a + return (left, top, right, bottom) + return (0, 0, 0, 0) + + +# ====================================================================== +# Platform handler registration (lazy imports inside functions) +# ====================================================================== + + +def _register_android_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 + from java import dynamic_proxy, jclass + + from .utils import get_android_context + + def _ctx() -> Any: + return get_android_context() + + def _density() -> float: + return float(_ctx().getResources().getDisplayMetrics().density) + + def _dp(value: float) -> int: + return int(value * _density()) + + # ---- Text ----------------------------------------------------------- + class AndroidTextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tv = jclass("android.widget.TextView")(_ctx()) + self._apply(tv, props) + return tv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, tv: Any, props: Dict[str, Any]) -> None: + if "text" in props: + tv.setText(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + tv.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + tv.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tv.setBackgroundColor(parse_color_int(props["background_color"])) + if "bold" in props and props["bold"]: + tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 + if "max_lines" in props and props["max_lines"] is not None: + tv.setMaxLines(int(props["max_lines"])) + if "text_align" in props: + Gravity = jclass("android.view.Gravity") + mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} + tv.setGravity(mapping.get(props["text_align"], Gravity.START)) + + # ---- Button --------------------------------------------------------- + class AndroidButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = jclass("android.widget.Button")(_ctx()) + self._apply(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setText(str(props["title"])) + if "font_size" in props and props["font_size"] is not None: + btn.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + btn.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor(parse_color_int(props["background_color"])) + if "enabled" in props: + btn.setEnabled(bool(props["enabled"])) + if "on_click" in props: + cb = props["on_click"] + if cb is not None: + + class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(ClickProxy(cb)) + else: + btn.setOnClickListener(None) + + # ---- Column (vertical LinearLayout) --------------------------------- + class AndroidColumnHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ll = jclass("android.widget.LinearLayout")(_ctx()) + ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) + self._apply(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, ll: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + px = _dp(float(props["spacing"])) + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + d = GradientDrawable() + d.setColor(0x00000000) + d.setSize(1, px) + ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) + ll.setDividerDrawable(d) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + if "alignment" in props and props["alignment"]: + Gravity = jclass("android.view.Gravity") + mapping = { + "fill": Gravity.FILL_HORIZONTAL, + "center": Gravity.CENTER_HORIZONTAL, + "leading": Gravity.START, + "start": Gravity.START, + "trailing": Gravity.END, + "end": Gravity.END, + } + ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_HORIZONTAL)) + if "background_color" in props and props["background_color"] is not None: + ll.setBackgroundColor(parse_color_int(props["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + # ---- Row (horizontal LinearLayout) ---------------------------------- + class AndroidRowHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ll = jclass("android.widget.LinearLayout")(_ctx()) + ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) + self._apply(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, ll: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + px = _dp(float(props["spacing"])) + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + d = GradientDrawable() + d.setColor(0x00000000) + d.setSize(px, 1) + ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) + ll.setDividerDrawable(d) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + if "alignment" in props and props["alignment"]: + Gravity = jclass("android.view.Gravity") + mapping = { + "fill": Gravity.FILL_VERTICAL, + "center": Gravity.CENTER_VERTICAL, + "top": Gravity.TOP, + "bottom": Gravity.BOTTOM, + } + ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_VERTICAL)) + if "background_color" in props and props["background_color"] is not None: + ll.setBackgroundColor(parse_color_int(props["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + # ---- ScrollView ----------------------------------------------------- + class AndroidScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = jclass("android.widget.ScrollView")(_ctx()) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor(parse_color_int(props["background_color"])) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + # ---- TextInput (EditText) ------------------------------------------- + class AndroidTextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + et = jclass("android.widget.EditText")(_ctx()) + self._apply(et, props) + return et + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, et: Any, props: Dict[str, Any]) -> None: + if "value" in props: + et.setText(str(props["value"])) + if "placeholder" in props: + et.setHint(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + et.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + et.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + et.setBackgroundColor(parse_color_int(props["background_color"])) + if "secure" in props and props["secure"]: + InputType = jclass("android.text.InputType") + et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + + # ---- Image ---------------------------------------------------------- + class AndroidImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = jclass("android.widget.ImageView")(_ctx()) + self._apply(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor(parse_color_int(props["background_color"])) + + # ---- Switch --------------------------------------------------------- + class AndroidSwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = jclass("android.widget.Switch")(_ctx()) + self._apply(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setChecked(bool(props["value"])) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): + def __init__(self, callback: Callable[[bool], None]) -> None: + super().__init__() + self.callback = callback + + def onCheckedChanged(self, button: Any, checked: bool) -> None: + self.callback(checked) + + sw.setOnCheckedChangeListener(CheckedProxy(cb)) + + # ---- ProgressBar ---------------------------------------------------- + class AndroidProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + style = jclass("android.R$attr").progressBarStyleHorizontal + pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) + pb.setMax(1000) + self._apply(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, pb: Any, props: Dict[str, Any]) -> None: + if "value" in props: + pb.setProgress(int(float(props["value"]) * 1000)) + + # ---- ActivityIndicator (circular ProgressBar) ----------------------- + class AndroidActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pb = jclass("android.widget.ProgressBar")(_ctx()) + if not props.get("animating", True): + pb.setVisibility(jclass("android.view.View").GONE) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + View = jclass("android.view.View") + if "animating" in changed: + native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) + + # ---- WebView -------------------------------------------------------- + class AndroidWebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = jclass("android.webkit.WebView")(_ctx()) + if "url" in props and props["url"]: + wv.loadUrl(str(props["url"])) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + native_view.loadUrl(str(changed["url"])) + + # ---- Spacer --------------------------------------------------------- + class AndroidSpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = jclass("android.view.View")(_ctx()) + if "size" in props and props["size"] is not None: + px = _dp(float(props["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + v.setLayoutParams(lp) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + px = _dp(float(changed["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + native_view.setLayoutParams(lp) + + registry.register("Text", AndroidTextHandler()) + registry.register("Button", AndroidButtonHandler()) + registry.register("Column", AndroidColumnHandler()) + registry.register("Row", AndroidRowHandler()) + registry.register("ScrollView", AndroidScrollViewHandler()) + registry.register("TextInput", AndroidTextInputHandler()) + registry.register("Image", AndroidImageHandler()) + registry.register("Switch", AndroidSwitchHandler()) + registry.register("ProgressBar", AndroidProgressBarHandler()) + registry.register("ActivityIndicator", AndroidActivityIndicatorHandler()) + registry.register("WebView", AndroidWebViewHandler()) + registry.register("Spacer", AndroidSpacerHandler()) + + +def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 + from rubicon.objc import SEL, ObjCClass, objc_method + + NSObject = ObjCClass("NSObject") + UIColor = ObjCClass("UIColor") + UIFont = ObjCClass("UIFont") + + def _uicolor(color: Any) -> Any: + argb = parse_color_int(color) + if argb < 0: + argb += 0x100000000 + a = ((argb >> 24) & 0xFF) / 255.0 + r = ((argb >> 16) & 0xFF) / 255.0 + g = ((argb >> 8) & 0xFF) / 255.0 + b = (argb & 0xFF) / 255.0 + return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + + # ---- Text ----------------------------------------------------------- + class IOSTextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + label = ObjCClass("UILabel").alloc().init() + self._apply(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + if "text" in props: + label.setText_(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + if props.get("bold"): + label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) + else: + label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + elif "bold" in props and props["bold"]: + size = label.font().pointSize() if label.font() else 17.0 + label.setFont_(UIFont.boldSystemFontOfSize_(size)) + if "color" in props and props["color"] is not None: + label.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + label.setBackgroundColor_(_uicolor(props["background_color"])) + if "max_lines" in props and props["max_lines"] is not None: + label.setNumberOfLines_(int(props["max_lines"])) + if "text_align" in props: + mapping = {"left": 0, "center": 1, "right": 2} + label.setTextAlignment_(mapping.get(props["text_align"], 0)) + + # ---- Button --------------------------------------------------------- + + # btn id(ObjCInstance) -> _PNButtonTarget. Keeps a strong ref to + # each handler (preventing GC) and lets us swap the callback on + # re-render without calling removeTarget/addTarget (which crashes + # due to rubicon-objc wrapper lifecycle issues). + _pn_btn_handler_map: dict = {} + + class _PNButtonTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[], None]] = None + + @objc_method + def onTap_(self, sender: object) -> None: + if self._callback is not None: + self._callback() + + # Strong refs to retained UIButton wrappers so the ObjCInstance + # (and its prevent-deallocation retain) stays alive for the + # lifetime of the app. + _pn_retained_views: list = [] + + class IOSButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = ObjCClass("UIButton").alloc().init() + btn.retain() + _pn_retained_views.append(btn) + _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) + btn.setTitleColor_forState_(_ios_blue, 0) + self._apply(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setTitle_forState_(str(props["title"]), 0) + if "font_size" in props and props["font_size"] is not None: + btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor_(_uicolor(props["background_color"])) + if "color" not in props: + _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) + btn.setTitleColor_forState_(_white, 0) + if "color" in props and props["color"] is not None: + btn.setTitleColor_forState_(_uicolor(props["color"]), 0) + if "enabled" in props: + btn.setEnabled_(bool(props["enabled"])) + if "on_click" in props: + existing = _pn_btn_handler_map.get(id(btn)) + if existing is not None: + existing._callback = props["on_click"] + else: + handler = _PNButtonTarget.new() + handler._callback = props["on_click"] + _pn_btn_handler_map[id(btn)] = handler + btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) + + # ---- Column (vertical UIStackView) ---------------------------------- + class IOSColumnHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) + sv.setAxis_(1) # vertical + self._apply(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sv: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + sv.setSpacing_(float(props["spacing"])) + if "alignment" in props and props["alignment"]: + mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4} + sv.setAlignment_(mapping.get(props["alignment"], 0)) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + sv.setLayoutMarginsRelativeArrangement_(True) + try: + sv.setDirectionalLayoutMargins_((top, left, bottom, right)) + except Exception: + sv.setLayoutMargins_((top, left, bottom, right)) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + # ---- Row (horizontal UIStackView) ----------------------------------- + class IOSRowHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) + sv.setAxis_(0) # horizontal + self._apply(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sv: Any, props: Dict[str, Any]) -> None: + if "spacing" in props and props["spacing"]: + sv.setSpacing_(float(props["spacing"])) + if "alignment" in props and props["alignment"]: + mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4} + sv.setAlignment_(mapping.get(props["alignment"], 0)) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + # ---- ScrollView ----------------------------------------------------- + class IOSScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIScrollView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + child.setTranslatesAutoresizingMaskIntoConstraints_(False) + parent.addSubview_(child) + content_guide = parent.contentLayoutGuide + frame_guide = parent.frameLayoutGuide + child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) + child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) + child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) + child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) + child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + # ---- TextInput (UITextField) ---------------------------------------- + class IOSTextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tf = ObjCClass("UITextField").alloc().init() + tf.setBorderStyle_(2) # RoundedRect + self._apply(tf, props) + return tf + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, tf: Any, props: Dict[str, Any]) -> None: + if "value" in props: + tf.setText_(str(props["value"])) + if "placeholder" in props: + tf.setPlaceholder_(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "color" in props and props["color"] is not None: + tf.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tf.setBackgroundColor_(_uicolor(props["background_color"])) + if "secure" in props and props["secure"]: + tf.setSecureTextEntry_(True) + + # ---- Image ---------------------------------------------------------- + class IOSImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = ObjCClass("UIImageView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor_(_uicolor(props["background_color"])) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + # ---- Switch --------------------------------------------------------- + class IOSSwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = ObjCClass("UISwitch").alloc().init() + self._apply(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setOn_animated_(bool(props["value"]), False) + + # ---- ProgressBar (UIProgressView) ----------------------------------- + class IOSProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pv = ObjCClass("UIProgressView").alloc().init() + if "value" in props: + pv.setProgress_(float(props["value"])) + return pv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "value" in changed: + native_view.setProgress_(float(changed["value"])) + + # ---- ActivityIndicator ---------------------------------------------- + class IOSActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ai = ObjCClass("UIActivityIndicatorView").alloc().init() + if props.get("animating", True): + ai.startAnimating() + return ai + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "animating" in changed: + if changed["animating"]: + native_view.startAnimating() + else: + native_view.stopAnimating() + + # ---- WebView (WKWebView) -------------------------------------------- + class IOSWebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = ObjCClass("WKWebView").alloc().init() + if "url" in props and props["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(props["url"])) + wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(changed["url"])) + native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + + # ---- Spacer --------------------------------------------------------- + class IOSSpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "size" in props and props["size"] is not None: + size = float(props["size"]) + v.setFrame_(((0, 0), (size, size))) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + size = float(changed["size"]) + native_view.setFrame_(((0, 0), (size, size))) + + registry.register("Text", IOSTextHandler()) + registry.register("Button", IOSButtonHandler()) + registry.register("Column", IOSColumnHandler()) + registry.register("Row", IOSRowHandler()) + registry.register("ScrollView", IOSScrollViewHandler()) + registry.register("TextInput", IOSTextInputHandler()) + registry.register("Image", IOSImageHandler()) + registry.register("Switch", IOSSwitchHandler()) + registry.register("ProgressBar", IOSProgressBarHandler()) + registry.register("ActivityIndicator", IOSActivityIndicatorHandler()) + registry.register("WebView", IOSWebViewHandler()) + registry.register("Spacer", IOSSpacerHandler()) + + +# ====================================================================== +# Factory +# ====================================================================== + +_registry: Optional[NativeViewRegistry] = None + + +def get_registry() -> NativeViewRegistry: + """Return the singleton registry, lazily creating platform handlers.""" + global _registry + if _registry is not None: + return _registry + _registry = NativeViewRegistry() + if IS_ANDROID: + _register_android_handlers(_registry) + else: + _register_ios_handlers(_registry) + return _registry + + +def set_registry(registry: NativeViewRegistry) -> None: + """Inject a custom or mock registry (primarily for testing).""" + global _registry + _registry = registry diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index 77e283c..421bed4 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -1,32 +1,29 @@ -""" -Your current approach, which involves creating an Android Activity in Kotlin -and then passing it to Python, is necessary due to the restrictions inherent -in Android's lifecycle. You are correctly following the Android way of managing -Activities. In Android, the system is in control of when and how Activities are -created and destroyed. It is not possible to directly create an instance of an -Activity from Python because that would bypass Android's lifecycle management, -leading to unpredictable results. - -Your Button example works because Button is a View, not an Activity. View -instances in Android can be created and managed directly by your code. This is -why you are able to create an instance of Button from Python. - -Remember that Activities in Android are not just containers for your UI like a -ViewGroup, they are also the main entry points into your app and are closely -tied to the app's lifecycle. Therefore, Android needs to maintain tight control -over them. Activities aren't something you instantiate whenever you need them; -they are created in response to a specific intent and their lifecycle is -managed by Android. - -So, to answer your question: Yes, you need to follow this approach for -Activities in Android. You cannot instantiate an Activity from Python like you -do for Views. - -On the other hand, for iOS, you can instantiate a UIViewController directly -from Python. The example code you provided for this is correct. - -Just ensure that your PythonNative UI framework is aware of these platform -differences and handles them appropriately. +"""Page — the root component that bridges native lifecycle and declarative UI. + +A ``Page`` subclass is the entry point for each screen. It owns a +:class:`~pythonnative.reconciler.Reconciler` and automatically mounts / +re-renders the element tree returned by :meth:`render` whenever state +changes. + +Usage:: + + import pythonnative as pn + + class MainPage(pn.Page): + def __init__(self, native_instance): + super().__init__(native_instance) + self.state = {"count": 0} + + def increment(self): + self.set_state(count=self.state["count"] + 1) + + def render(self): + return pn.Column( + pn.Text(f"Count: {self.state['count']}", font_size=24), + pn.Button("Increment", on_click=self.increment), + spacing=12, + padding=16, + ) """ import json @@ -34,55 +31,50 @@ from typing import Any, Optional, Union from .utils import IS_ANDROID, set_android_context -from .view import ViewBase -# ======================================== -# Base class -# ======================================== +# ====================================================================== +# Base class (platform-independent) +# ====================================================================== class PageBase(ABC): + """Abstract base defining the Page interface.""" + @abstractmethod def __init__(self) -> None: super().__init__() @abstractmethod - def set_root_view(self, view: Any) -> None: - pass + def render(self) -> Any: + """Return an Element tree describing this page's UI.""" + + def set_state(self, **updates: Any) -> None: + """Merge *updates* into ``self.state`` and trigger a re-render.""" - @abstractmethod def on_create(self) -> None: - pass + """Called when the page is first created. Triggers initial render.""" - @abstractmethod def on_start(self) -> None: pass - @abstractmethod def on_resume(self) -> None: pass - @abstractmethod def on_pause(self) -> None: pass - @abstractmethod def on_stop(self) -> None: pass - @abstractmethod def on_destroy(self) -> None: pass - @abstractmethod def on_restart(self) -> None: pass - @abstractmethod def on_save_instance_state(self) -> None: pass - @abstractmethod def on_restore_instance_state(self) -> None: pass @@ -99,141 +91,182 @@ def pop(self) -> None: pass def get_args(self) -> dict: - """Return arguments provided to this Page (empty dict if none).""" - # Concrete classes should set self._args; default empty + """Return navigation arguments (empty dict if none).""" return getattr(self, "_args", {}) - # Back-compat: navigate_to delegates to push def navigate_to(self, page: Any) -> None: self.push(page) - pass -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/app/Activity - # ======================================== +# ====================================================================== +# Shared declarative rendering helpers +# ====================================================================== + + +def _init_page_common(page: Any) -> None: + """Common initialisation shared by both platform Page classes.""" + page.state = {} + page._args = {} + page._reconciler = None + page._root_native_view = None + + +def _set_state(page: Any, **updates: Any) -> None: + page.state.update(updates) + if page._reconciler is not None: + _re_render(page) + + +def _on_create(page: Any) -> None: + from .native_views import get_registry + from .reconciler import Reconciler + + page._reconciler = Reconciler(get_registry()) + element = page.render() + page._root_native_view = page._reconciler.mount(element) + page._attach_root(page._root_native_view) + + +def _re_render(page: Any) -> None: + element = page.render() + new_root = page._reconciler.reconcile(element) + if new_root is not page._root_native_view: + page._detach_root(page._root_native_view) + page._root_native_view = new_root + page._attach_root(new_root) + +def _resolve_page_path(page_ref: Union[str, Any]) -> str: + if isinstance(page_ref, str): + return page_ref + module = getattr(page_ref, "__module__", None) + name = getattr(page_ref, "__name__", None) + if module and name: + return f"{module}.{name}" + cls = page_ref.__class__ + return f"{cls.__module__}.{cls.__name__}" + + +def _set_args(page: Any, args: Optional[dict]) -> None: + if isinstance(args, str): + try: + page._args = json.loads(args) or {} + except Exception: + page._args = {} + return + page._args = args or {} + + +# ====================================================================== +# Platform implementations +# ====================================================================== + +if IS_ANDROID: from java import jclass - class Page(PageBase, ViewBase): + class Page(PageBase): + """Android Page backed by an Activity and Fragment navigation.""" + def __init__(self, native_instance: Any) -> None: super().__init__() self.native_class = jclass("android.app.Activity") self.native_instance = native_instance - # self.native_instance = self.native_class() - # Stash the Activity so child views can implicitly acquire a Context set_android_context(native_instance) - self._args: dict = {} + _init_page_common(self) - def set_root_view(self, view: Any) -> None: - # In fragment-based navigation, attach child view to the current fragment container. - try: - from .utils import get_android_fragment_container + def render(self) -> Any: + raise NotImplementedError("Page subclass must implement render()") - container = get_android_fragment_container() - # Remove previous children if any, then add the new root - try: - container.removeAllViews() - except Exception: - pass - container.addView(view.native_instance) - except Exception: - # Fallback to setting content view directly on the Activity - self.native_instance.setContentView(view.native_instance) + def set_state(self, **updates: Any) -> None: + _set_state(self, **updates) def on_create(self) -> None: - print("Android on_create() called") + _on_create(self) def on_start(self) -> None: - print("Android on_start() called") + pass def on_resume(self) -> None: - print("Android on_resume() called") + pass def on_pause(self) -> None: - print("Android on_pause() called") + pass def on_stop(self) -> None: - print("Android on_stop() called") + pass def on_destroy(self) -> None: - print("Android on_destroy() called") + pass def on_restart(self) -> None: - print("Android on_restart() called") + pass def on_save_instance_state(self) -> None: - print("Android on_save_instance_state() called") + pass def on_restore_instance_state(self) -> None: - print("Android on_restore_instance_state() called") + pass def set_args(self, args: Optional[dict]) -> None: - # Accept dict or JSON string for convenience when crossing language boundaries - if isinstance(args, str): - try: - self._args = json.loads(args) or {} - return - except Exception: - self._args = {} - return - self._args = args or {} - - def _resolve_page_path(self, page: Union[str, Any]) -> str: - if isinstance(page, str): - return page - # If a class or instance is passed, derive dotted path - try: - module = getattr(page, "__module__", None) - name = getattr(page, "__name__", None) - if module and name: - return f"{module}.{name}" - # Instance: use its class - cls = page.__class__ - return f"{cls.__module__}.{cls.__name__}" - except Exception: - raise ValueError("Unsupported page reference; expected dotted string or class/instance") + _set_args(self, args) def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - # Delegate to Navigator.push to navigate to PageFragment with arguments - page_path = self._resolve_page_path(page) - try: - Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") - args_json = json.dumps(args) if args else None - Navigator.push(self.native_instance, page_path, args_json) - except Exception: - # As a last resort, do nothing rather than crash - pass + page_path = _resolve_page_path(page) + Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") + args_json = json.dumps(args) if args else None + Navigator.push(self.native_instance, page_path, args_json) def pop(self) -> None: - # Delegate to Navigator.pop for back-stack pop try: Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") Navigator.pop(self.native_instance) except Exception: + self.native_instance.finish() + + def _attach_root(self, native_view: Any) -> None: + try: + from .utils import get_android_fragment_container + + container = get_android_fragment_container() try: - self.native_instance.finish() + container.removeAllViews() except Exception: pass + LayoutParams = jclass("android.view.ViewGroup$LayoutParams") + lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + container.addView(native_view, lp) + except Exception: + self.native_instance.setContentView(native_view) + + def _detach_root(self, native_view: Any) -> None: + try: + from .utils import get_android_fragment_container + + container = get_android_fragment_container() + container.removeAllViews() + except Exception: + pass else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiviewcontroller - # ======================================== + from typing import Dict as _Dict + + _rubicon_available = False + try: + from rubicon.objc import ObjCClass, ObjCInstance - from typing import Dict + _rubicon_available = True - from rubicon.objc import ObjCClass, ObjCInstance + import gc as _gc - # Global registry mapping native UIViewController pointer address to Page instances. - _IOS_PAGE_REGISTRY: Dict[int, Any] = {} + _gc.disable() + except ImportError: + pass + + _IOS_PAGE_REGISTRY: _Dict[int, Any] = {} def _ios_register_page(vc_instance: Any, page_obj: Any) -> None: try: - ptr = int(vc_instance.ptr) # rubicon ObjCInstance -> c_void_p convertible to int + ptr = int(vc_instance.ptr) _IOS_PAGE_REGISTRY[ptr] = page_obj except Exception: pass @@ -246,151 +279,190 @@ def _ios_unregister_page(vc_instance: Any) -> None: pass def forward_lifecycle(native_addr: int, event: str) -> None: - """Forward a lifecycle event from Swift ViewController to the registered Page. - - :param native_addr: Integer pointer address of the UIViewController - :param event: One of 'on_start', 'on_resume', 'on_pause', 'on_stop', 'on_destroy', - 'on_save_instance_state', 'on_restore_instance_state'. - """ + """Forward a lifecycle event from Swift ViewController to the registered Page.""" page = _IOS_PAGE_REGISTRY.get(int(native_addr)) - if not page: + if page is None: return - try: - handler = getattr(page, event, None) - if handler: - handler() - except Exception: - # Avoid surfacing exceptions across the Swift/Python boundary in lifecycle - pass + handler = getattr(page, event, None) + if handler: + handler() - class Page(PageBase, ViewBase): - def __init__(self, native_instance: Any) -> None: - super().__init__() - self.native_class = ObjCClass("UIViewController") - # If Swift passed us an integer pointer, wrap it as an ObjCInstance. - if isinstance(native_instance, int): - try: - native_instance = ObjCInstance(native_instance) - except Exception: - native_instance = None - self.native_instance = native_instance - # self.native_instance = self.native_class.alloc().init() - self._args: dict = {} - # Register for lifecycle forwarding - if self.native_instance is not None: - _ios_register_page(self.native_instance, self) - - def set_root_view(self, view: Any) -> None: - # UIViewController.view is a property; access without calling. - root_view = self.native_instance.view - # Size the root child to fill the controller's view and enable autoresizing - try: - bounds = root_view.bounds - view.native_instance.setFrame_(bounds) - # UIViewAutoresizingFlexibleWidth (2) | UIViewAutoresizingFlexibleHeight (16) - view.native_instance.setAutoresizingMask_(2 | 16) - except Exception: - pass - root_view.addSubview_(view.native_instance) + if _rubicon_available: - def on_create(self) -> None: - print("iOS on_create() called") + class Page(PageBase): + """iOS Page backed by a UIViewController.""" - def on_start(self) -> None: - print("iOS on_start() called") + def __init__(self, native_instance: Any) -> None: + super().__init__() + self.native_class = ObjCClass("UIViewController") + if isinstance(native_instance, int): + try: + native_instance = ObjCInstance(native_instance) + except Exception: + native_instance = None + self.native_instance = native_instance + _init_page_common(self) + if self.native_instance is not None: + _ios_register_page(self.native_instance, self) - def on_resume(self) -> None: - print("iOS on_resume() called") + def render(self) -> Any: + raise NotImplementedError("Page subclass must implement render()") - def on_pause(self) -> None: - print("iOS on_pause() called") + def set_state(self, **updates: Any) -> None: + _set_state(self, **updates) - def on_stop(self) -> None: - print("iOS on_stop() called") + def on_create(self) -> None: + _on_create(self) - def on_destroy(self) -> None: - print("iOS on_destroy() called") - if self.native_instance is not None: - _ios_unregister_page(self.native_instance) + def on_start(self) -> None: + pass - def on_restart(self) -> None: - print("iOS on_restart() called") + def on_resume(self) -> None: + pass - def on_save_instance_state(self) -> None: - print("iOS on_save_instance_state() called") + def on_pause(self) -> None: + pass - def on_restore_instance_state(self) -> None: - print("iOS on_restore_instance_state() called") + def on_stop(self) -> None: + pass - def set_args(self, args: Optional[dict]) -> None: - if isinstance(args, str): - try: - self._args = json.loads(args) or {} - return - except Exception: - self._args = {} - return - self._args = args or {} + def on_destroy(self) -> None: + if self.native_instance is not None: + _ios_unregister_page(self.native_instance) - def _resolve_page_path(self, page: Union[str, Any]) -> str: - if isinstance(page, str): - return page - try: - module = getattr(page, "__module__", None) - name = getattr(page, "__name__", None) - if module and name: - return f"{module}.{name}" - cls = page.__class__ - return f"{cls.__module__}.{cls.__name__}" - except Exception: - raise ValueError("Unsupported page reference; expected dotted string or class/instance") + def on_restart(self) -> None: + pass - def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: - page_path = self._resolve_page_path(page) - # Resolve the Swift ViewController class. Swift classes are namespaced by - # the module name (CFBundleName). Try plain name first, then Module.Name. - ViewController = None - try: - ViewController = ObjCClass("ViewController") - except Exception: + def on_save_instance_state(self) -> None: + pass + + def on_restore_instance_state(self) -> None: + pass + + def set_args(self, args: Optional[dict]) -> None: + _set_args(self, args) + + def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: + page_path = _resolve_page_path(page) + ViewController = None try: - NSBundle = ObjCClass("NSBundle") - bundle = NSBundle.mainBundle - module_name = None + ViewController = ObjCClass("ViewController") + except Exception: try: - # Prefer CFBundleName; fallback to CFBundleExecutable + NSBundle = ObjCClass("NSBundle") + bundle = NSBundle.mainBundle module_name = bundle.objectForInfoDictionaryKey_("CFBundleName") if module_name is None: module_name = bundle.objectForInfoDictionaryKey_("CFBundleExecutable") + if module_name: + ViewController = ObjCClass(f"{module_name}.ViewController") except Exception: - module_name = None - if module_name: - ViewController = ObjCClass(f"{module_name}.ViewController") + pass + + if ViewController is None: + raise NameError("ViewController class not found; ensure Swift class is ObjC-visible") + + next_vc = ViewController.alloc().init() + try: + next_vc.setValue_forKey_(page_path, "requestedPagePath") + if args: + next_vc.setValue_forKey_(json.dumps(args), "requestedPageArgsJSON") + except Exception: + pass + nav = getattr(self.native_instance, "navigationController", None) + if nav is None: + raise RuntimeError( + "No UINavigationController available; ensure template embeds root in navigation controller" + ) + nav.pushViewController_animated_(next_vc, True) + + def pop(self) -> None: + nav = getattr(self.native_instance, "navigationController", None) + if nav is not None: + nav.popViewControllerAnimated_(True) + + def _attach_root(self, native_view: Any) -> None: + root_view = self.native_instance.view + native_view.setTranslatesAutoresizingMaskIntoConstraints_(False) + root_view.addSubview_(native_view) + try: + safe = root_view.safeAreaLayoutGuide + native_view.topAnchor.constraintEqualToAnchor_(safe.topAnchor).setActive_(True) + native_view.bottomAnchor.constraintEqualToAnchor_(safe.bottomAnchor).setActive_(True) + native_view.leadingAnchor.constraintEqualToAnchor_(safe.leadingAnchor).setActive_(True) + native_view.trailingAnchor.constraintEqualToAnchor_(safe.trailingAnchor).setActive_(True) except Exception: - ViewController = None + native_view.setTranslatesAutoresizingMaskIntoConstraints_(True) + try: + native_view.setFrame_(root_view.bounds) + native_view.setAutoresizingMask_(2 | 16) + except Exception: + pass - if ViewController is None: - raise NameError("ViewController class not found; ensure Swift class is ObjC-visible") + def _detach_root(self, native_view: Any) -> None: + try: + native_view.removeFromSuperview() + except Exception: + pass - next_vc = ViewController.alloc().init() - try: - # Use KVC to pass metadata to Swift - next_vc.setValue_forKey_(page_path, "requestedPagePath") - if args: - next_vc.setValue_forKey_(json.dumps(args), "requestedPageArgsJSON") - except Exception: + else: + + class Page(PageBase): + """Desktop stub — no native runtime available. + + Fully functional for testing with a mock backend via + ``native_views.set_registry()``. + """ + + def __init__(self, native_instance: Any = None) -> None: + super().__init__() + self.native_instance = native_instance + _init_page_common(self) + + def render(self) -> Any: + raise NotImplementedError("Page subclass must implement render()") + + def set_state(self, **updates: Any) -> None: + _set_state(self, **updates) + + def on_create(self) -> None: + _on_create(self) + + def on_start(self) -> None: pass - # On iOS, `navigationController` is exposed as a property; treat it as such. - nav = getattr(self.native_instance, "navigationController", None) - if nav is None: - # If no navigation controller, this push will be a no-op; rely on template to embed one. - raise RuntimeError( - "No UINavigationController available; ensure template embeds root in navigation controller" - ) - # Method name maps from pushViewController:animated: - nav.pushViewController_animated_(next_vc, True) - def pop(self) -> None: - nav = getattr(self.native_instance, "navigationController", None) - if nav is not None: - nav.popViewControllerAnimated_(True) + def on_resume(self) -> None: + pass + + def on_pause(self) -> None: + pass + + def on_stop(self) -> None: + pass + + def on_destroy(self) -> None: + pass + + def on_restart(self) -> None: + pass + + def on_save_instance_state(self) -> None: + pass + + def on_restore_instance_state(self) -> None: + pass + + def set_args(self, args: Optional[dict]) -> None: + _set_args(self, args) + + def push(self, page: Union[str, Any], args: Optional[dict] = None) -> None: + raise RuntimeError("push() requires a native runtime (iOS or Android)") + + def pop(self) -> None: + raise RuntimeError("pop() requires a native runtime (iOS or Android)") + + def _attach_root(self, native_view: Any) -> None: + pass + + def _detach_root(self, native_view: Any) -> None: + pass diff --git a/src/pythonnative/picker_view.py b/src/pythonnative/picker_view.py deleted file mode 100644 index 7f1ae15..0000000 --- a/src/pythonnative/picker_view.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class PickerViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_selected(self, index: int) -> "PickerViewBase": - pass - - @abstractmethod - def get_selected(self) -> int: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Spinner - # ======================================== - - from typing import Any - - from java import jclass - - class PickerView(PickerViewBase, ViewBase): - def __init__(self, context: Any, index: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.Spinner") - self.native_instance = self.native_class(context) - self.set_selected(index) - - def set_selected(self, index: int) -> "PickerView": - self.native_instance.setSelection(index) - return self - - def get_selected(self) -> int: - return self.native_instance.getSelectedItemPosition() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uipickerview - # ======================================== - - from rubicon.objc import ObjCClass - - class PickerView(PickerViewBase, ViewBase): - def __init__(self, index: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIPickerView") - self.native_instance = self.native_class.alloc().init() - self.set_selected(index) - - def set_selected(self, index: int) -> "PickerView": - self.native_instance.selectRow_inComponent_animated_(index, 0, False) - return self - - def get_selected(self) -> int: - return self.native_instance.selectedRowInComponent_(0) diff --git a/src/pythonnative/progress_view.py b/src/pythonnative/progress_view.py deleted file mode 100644 index 5587170..0000000 --- a/src/pythonnative/progress_view.py +++ /dev/null @@ -1,70 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ProgressViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_progress(self, progress: float) -> "ProgressViewBase": - pass - - @abstractmethod - def get_progress(self) -> float: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ProgressBar - # ======================================== - - from java import jclass - - class ProgressView(ProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ProgressBar") - # self.native_instance = self.native_class(context, None, android.R.attr.progressBarStyleHorizontal) - context = get_android_context() - self.native_instance = self.native_class(context, None, jclass("android.R$attr").progressBarStyleHorizontal) - self.native_instance.setIndeterminate(False) - - def set_progress(self, progress: float) -> "ProgressView": - self.native_instance.setProgress(int(progress * 100)) - return self - - def get_progress(self) -> float: - return self.native_instance.getProgress() / 100.0 - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiprogressview - # ======================================== - - from rubicon.objc import ObjCClass - - class ProgressView(ProgressViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIProgressView") - self.native_instance = self.native_class.alloc().initWithProgressViewStyle_( - 0 - ) # 0: UIProgressViewStyleDefault - - def set_progress(self, progress: float) -> "ProgressView": - self.native_instance.setProgress_animated_(progress, False) - return self - - def get_progress(self) -> float: - return self.native_instance.progress() diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py new file mode 100644 index 0000000..b7aa5bc --- /dev/null +++ b/src/pythonnative/reconciler.py @@ -0,0 +1,129 @@ +"""Virtual-tree reconciler. + +Maintains a tree of :class:`VNode` objects (each wrapping a native view) +and diffs incoming :class:`Element` trees to apply the minimal set of +native mutations. +""" + +from typing import Any, List, Optional + +from .element import Element + + +class VNode: + """A mounted element paired with its native view and child VNodes.""" + + __slots__ = ("element", "native_view", "children") + + def __init__(self, element: Element, native_view: Any, children: List["VNode"]) -> None: + self.element = element + self.native_view = native_view + self.children = children + + +class Reconciler: + """Create, diff, and patch native view trees from Element descriptors. + + Parameters + ---------- + backend: + An object implementing the :class:`NativeViewRegistry` protocol + (``create_view``, ``update_view``, ``add_child``, ``remove_child``, + ``insert_child``). + """ + + def __init__(self, backend: Any) -> None: + self.backend = backend + self._tree: Optional[VNode] = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def mount(self, element: Element) -> Any: + """Build native views from *element* and return the root native view.""" + self._tree = self._create_tree(element) + return self._tree.native_view + + def reconcile(self, new_element: Element) -> Any: + """Diff *new_element* against the current tree and patch native views. + + Returns the (possibly replaced) root native view. + """ + if self._tree is None: + self._tree = self._create_tree(new_element) + return self._tree.native_view + + self._tree = self._reconcile_node(self._tree, new_element) + return self._tree.native_view + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _create_tree(self, element: Element) -> VNode: + native_view = self.backend.create_view(element.type, element.props) + children: List[VNode] = [] + for child_el in element.children: + child_node = self._create_tree(child_el) + self.backend.add_child(native_view, child_node.native_view, element.type) + children.append(child_node) + return VNode(element, native_view, children) + + def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: + if old.element.type != new_el.type: + new_node = self._create_tree(new_el) + self._destroy_tree(old) + return new_node + + changed = self._diff_props(old.element.props, new_el.props) + if changed: + self.backend.update_view(old.native_view, old.element.type, changed) + + self._reconcile_children(old, new_el.children) + old.element = new_el + return old + + def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None: + old_children = parent.children + new_child_nodes: List[VNode] = [] + max_len = max(len(old_children), len(new_children)) + + for i in range(max_len): + if i >= len(new_children): + self.backend.remove_child(parent.native_view, old_children[i].native_view, parent.element.type) + self._destroy_tree(old_children[i]) + elif i >= len(old_children): + node = self._create_tree(new_children[i]) + self.backend.add_child(parent.native_view, node.native_view, parent.element.type) + new_child_nodes.append(node) + else: + if old_children[i].element.type != new_children[i].type: + self.backend.remove_child(parent.native_view, old_children[i].native_view, parent.element.type) + self._destroy_tree(old_children[i]) + node = self._create_tree(new_children[i]) + self.backend.insert_child(parent.native_view, node.native_view, parent.element.type, i) + new_child_nodes.append(node) + else: + updated = self._reconcile_node(old_children[i], new_children[i]) + new_child_nodes.append(updated) + + parent.children = new_child_nodes + + def _destroy_tree(self, node: VNode) -> None: + for child in node.children: + self._destroy_tree(child) + node.children = [] + + @staticmethod + def _diff_props(old: dict, new: dict) -> dict: + """Return props that changed (callables always count as changed).""" + changed = {} + for key, new_val in new.items(): + old_val = old.get(key) + if callable(new_val) or old_val != new_val: + changed[key] = new_val + for key in old: + if key not in new: + changed[key] = None + return changed diff --git a/src/pythonnative/scroll_view.py b/src/pythonnative/scroll_view.py deleted file mode 100644 index b532cfe..0000000 --- a/src/pythonnative/scroll_view.py +++ /dev/null @@ -1,101 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, List - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class ScrollViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - self.views: List[Any] = [] - - @abstractmethod - def add_view(self, view: Any) -> None: - pass - - @staticmethod - @abstractmethod - def wrap(view: Any) -> "ScrollViewBase": - """Return a new ScrollView containing the provided view as its only child.""" - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/ScrollView - # ======================================== - - from java import jclass - - class ScrollView(ScrollViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.ScrollView") - context = get_android_context() - self.native_instance = self.native_class(context) - - def add_view(self, view: Any) -> None: - self.views.append(view) - # In Android, ScrollView can host only one direct child - if len(self.views) == 1: - self.native_instance.addView(view.native_instance) - else: - raise Exception("ScrollView can host only one direct child") - - @staticmethod - def wrap(view: Any) -> "ScrollView": - """Return a new ScrollView containing the provided view as its only child.""" - sv = ScrollView() - sv.add_view(view) - return sv - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiscrollview - # ======================================== - - from rubicon.objc import ObjCClass - - class ScrollView(ScrollViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIScrollView") - self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - - def add_view(self, view: Any) -> None: - self.views.append(view) - # Add as subview and size child to fill scroll view by default so content is visible - try: - self.native_instance.addSubview_(view.native_instance) - except Exception: - pass - # Default layout: if the child has no size yet, size it to fill the scroll view - # and enable flexible width/height. If the child is already sized explicitly, - # leave it unchanged. - try: - frame = getattr(view.native_instance, "frame") - size = getattr(frame, "size", None) - width = getattr(size, "width", 0) if size is not None else 0 - height = getattr(size, "height", 0) if size is not None else 0 - if width <= 0 or height <= 0: - bounds = self.native_instance.bounds - view.native_instance.setFrame_(bounds) - # UIViewAutoresizingFlexibleWidth (2) | UIViewAutoresizingFlexibleHeight (16) - view.native_instance.setAutoresizingMask_(2 | 16) - except Exception: - pass - - @staticmethod - def wrap(view: Any) -> "ScrollView": - """Return a new ScrollView containing the provided view as its only child.""" - sv = ScrollView() - sv.add_view(view) - return sv diff --git a/src/pythonnative/search_bar.py b/src/pythonnative/search_bar.py deleted file mode 100644 index ae33da1..0000000 --- a/src/pythonnative/search_bar.py +++ /dev/null @@ -1,69 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class SearchBarBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_query(self, query: str) -> "SearchBarBase": - pass - - @abstractmethod - def get_query(self) -> str: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/SearchView - # ======================================== - - from typing import Any - - from java import jclass - - class SearchBar(SearchBarBase, ViewBase): - def __init__(self, context: Any, query: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.SearchView") - self.native_instance = self.native_class(context) - self.set_query(query) - - def set_query(self, query: str) -> "SearchBar": - self.native_instance.setQuery(query, False) - return self - - def get_query(self) -> str: - return self.native_instance.getQuery().toString() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uisearchbar - # ======================================== - - from rubicon.objc import ObjCClass - - class SearchBar(SearchBarBase, ViewBase): - def __init__(self, query: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UISearchBar") - self.native_instance = self.native_class.alloc().init() - self.set_query(query) - - def set_query(self, query: str) -> "SearchBar": - self.native_instance.set_text_(query) - return self - - def get_query(self) -> str: - return self.native_instance.text() diff --git a/src/pythonnative/stack_view.py b/src/pythonnative/stack_view.py deleted file mode 100644 index cb2273c..0000000 --- a/src/pythonnative/stack_view.py +++ /dev/null @@ -1,199 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, List - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class StackViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - self.views: List[Any] = [] - - @abstractmethod - def add_view(self, view: Any) -> None: - pass - - @abstractmethod - def set_axis(self, axis: str) -> "StackViewBase": - pass - - @abstractmethod - def set_spacing(self, spacing: float) -> "StackViewBase": - pass - - @abstractmethod - def set_alignment(self, alignment: str) -> "StackViewBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/LinearLayout - # ======================================== - - from java import jclass - - class StackView(StackViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = jclass("android.widget.LinearLayout") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setOrientation(self.native_class.VERTICAL) - # Cache context and current orientation for spacing/alignment helpers - self._context = context - self._axis = "vertical" - - def add_view(self, view: Any) -> None: - self.views.append(view) - # Apply margins if the child has any recorded (supported for LinearLayout) - try: - lp = view.native_instance.getLayoutParams() - except Exception: - lp = None - if lp is None: - # Create default LayoutParams (WRAP_CONTENT) - layout_params = jclass("android.widget.LinearLayout$LayoutParams")(-2, -2) - else: - layout_params = lp - margin = getattr(view, "_pn_margin", None) - if margin is not None: - left, top, right, bottom = margin - # Convert dp to px - density = self._context.getResources().getDisplayMetrics().density - lpx = int(left * density) - tpx = int(top * density) - rpx = int(right * density) - bpx = int(bottom * density) - try: - layout_params.setMargins(lpx, tpx, rpx, bpx) - except Exception: - pass - try: - view.native_instance.setLayoutParams(layout_params) - except Exception: - pass - self.native_instance.addView(view.native_instance) - - def set_axis(self, axis: str) -> "StackView": - """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" - axis_l = (axis or "").lower() - if axis_l not in ("vertical", "horizontal"): - return self - orientation = self.native_class.VERTICAL if axis_l == "vertical" else self.native_class.HORIZONTAL - self.native_instance.setOrientation(orientation) - self._axis = axis_l - return self - - def set_spacing(self, spacing: float) -> "StackView": - """Set spacing between children in dp (Android: uses LinearLayout dividers). Returns self.""" - try: - density = self._context.getResources().getDisplayMetrics().density - px = max(0, int(spacing * density)) - # Use a transparent GradientDrawable with specified size as divider - GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") - drawable = GradientDrawable() - drawable.setColor(0x00000000) - if self._axis == "vertical": - drawable.setSize(1, px) - else: - drawable.setSize(px, 1) - self.native_instance.setShowDividers(self.native_class.SHOW_DIVIDER_MIDDLE) - self.native_instance.setDividerDrawable(drawable) - except Exception: - pass - return self - - def set_alignment(self, alignment: str) -> "StackView": - """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" - try: - Gravity = jclass("android.view.Gravity") - a = (alignment or "").lower() - if self._axis == "vertical": - # Cross-axis is horizontal - if a in ("fill",): - self.native_instance.setGravity(Gravity.FILL_HORIZONTAL) - elif a in ("center", "centre"): - self.native_instance.setGravity(Gravity.CENTER_HORIZONTAL) - elif a in ("leading", "start", "left"): - self.native_instance.setGravity(Gravity.START) - elif a in ("trailing", "end", "right"): - self.native_instance.setGravity(Gravity.END) - else: - # Cross-axis is vertical - if a in ("fill",): - self.native_instance.setGravity(Gravity.FILL_VERTICAL) - elif a in ("center", "centre"): - self.native_instance.setGravity(Gravity.CENTER_VERTICAL) - elif a in ("top",): - self.native_instance.setGravity(Gravity.TOP) - elif a in ("bottom",): - self.native_instance.setGravity(Gravity.BOTTOM) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uistackview - # ======================================== - - from rubicon.objc import ObjCClass - - class StackView(StackViewBase, ViewBase): - def __init__(self) -> None: - super().__init__() - self.native_class = ObjCClass("UIStackView") - self.native_instance = self.native_class.alloc().initWithFrame_(((0, 0), (0, 0))) - # Default to vertical axis - self.native_instance.setAxis_(1) - - def add_view(self, view: Any) -> None: - self.views.append(view) - self.native_instance.addArrangedSubview_(view.native_instance) - - def set_axis(self, axis: str) -> "StackView": - """Set stacking axis: 'vertical' or 'horizontal'. Returns self.""" - axis_l = (axis or "").lower() - value = 1 if axis_l == "vertical" else 0 - try: - self.native_instance.setAxis_(value) - except Exception: - pass - return self - - def set_spacing(self, spacing: float) -> "StackView": - """Set spacing between arranged subviews. Returns self.""" - try: - self.native_instance.setSpacing_(float(spacing)) - except Exception: - pass - return self - - def set_alignment(self, alignment: str) -> "StackView": - """Set cross-axis alignment: 'fill', 'center', 'leading'/'top', 'trailing'/'bottom'. Returns self.""" - a = (alignment or "").lower() - # UIStackViewAlignment: Fill=0, Leading/Top=1, Center=3, Trailing/Bottom=4 - mapping = { - "fill": 0, - "leading": 1, - "top": 1, - "center": 3, - "centre": 3, - "trailing": 4, - "bottom": 4, - } - value = mapping.get(a, 0) - try: - self.native_instance.setAlignment_(value) - except Exception: - pass - return self diff --git a/src/pythonnative/switch.py b/src/pythonnative/switch.py deleted file mode 100644 index 55d95ba..0000000 --- a/src/pythonnative/switch.py +++ /dev/null @@ -1,68 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class SwitchBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_on(self, value: bool) -> "SwitchBase": - pass - - @abstractmethod - def is_on(self) -> bool: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/Switch - # ======================================== - - from java import jclass - - class Switch(SwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = jclass("android.widget.Switch") - context = get_android_context() - self.native_instance = self.native_class(context) - self.set_on(value) - - def set_on(self, value: bool) -> "Switch": - self.native_instance.setChecked(value) - return self - - def is_on(self) -> bool: - return self.native_instance.isChecked() - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uiswitch - # ======================================== - - from rubicon.objc import ObjCClass - - class Switch(SwitchBase, ViewBase): - def __init__(self, value: bool = False) -> None: - super().__init__() - self.native_class = ObjCClass("UISwitch") - self.native_instance = self.native_class.alloc().init() - self.set_on(value) - - def set_on(self, value: bool) -> "Switch": - self.native_instance.setOn_animated_(value, False) - return self - - def is_on(self) -> bool: - return self.native_instance.isOn() diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt index b5d1dab..4c35fcb 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt @@ -65,7 +65,8 @@ class PageFragment : Fragment() { utils.callAttr("set_android_fragment_container", view) // Now that container exists, invoke on_create so Python can attach its root view page?.callAttr("on_create") - } catch (_: Exception) { + } catch (e: Exception) { + Log.e(TAG, "on_create failed", e) } } diff --git a/src/pythonnative/text_field.py b/src/pythonnative/text_field.py deleted file mode 100644 index d6d1de1..0000000 --- a/src/pythonnative/text_field.py +++ /dev/null @@ -1,132 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TextFieldBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> "TextFieldBase": - pass - - @abstractmethod - def get_text(self) -> str: - pass - - @abstractmethod - def set_text_color(self, color: Any) -> "TextFieldBase": - pass - - @abstractmethod - def set_text_size(self, size: float) -> "TextFieldBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/EditText - # ======================================== - - from java import jclass - - class TextField(TextFieldBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.EditText") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setSingleLine(True) - self.set_text(text) - - def set_text(self, text: str) -> "TextField": - self.native_instance.setText(text) - return self - - def get_text(self) -> str: - return self.native_instance.getText().toString() - - def set_text_color(self, color: Any) -> "TextField": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - self.native_instance.setTextColor(color_int) - except Exception: - pass - return self - - def set_text_size(self, size_sp: float) -> "TextField": - try: - self.native_instance.setTextSize(float(size_sp)) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitextfield - # ======================================== - - from rubicon.objc import ObjCClass - - class TextField(TextFieldBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UITextField") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> "TextField": - self.native_instance.setText_(text) - return self - - def get_text(self) -> str: - return self.native_instance.text() - - def set_text_color(self, color: Any) -> "TextField": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - self.native_instance.setTextColor_(color_obj) - except Exception: - pass - return self - - def set_text_size(self, size: float) -> "TextField": - try: - UIFont = ObjCClass("UIFont") - font = UIFont.systemFontOfSize_(float(size)) - self.native_instance.setFont_(font) - except Exception: - pass - return self diff --git a/src/pythonnative/text_view.py b/src/pythonnative/text_view.py deleted file mode 100644 index 1e997bf..0000000 --- a/src/pythonnative/text_view.py +++ /dev/null @@ -1,135 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TextViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_text(self, text: str) -> "TextViewBase": - pass - - @abstractmethod - def get_text(self) -> str: - pass - - @abstractmethod - def set_text_color(self, color: Any) -> "TextViewBase": - pass - - @abstractmethod - def set_text_size(self, size: float) -> "TextViewBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/EditText - # ======================================== - - from java import jclass - - class TextView(TextViewBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = jclass("android.widget.EditText") - context = get_android_context() - self.native_instance = self.native_class(context) - self.native_instance.setLines(3) - self.native_instance.setMaxLines(5) - self.native_instance.setVerticalScrollBarEnabled(True) - # self.native_instance.movementMethod = ScrollingMovementMethod() - self.set_text(text) - - def set_text(self, text: str) -> "TextView": - self.native_instance.setText(text) - return self - - def get_text(self) -> str: - return self.native_instance.getText().toString() - - def set_text_color(self, color: Any) -> "TextView": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - self.native_instance.setTextColor(color_int) - except Exception: - pass - return self - - def set_text_size(self, size_sp: float) -> "TextView": - try: - self.native_instance.setTextSize(float(size_sp)) - except Exception: - pass - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uitextview - # ======================================== - - from rubicon.objc import ObjCClass - - class TextView(TextViewBase, ViewBase): - def __init__(self, text: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("UITextView") - self.native_instance = self.native_class.alloc().init() - self.set_text(text) - - def set_text(self, text: str) -> "TextView": - self.native_instance.setText_(text) - return self - - def get_text(self) -> str: - return self.native_instance.text() - - def set_text_color(self, color: Any) -> "TextView": - if isinstance(color, str): - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - try: - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - self.native_instance.setTextColor_(color_obj) - except Exception: - pass - return self - - def set_text_size(self, size: float) -> "TextView": - try: - UIFont = ObjCClass("UIFont") - font = UIFont.systemFontOfSize_(float(size)) - self.native_instance.setFont_(font) - except Exception: - pass - return self diff --git a/src/pythonnative/time_picker.py b/src/pythonnative/time_picker.py deleted file mode 100644 index d9085b9..0000000 --- a/src/pythonnative/time_picker.py +++ /dev/null @@ -1,77 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class TimePickerBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def set_time(self, hour: int, minute: int) -> "TimePickerBase": - pass - - @abstractmethod - def get_time(self) -> tuple: - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/widget/TimePicker - # ======================================== - - from typing import Any - - from java import jclass - - class TimePicker(TimePickerBase, ViewBase): - def __init__(self, context: Any, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = jclass("android.widget.TimePicker") - self.native_instance = self.native_class(context) - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "TimePicker": - self.native_instance.setHour(hour) - self.native_instance.setMinute(minute) - return self - - def get_time(self) -> tuple: - hour = self.native_instance.getHour() - minute = self.native_instance.getMinute() - return hour, minute - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/uikit/uidatepicker - # ======================================== - - from datetime import time - - from rubicon.objc import ObjCClass - - class TimePicker(TimePickerBase, ViewBase): - def __init__(self, hour: int = 0, minute: int = 0) -> None: - super().__init__() - self.native_class = ObjCClass("UIDatePicker") - self.native_instance = self.native_class.alloc().init() - self.native_instance.setDatePickerMode_(1) # Setting mode to Time - self.set_time(hour, minute) - - def set_time(self, hour: int, minute: int) -> "TimePicker": - t = time(hour, minute) - self.native_instance.setTime_(t) - return self - - def get_time(self) -> tuple: - t = self.native_instance.time() - return t.hour, t.minute diff --git a/src/pythonnative/utils.py b/src/pythonnative/utils.py index 1cbde95..4505a0e 100644 --- a/src/pythonnative/utils.py +++ b/src/pythonnative/utils.py @@ -1,27 +1,29 @@ +"""Platform detection and shared helpers. + +This module is imported early by most other modules, so it avoids +importing platform-specific packages at module level. +""" + import os from typing import Any, Optional -# Platform detection with multiple fallbacks suitable for Chaquopy/Android +# ====================================================================== +# Platform detection +# ====================================================================== + _is_android: Optional[bool] = None def _detect_android() -> bool: - # 1) Direct environment hints commonly present on Android env = os.environ if "ANDROID_BOOTLOGO" in env or "ANDROID_ROOT" in env or "ANDROID_DATA" in env or "ANDROID_ARGUMENT" in env: return True - - # 2) Chaquopy-specific: the builtin 'java' package is available try: - # Import inside try so importing this module doesn't explode off-device - from java import jclass + from java import jclass # noqa: F401 - _ = jclass # silence linter unused return True except Exception: pass - - # 3) Last resort: some Android Python dists set os.name/others, but avoid false positives return False @@ -39,53 +41,43 @@ def _get_is_android() -> bool: IS_ANDROID: bool = _get_is_android() -# Global hooks to access current Android Activity/Context and Fragment container from Python code +# ====================================================================== +# Android context management +# ====================================================================== + _android_context: Any = None _android_fragment_container: Any = None def set_android_context(context: Any) -> None: - """Record the current Android Activity/Context for implicit constructor use. - - On Android, Python UI components require a Context to create native views. - We capture it when a Page is constructed from the host Activity so component - constructors can be platform-consistent and avoid explicit context params. - """ - + """Record the current Android Activity/Context for view construction.""" global _android_context _android_context = context def set_android_fragment_container(container_view: Any) -> None: - """Record the current Fragment root container ViewGroup for rendering pages. - - The current Page's `set_root_view` will attach its native view to this container. - """ + """Record the current Fragment root container ViewGroup.""" global _android_fragment_container _android_fragment_container = container_view def get_android_context() -> Any: - """Return the previously set Android Activity/Context or raise if missing.""" - + """Return the current Android Activity/Context.""" if not IS_ANDROID: raise RuntimeError("get_android_context() called on non-Android platform") if _android_context is None: raise RuntimeError( - "Android context is not set. Ensure Page is initialized from an Activity " "before constructing views." + "Android context not set. Ensure Page is initialized from an Activity before constructing views." ) return _android_context def get_android_fragment_container() -> Any: - """Return the previously set Fragment container ViewGroup or raise if missing. - - This is set by the host `PageFragment` when its view is created. - """ + """Return the current Fragment container ViewGroup.""" if not IS_ANDROID: raise RuntimeError("get_android_fragment_container() called on non-Android platform") if _android_fragment_container is None: raise RuntimeError( - "Android fragment container is not set. Ensure PageFragment has been created before set_root_view." + "Android fragment container not set. Ensure PageFragment has been created before set_root_view." ) return _android_fragment_container diff --git a/src/pythonnative/view.py b/src/pythonnative/view.py deleted file mode 100644 index abef7f6..0000000 --- a/src/pythonnative/view.py +++ /dev/null @@ -1,173 +0,0 @@ -from abc import ABC -from typing import Any, Optional, Tuple - -# ======================================== -# Base class -# ======================================== - - -class ViewBase(ABC): - def __init__(self) -> None: - # Native bridge handles return types dynamically; these attributes are set at runtime. - self.native_instance: Any = None - self.native_class: Any = None - # Record margins for parents that can apply them (Android LinearLayout) - self._pn_margin: Optional[Tuple[int, int, int, int]] = None - - # ======================================== - # Lightweight style helpers - # ======================================== - - def set_background_color(self, color: Any) -> "ViewBase": - """Set background color. Accepts platform color int or CSS-like hex string. Returns self.""" - try: - from .utils import IS_ANDROID - - if isinstance(color, str): - # Support formats: #RRGGBB or #AARRGGBB - c = color.strip() - if c.startswith("#"): - c = c[1:] - if len(c) == 6: - c = "FF" + c - color_int = int(c, 16) - else: - color_int = int(color) - - if IS_ANDROID: - # Android expects ARGB int - self.native_instance.setBackgroundColor(color_int) - else: - # iOS expects a UIColor - from rubicon.objc import ObjCClass - - UIColor = ObjCClass("UIColor") - a = ((color_int >> 24) & 0xFF) / 255.0 - r = ((color_int >> 16) & 0xFF) / 255.0 - g = ((color_int >> 8) & 0xFF) / 255.0 - b = (color_int & 0xFF) / 255.0 - try: - color_obj = UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - except Exception: - color_obj = UIColor.blackColor() - try: - self.native_instance.setBackgroundColor_(color_obj) - except Exception: - try: - # Some UIKit classes expose 'backgroundColor' property - self.native_instance.setBackgroundColor_(color_obj) - except Exception: - pass - except Exception: - pass - return self - - def set_padding( - self, - left: Optional[int] = None, - top: Optional[int] = None, - right: Optional[int] = None, - bottom: Optional[int] = None, - all: Optional[int] = None, - horizontal: Optional[int] = None, - vertical: Optional[int] = None, - ) -> "ViewBase": - """Set padding (dp on Android; best-effort on iOS where supported). Returns self. - - When provided, 'all' applies to all sides; 'horizontal' applies to left and right; - 'vertical' applies to top and bottom; individual overrides take precedence. - """ - try: - from .utils import IS_ANDROID, get_android_context - - left_value = left - top_value = top - right_value = right - bottom_value = bottom - if all is not None: - left_value = top_value = right_value = bottom_value = all - if horizontal is not None: - left_value = horizontal if left_value is None else left_value - right_value = horizontal if right_value is None else right_value - if vertical is not None: - top_value = vertical if top_value is None else top_value - bottom_value = vertical if bottom_value is None else bottom_value - left_value = left_value or 0 - top_value = top_value or 0 - right_value = right_value or 0 - bottom_value = bottom_value or 0 - - if IS_ANDROID: - density = get_android_context().getResources().getDisplayMetrics().density - lpx = int(left_value * density) - tpx = int(top_value * density) - rpx = int(right_value * density) - bpx = int(bottom_value * density) - self.native_instance.setPadding(lpx, tpx, rpx, bpx) - else: - # Best-effort: many UIKit views don't have direct padding; leave to containers (e.g. UIStackView) - # No-op by default. - pass - except Exception: - pass - return self - - def set_margin( - self, - left: Optional[int] = None, - top: Optional[int] = None, - right: Optional[int] = None, - bottom: Optional[int] = None, - all: Optional[int] = None, - horizontal: Optional[int] = None, - vertical: Optional[int] = None, - ) -> "ViewBase": - """Record margins for this view (applied where supported). Returns self. - - Currently applied automatically when added to Android LinearLayout (StackView). - """ - try: - left_value = left - top_value = top - right_value = right - bottom_value = bottom - if all is not None: - left_value = top_value = right_value = bottom_value = all - if horizontal is not None: - left_value = horizontal if left_value is None else left_value - right_value = horizontal if right_value is None else right_value - if vertical is not None: - top_value = vertical if top_value is None else top_value - bottom_value = vertical if bottom_value is None else bottom_value - left_value = int(left_value or 0) - top_value = int(top_value or 0) - right_value = int(right_value or 0) - bottom_value = int(bottom_value or 0) - self._pn_margin = (left_value, top_value, right_value, bottom_value) - except Exception: - pass - return self - - def wrap_in_scroll(self) -> Any: - """Return a ScrollView containing this view as its only child. Returns the ScrollView.""" - try: - # Local import to avoid circulars - from .scroll_view import ScrollView - - sv = ScrollView() - sv.add_view(self) - return sv - except Exception: - return None - - # @abstractmethod - # def add_view(self, view): - # pass - # - # @abstractmethod - # def set_layout(self, layout): - # pass - # - # @abstractmethod - # def show(self): - # pass diff --git a/src/pythonnative/web_view.py b/src/pythonnative/web_view.py deleted file mode 100644 index b72d51b..0000000 --- a/src/pythonnative/web_view.py +++ /dev/null @@ -1,60 +0,0 @@ -from abc import ABC, abstractmethod - -from .utils import IS_ANDROID, get_android_context -from .view import ViewBase - -# ======================================== -# Base class -# ======================================== - - -class WebViewBase(ABC): - @abstractmethod - def __init__(self) -> None: - super().__init__() - - @abstractmethod - def load_url(self, url: str) -> "WebViewBase": - pass - - -if IS_ANDROID: - # ======================================== - # Android class - # https://developer.android.com/reference/android/webkit/WebView - # ======================================== - - from java import jclass - - class WebView(WebViewBase, ViewBase): - def __init__(self, url: str = "") -> None: - super().__init__() - self.native_class = jclass("android.webkit.WebView") - context = get_android_context() - self.native_instance = self.native_class(context) - self.load_url(url) - - def load_url(self, url: str) -> "WebView": - self.native_instance.loadUrl(url) - return self - -else: - # ======================================== - # iOS class - # https://developer.apple.com/documentation/webkit/wkwebview - # ======================================== - - from rubicon.objc import NSURL, NSURLRequest, ObjCClass - - class WebView(WebViewBase, ViewBase): - def __init__(self, url: str = "") -> None: - super().__init__() - self.native_class = ObjCClass("WKWebView") - self.native_instance = self.native_class.alloc().init() - self.load_url(url) - - def load_url(self, url: str) -> "WebView": - ns_url = NSURL.URLWithString_(url) - request = NSURLRequest.requestWithURL_(ns_url) - self.native_instance.loadRequest_(request) - return self diff --git a/tests/test_components.py b/tests/test_components.py new file mode 100644 index 0000000..8ff7393 --- /dev/null +++ b/tests/test_components.py @@ -0,0 +1,194 @@ +"""Unit tests for the built-in element-creating functions.""" + +from pythonnative.components import ( + ActivityIndicator, + Button, + Column, + Image, + ProgressBar, + Row, + ScrollView, + Spacer, + Switch, + Text, + TextInput, + WebView, +) + +# --------------------------------------------------------------------------- +# Text +# --------------------------------------------------------------------------- + + +def test_text_defaults() -> None: + el = Text() + assert el.type == "Text" + assert el.props.get("text", "") == "" + assert el.children == [] + + +def test_text_with_props() -> None: + el = Text("Hello", font_size=18, color="#FF0000", bold=True, text_align="center") + assert el.props["text"] == "Hello" + assert el.props["font_size"] == 18 + assert el.props["color"] == "#FF0000" + assert el.props["bold"] is True + assert el.props["text_align"] == "center" + + +def test_text_none_props_excluded() -> None: + el = Text("Hi") + assert "font_size" not in el.props + assert "color" not in el.props + + +# --------------------------------------------------------------------------- +# Button +# --------------------------------------------------------------------------- + + +def test_button_defaults() -> None: + el = Button() + assert el.type == "Button" + assert el.props["title"] == "" + assert "on_click" not in el.props + + +def test_button_with_callback() -> None: + cb = lambda: None # noqa: E731 + el = Button("Tap", on_click=cb, background_color="#123456") + assert el.props["title"] == "Tap" + assert el.props["on_click"] is cb + assert el.props["background_color"] == "#123456" + + +def test_button_disabled() -> None: + el = Button("Off", enabled=False) + assert el.props["enabled"] is False + + +# --------------------------------------------------------------------------- +# Column / Row +# --------------------------------------------------------------------------- + + +def test_column_with_children() -> None: + el = Column(Text("a"), Text("b"), spacing=10, padding=16, alignment="fill") + assert el.type == "Column" + assert len(el.children) == 2 + assert el.props["spacing"] == 10 + assert el.props["padding"] == 16 + assert el.props["alignment"] == "fill" + + +def test_row_with_children() -> None: + el = Row(Text("x"), Text("y"), spacing=5) + assert el.type == "Row" + assert len(el.children) == 2 + assert el.props["spacing"] == 5 + + +def test_column_no_spacing_omitted() -> None: + el = Column() + assert "spacing" not in el.props + + +# --------------------------------------------------------------------------- +# ScrollView +# --------------------------------------------------------------------------- + + +def test_scrollview_with_child() -> None: + child = Column(Text("a")) + el = ScrollView(child) + assert el.type == "ScrollView" + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_scrollview_empty() -> None: + el = ScrollView() + assert el.children == [] + + +# --------------------------------------------------------------------------- +# TextInput +# --------------------------------------------------------------------------- + + +def test_textinput_defaults() -> None: + el = TextInput() + assert el.type == "TextInput" + assert el.props["value"] == "" + + +def test_textinput_with_props() -> None: + cb = lambda s: None # noqa: E731 + el = TextInput(value="hi", placeholder="type...", on_change=cb, secure=True) + assert el.props["value"] == "hi" + assert el.props["placeholder"] == "type..." + assert el.props["on_change"] is cb + assert el.props["secure"] is True + + +# --------------------------------------------------------------------------- +# Other leaf components +# --------------------------------------------------------------------------- + + +def test_image() -> None: + el = Image("icon.png", width=48, height=48) + assert el.type == "Image" + assert el.props["source"] == "icon.png" + assert el.props["width"] == 48 + + +def test_switch() -> None: + el = Switch(value=True) + assert el.type == "Switch" + assert el.props["value"] is True + + +def test_progress_bar() -> None: + el = ProgressBar(value=0.5) + assert el.type == "ProgressBar" + assert el.props["value"] == 0.5 + + +def test_activity_indicator() -> None: + el = ActivityIndicator(animating=False) + assert el.type == "ActivityIndicator" + assert el.props["animating"] is False + + +def test_webview() -> None: + el = WebView(url="https://example.com") + assert el.type == "WebView" + assert el.props["url"] == "https://example.com" + + +def test_spacer() -> None: + el = Spacer(size=20) + assert el.type == "Spacer" + assert el.props["size"] == 20 + + +def test_spacer_empty() -> None: + el = Spacer() + assert el.type == "Spacer" + assert el.props == {} + + +# --------------------------------------------------------------------------- +# Key support +# --------------------------------------------------------------------------- + + +def test_key_propagation() -> None: + el = Text("keyed", key="k1") + assert el.key == "k1" + + +def test_column_key() -> None: + el = Column(key="col-1") + assert el.key == "col-1" diff --git a/tests/test_element.py b/tests/test_element.py new file mode 100644 index 0000000..bd9e887 --- /dev/null +++ b/tests/test_element.py @@ -0,0 +1,71 @@ +"""Unit tests for Element descriptors.""" + +from pythonnative.element import Element + + +def test_element_creation() -> None: + el = Element("Text", {"text": "hello"}, []) + assert el.type == "Text" + assert el.props == {"text": "hello"} + assert el.children == [] + assert el.key is None + + +def test_element_with_key() -> None: + el = Element("Text", {}, [], key="abc") + assert el.key == "abc" + + +def test_element_with_children() -> None: + child1 = Element("Text", {"text": "a"}, []) + child2 = Element("Text", {"text": "b"}, []) + parent = Element("Column", {}, [child1, child2]) + assert len(parent.children) == 2 + assert parent.children[0].props["text"] == "a" + assert parent.children[1].props["text"] == "b" + + +def test_element_equality() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Text", {"text": "hi"}, []) + assert a == b + + +def test_element_inequality_type() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Button", {"text": "hi"}, []) + assert a != b + + +def test_element_inequality_props() -> None: + a = Element("Text", {"text": "hi"}, []) + b = Element("Text", {"text": "bye"}, []) + assert a != b + + +def test_element_inequality_children() -> None: + child = Element("Text", {}, []) + a = Element("Column", {}, [child]) + b = Element("Column", {}, []) + assert a != b + + +def test_element_not_equal_to_other_types() -> None: + el = Element("Text", {}, []) + assert el != "not an element" + assert el != 42 + + +def test_element_repr() -> None: + el = Element("Button", {"title": "ok"}, []) + r = repr(el) + assert "Button" in r + assert "children=0" in r + + +def test_deeply_nested_equality() -> None: + leaf = Element("Text", {"text": "x"}, []) + mid = Element("Row", {}, [leaf]) + root_a = Element("Column", {}, [mid]) + root_b = Element("Column", {}, [Element("Row", {}, [Element("Text", {"text": "x"}, [])])]) + assert root_a == root_b diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py new file mode 100644 index 0000000..735592c --- /dev/null +++ b/tests/test_reconciler.py @@ -0,0 +1,280 @@ +"""Unit tests for the reconciler using a mock native backend.""" + +from typing import Any, Dict, List + +from pythonnative.element import Element +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend +# ====================================================================== + + +class MockView: + """Simulates a native view for testing.""" + + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + def __repr__(self) -> str: + return f"MockView({self.type_name}#{self.id})" + + +class MockBackend: + """Records operations for assertions.""" + + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, native_view: MockView, type_name: str, changed_props: Dict[str, Any]) -> None: + native_view.props.update(changed_props) + self.ops.append(("update", type_name, native_view.id, tuple(sorted(changed_props.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# Tests: mount +# ====================================================================== + + +def test_mount_single_element() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element("Text", {"text": "hello"}, []) + root = rec.mount(el) + assert isinstance(root, MockView) + assert root.type_name == "Text" + assert root.props["text"] == "hello" + + +def test_mount_nested_elements() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element( + "Column", + {}, + [ + Element("Text", {"text": "a"}, []), + Element("Button", {"title": "b"}, []), + ], + ) + root = rec.mount(el) + assert root.type_name == "Column" + assert len(root.children) == 2 + assert root.children[0].type_name == "Text" + assert root.children[1].type_name == "Button" + + +def test_mount_deeply_nested() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element( + "ScrollView", + {}, + [ + Element( + "Column", + {}, + [ + Element("Text", {"text": "deep"}, []), + ], + ), + ], + ) + root = rec.mount(el) + assert root.children[0].children[0].props["text"] == "deep" + + +# ====================================================================== +# Tests: reconcile (update props) +# ====================================================================== + + +def test_reconcile_updates_props() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "hello"}, []) + rec.mount(el1) + + backend.ops.clear() + el2 = Element("Text", {"text": "world"}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "text" in update_ops[0][3] + + +def test_reconcile_no_change_no_update() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el = Element("Text", {"text": "same"}, []) + rec.mount(el) + + backend.ops.clear() + rec.reconcile(Element("Text", {"text": "same"}, [])) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 0 + + +# ====================================================================== +# Tests: reconcile children (add / remove) +# ====================================================================== + + +def test_reconcile_add_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + root = rec.mount(el1) + assert len(root.children) == 1 + + backend.ops.clear() + el2 = Element( + "Column", + {}, + [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])], + ) + rec.reconcile(el2) + + add_ops = [op for op in backend.ops if op[0] == "add_child"] + assert len(add_ops) == 1 + assert len(root.children) == 2 + + +def test_reconcile_remove_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element( + "Column", + {}, + [Element("Text", {"text": "a"}, []), Element("Text", {"text": "b"}, [])], + ) + root = rec.mount(el1) + assert len(root.children) == 2 + + backend.ops.clear() + el2 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + rec.reconcile(el2) + + remove_ops = [op for op in backend.ops if op[0] == "remove_child"] + assert len(remove_ops) == 1 + assert len(root.children) == 1 + + +def test_reconcile_replace_child_type() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) + root = rec.mount(el1) + + backend.ops.clear() + el2 = Element("Column", {}, [Element("Button", {"title": "b"}, [])]) + rec.reconcile(el2) + + remove_ops = [op for op in backend.ops if op[0] == "remove_child"] + insert_ops = [op for op in backend.ops if op[0] == "insert_child"] + assert len(remove_ops) == 1 + assert len(insert_ops) == 1 + assert root.children[0].type_name == "Button" + + +# ====================================================================== +# Tests: reconcile root type change +# ====================================================================== + + +def test_reconcile_root_type_change() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "a"}, []) + root1 = rec.mount(el1) + + el2 = Element("Button", {"title": "b"}, []) + root2 = rec.reconcile(el2) + assert root2.type_name == "Button" + assert root2 is not root1 + + +# ====================================================================== +# Tests: callback props always counted as changed +# ====================================================================== + + +def test_reconcile_callback_always_updated() -> None: + backend = MockBackend() + rec = Reconciler(backend) + cb1 = lambda: None # noqa: E731 + cb2 = lambda: None # noqa: E731 + el1 = Element("Button", {"title": "x", "on_click": cb1}, []) + rec.mount(el1) + + backend.ops.clear() + el2 = Element("Button", {"title": "x", "on_click": cb2}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "on_click" in update_ops[0][3] + + +# ====================================================================== +# Tests: removed props signalled as None +# ====================================================================== + + +def test_reconcile_removed_prop_becomes_none() -> None: + backend = MockBackend() + rec = Reconciler(backend) + el1 = Element("Text", {"text": "hi", "color": "#FF0000"}, []) + root = rec.mount(el1) + + backend.ops.clear() + el2 = Element("Text", {"text": "hi"}, []) + rec.reconcile(el2) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "color" in update_ops[0][3] + assert root.props.get("color") is None + + +# ====================================================================== +# Tests: complex multi-step reconciliation +# ====================================================================== + + +def test_multiple_reconcile_cycles() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + rec.mount(Element("Column", {}, [Element("Text", {"text": "0"}, [])])) + + for i in range(1, 5): + rec.reconcile(Element("Column", {}, [Element("Text", {"text": str(i)}, [])])) + + assert rec._tree is not None + assert rec._tree.children[0].element.props["text"] == "4" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d346c62..0dad9fc 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,2 +1,32 @@ -def test_pytest_workflow_smoke() -> None: - assert 2 + 2 == 4 +"""Verify the package is importable and exports the public API.""" + +import pythonnative as pn +from pythonnative.element import Element + + +def test_package_version() -> None: + assert pn.__version__ + + +def test_element_class_exported() -> None: + assert pn.Element is Element + + +def test_public_api_names() -> None: + expected = { + "ActivityIndicator", + "Button", + "Column", + "Element", + "Image", + "Page", + "ProgressBar", + "Row", + "ScrollView", + "Spacer", + "Switch", + "Text", + "TextInput", + "WebView", + } + assert expected.issubset(set(pn.__all__)) From cfe247edf99da8ff870e2d4118ef74b2df5521c1 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:39:25 -0700 Subject: [PATCH 14/34] ci(workflows): add Maestro E2E tests for Android and iOS --- .github/workflows/e2e.yml | 78 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 29 ++++++++++++ tests/e2e/android.yaml | 6 +++ tests/e2e/flows/main_page.yaml | 12 +++++ tests/e2e/flows/navigation.yaml | 17 +++++++ tests/e2e/ios.yaml | 6 +++ 6 files changed, 148 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/android.yaml create mode 100644 tests/e2e/flows/main_page.yaml create mode 100644 tests/e2e/flows/navigation.yaml create mode 100644 tests/e2e/ios.yaml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..0f8db75 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,78 @@ +name: E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + e2e-android: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install PythonNative + run: pip install -e . + + - name: Install Maestro + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Build, install, and run E2E tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 31 + arch: x86_64 + script: | + export PATH="$HOME/.maestro/bin:$PATH" + cd examples/hello-world + pn run android + sleep 5 + cd ../.. + maestro test tests/e2e/android.yaml + + e2e-ios: + runs-on: macos-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install PythonNative + run: pip install -e . + + - name: Install Maestro and idb + run: | + curl -Ls "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH + brew tap facebook/fb && brew install idb-companion + + - name: Build and launch iOS app + working-directory: examples/hello-world + run: pn run ios + + - name: Run E2E tests + run: maestro --platform ios test tests/e2e/ios.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe5b6ab..78e1a10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -256,9 +256,38 @@ release/v0.2.0 hotfix/cli-regression ``` +### E2E tests (Maestro) + +End-to-end tests use [Maestro](https://maestro.dev/) to drive the hello-world example on real emulators and simulators. + +```bash +# Install Maestro (one-time) +curl -Ls "https://get.maestro.mobile.dev" | bash + +# For iOS, also install idb-companion +brew tap facebook/fb && brew install idb-companion +``` + +Build and launch the app first, then run the tests: + +```bash +cd examples/hello-world + +# Android (emulator must be running) +pn run android +maestro test ../../tests/e2e/android.yaml + +# iOS (simulator must be running; --platform ios needed when an Android emulator is also connected) +pn run ios +maestro --platform ios test ../../tests/e2e/ios.yaml +``` + +Test flows live in `tests/e2e/flows/` and cover main page rendering, counter interaction, and multi-page navigation. The `e2e.yml` workflow runs these automatically on pushes to `main` and PRs. + ### CI - **CI** (`ci.yml`): runs formatter, linter, type checker, and tests on every push and PR. +- **E2E** (`e2e.yml`): builds the hello-world example on Android (Linux emulator) and iOS (macOS simulator), then runs Maestro flows. Triggers on pushes to `main`, PRs, and manual dispatch. - **PR Lint** (`pr-lint.yml`): validates the PR title against Conventional Commits format (protects squash merges) and checks individual commit messages via commitlint (protects rebase merges). Recommended: add the **PR title** job as a required status check in branch-protection settings. - **Release** (`release.yml`): runs on merge to `main`; computes version, generates changelog, tags, creates GitHub Release, and (when `DRAFT_RELEASE` is `"false"`) publishes to PyPI. - **Docs** (`docs.yml`): deploys documentation to GitHub Pages on push to `main`. diff --git a/tests/e2e/android.yaml b/tests/e2e/android.yaml new file mode 100644 index 0000000..5b9a6ff --- /dev/null +++ b/tests/e2e/android.yaml @@ -0,0 +1,6 @@ +appId: com.pythonnative.android_template +env: + APP_ID: com.pythonnative.android_template +--- +- runFlow: flows/main_page.yaml +- runFlow: flows/navigation.yaml diff --git a/tests/e2e/flows/main_page.yaml b/tests/e2e/flows/main_page.yaml new file mode 100644 index 0000000..43c184d --- /dev/null +++ b/tests/e2e/flows/main_page.yaml @@ -0,0 +1,12 @@ +appId: ${APP_ID} +--- +# Verify main page renders correctly and the counter works. +- launchApp +- assertVisible: "Hello from PythonNative Demo!" +- assertVisible: "Tapped 0 times" +- assertVisible: "Tap me" +- assertVisible: "Go to Second Page" +- tapOn: "Tap me" +- assertVisible: "Tapped 1 times" +- tapOn: "Tap me" +- assertVisible: "Tapped 2 times" diff --git a/tests/e2e/flows/navigation.yaml b/tests/e2e/flows/navigation.yaml new file mode 100644 index 0000000..6e1b521 --- /dev/null +++ b/tests/e2e/flows/navigation.yaml @@ -0,0 +1,17 @@ +appId: ${APP_ID} +--- +# Navigate through all three pages and back to main. +- launchApp +- assertVisible: "Hello from PythonNative Demo!" +- tapOn: "Go to Second Page" +- assertVisible: "Greetings from MainPage" +- assertVisible: "Go to Third Page" +- assertVisible: "Back" +- tapOn: "Go to Third Page" +- assertVisible: "Third Page" +- assertVisible: "You navigated two levels deep." +- assertVisible: "Back to Second" +- tapOn: "Back to Second" +- assertVisible: "Greetings from MainPage" +- tapOn: "Back" +- assertVisible: "Hello from PythonNative Demo!" diff --git a/tests/e2e/ios.yaml b/tests/e2e/ios.yaml new file mode 100644 index 0000000..c41eaa8 --- /dev/null +++ b/tests/e2e/ios.yaml @@ -0,0 +1,6 @@ +appId: com.pythonnative.ios-template +env: + APP_ID: com.pythonnative.ios-template +--- +- runFlow: flows/main_page.yaml +- runFlow: flows/navigation.yaml From a65b89b2fc1cdc6335c5bc806bdaabba12d01709 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 31 Mar 2026 05:44:44 +0000 Subject: [PATCH 15/34] chore(release): v0.5.0 --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- src/pythonnative/__init__.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 430eac1..23a714b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # CHANGELOG +## v0.5.0 (2026-03-31) + +### Continuous Integration + +- **workflows**: Add Maestro E2E tests for Android and iOS + ([`cfe247e`](https://github.com/pythonnative/pythonnative/commit/cfe247edf99da8ff870e2d4118ef74b2df5521c1)) + +### Features + +- **core**: Replace imperative widget API with declarative component model and reconciler + ([`b6b7721`](https://github.com/pythonnative/pythonnative/commit/b6b77216305202ea0c5197b29e725e14cbe99b5e)) + + ## v0.4.0 (2026-03-18) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 95432b6..049088c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.4.0" +version = "0.5.0" description = "Cross-platform native UI toolkit for Android and iOS" authors = [ { name = "Owen Carey" } diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index b63c9cf..053a95e 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -17,7 +17,7 @@ def render(self): ) """ -__version__ = "0.4.0" +__version__ = "0.5.0" from .components import ( ActivityIndicator, From 298f884ce3e1c58a17c92484c5832ebae6f1beaa Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:52:06 -0700 Subject: [PATCH 16/34] style(cli): reformat pn.py for Black 2026 stable style --- src/pythonnative/cli/pn.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 22848c4..491381e 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -44,8 +44,7 @@ def init_project(args: argparse.Namespace) -> None: main_page_py = os.path.join(app_dir, "main_page.py") if not os.path.exists(main_page_py) or args.force: with open(main_page_py, "w", encoding="utf-8") as f: - f.write( - """import pythonnative as pn + f.write("""import pythonnative as pn class MainPage(pn.Page): @@ -67,8 +66,7 @@ def render(self): alignment="fill", ) ) -""" - ) +""") # Create config config = { From 01d19683f41a4b00048dfbce687e510bec2e2d31 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:59:52 -0700 Subject: [PATCH 17/34] ci(workflows,cli): fix e2e workflow script chaining and GitHub API auth --- .github/workflows/e2e.yml | 13 +++++++------ src/pythonnative/cli/pn.py | 6 +++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0f8db75..7935a34 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -40,12 +40,11 @@ jobs: with: api-level: 31 arch: x86_64 - script: | - export PATH="$HOME/.maestro/bin:$PATH" - cd examples/hello-world - pn run android - sleep 5 - cd ../.. + script: >- + cd examples/hello-world && + pn run android && + sleep 5 && + cd ../.. && maestro test tests/e2e/android.yaml e2e-ios: @@ -73,6 +72,8 @@ jobs: - name: Build and launch iOS app working-directory: examples/hello-world run: pn run ios + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run E2E tests run: maestro --platform ios test tests/e2e/ios.yaml diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 491381e..12485c5 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -156,7 +156,11 @@ def _copy_bundled_template_dir(template_dir: str, destination: str) -> None: def _github_json(url: str) -> Any: - req = urllib.request.Request(url, headers={"User-Agent": "pythonnative-cli"}) + headers: dict[str, str] = {"User-Agent": "pythonnative-cli"} + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req) as r: return json.loads(r.read().decode("utf-8")) From 552cd9958c463a51af9e33f0e254dab18135130f Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:19:05 -0700 Subject: [PATCH 18/34] build(deps): drop Python 3.9 support (EOL October 2025) --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 +- pyproject.toml | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e801b3..27db41c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11'] steps: - name: Checkout diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 78e1a10..a4f3201 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in contributing. This repository contains the PythonNat ## Quick start -Development uses Python ≥ 3.9. +Development uses Python ≥ 3.10. ```bash # create and activate a venv (recommended) diff --git a/pyproject.toml b/pyproject.toml index 049088c..1a00c42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ { name = "Owen Carey" } ] readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { file = "LICENSE" } classifiers = [ "Development Status :: 2 - Pre-Alpha", @@ -18,7 +18,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", From 4ff6b9453a7687eeaf7777bf4a2ab542b32a7e25 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:26:52 -0700 Subject: [PATCH 19/34] test: increase app startup wait for slow CI emulators --- tests/e2e/flows/main_page.yaml | 4 +++- tests/e2e/flows/navigation.yaml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/flows/main_page.yaml b/tests/e2e/flows/main_page.yaml index 43c184d..9c7ab0c 100644 --- a/tests/e2e/flows/main_page.yaml +++ b/tests/e2e/flows/main_page.yaml @@ -2,7 +2,9 @@ appId: ${APP_ID} --- # Verify main page renders correctly and the counter works. - launchApp -- assertVisible: "Hello from PythonNative Demo!" +- extendedWaitUntil: + visible: "Hello from PythonNative Demo!" + timeout: 30000 - assertVisible: "Tapped 0 times" - assertVisible: "Tap me" - assertVisible: "Go to Second Page" diff --git a/tests/e2e/flows/navigation.yaml b/tests/e2e/flows/navigation.yaml index 6e1b521..f71a7c9 100644 --- a/tests/e2e/flows/navigation.yaml +++ b/tests/e2e/flows/navigation.yaml @@ -2,7 +2,9 @@ appId: ${APP_ID} --- # Navigate through all three pages and back to main. - launchApp -- assertVisible: "Hello from PythonNative Demo!" +- extendedWaitUntil: + visible: "Hello from PythonNative Demo!" + timeout: 30000 - tapOn: "Go to Second Page" - assertVisible: "Greetings from MainPage" - assertVisible: "Go to Third Page" From ecc39af78708bc5a83ba81501c7b65d985890de9 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:32:59 -0700 Subject: [PATCH 20/34] docs(repo): align conventional commit scopes with module structure --- CONTRIBUTING.md | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4f3201..b05ec9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,27 +94,27 @@ Accepted types (standard): - `style` – formatting/whitespace (no code behavior) - `test` – add/adjust tests only -Recommended scopes (match the smallest accurate directory/module): - -- Library/CLI scopes: - - `cli` – `src/pythonnative/cli/` (the `pn` command) - - `core` – `src/pythonnative/pythonnative/` package internals - - `components` – UI view modules under `src/pythonnative/pythonnative/` (e.g., `button.py`, `label.py`) - - `utils` – utilities like `utils.py` - - `tests` – `tests/` - -- Templates and examples: - - `templates` – `templates/` (Android/iOS templates, zips) - - `examples` – `examples/` (e.g., `hello-world/`) - - - -- Repo‑level and ops: +Recommended scopes (choose the smallest, most accurate unit; prefer module/directory names): + +- Module/directory scopes: + - `cli` – CLI tool and `pn` command (`src/pythonnative/cli/`) + - `components` – declarative element-creating functions (`components.py`) + - `element` – Element descriptor class (`element.py`) + - `native_views` – platform-specific native view creation and updates (`native_views.py`) + - `package` – `src/pythonnative/__init__.py` exports and package boundary + - `page` – Page component, lifecycle, and reactive state (`page.py`) + - `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`) + - `utils` – shared utilities (`utils.py`) + +- Other scopes: - `deps` – dependency updates and version pins - - `docker` – containerization files (e.g., `Dockerfile`) - - `repo` – top‑level files (`README.md`, `CONTRIBUTING.md`, `.gitignore`, licenses) + - `examples` – example apps under `examples/` - `mkdocs` – documentation site (MkDocs/Material) configuration and content under `docs/` - - `workflows` – CI pipelines (e.g., `.github/workflows/`) + - `pyproject` – `pyproject.toml` packaging/build metadata + - `repo` – repository metadata and top‑level files (`README.md`, `CONTRIBUTING.md`, `.gitignore`, licenses) + - `templates` – Android/iOS project templates under `src/pythonnative/templates/` + - `tests` – unit/integration/E2E tests under `tests/` + - `workflows` – CI pipelines under `.github/workflows/` Note: Avoid redundant type==scope pairs (e.g., `docs(docs)`). Prefer a module scope (e.g., `docs(core)`) or `docs(repo)` for top‑level updates. @@ -124,12 +124,12 @@ Examples: build(deps): refresh pinned versions chore(repo): add contributing guidelines ci(workflows): add publish job -docs(core): clarify ListView data contract -feat(components): add MaterialSearchBar +docs(reconciler): clarify diffing algorithm +feat(components): add Slider element fix(cli): handle missing Android SDK gracefully -perf(core): reduce allocations in list diffing +perf(reconciler): reduce allocations in list diffing refactor(utils): extract path helpers -test(tests): cover ios template copy flow +test: cover iOS template copy flow ``` Examples (no scope): From c6e0e08cb0757dad6495c6fee36063699afba87a Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:06:05 -0700 Subject: [PATCH 21/34] docs: align branch prefixes with conventional commit types --- CONTRIBUTING.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b05ec9e..cfc287d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -229,8 +229,8 @@ Co-authored-by: Name ### Branch naming (suggested) - Use lowercase kebab‑case; concise (≤ 40 chars). -- Prefix conventions: - - `feature/-` +- Branch prefixes match Conventional Commit types: + - `feat/-` - `fix/-` - `chore/` - `docs/` @@ -239,21 +239,18 @@ Co-authored-by: Name - `test/` - `perf/` - `build/` - - `release/vX.Y.Z` - - `hotfix/` Examples: ```text -feature/cli-init +feat/cli-init fix/core-threading-deadlock-123 docs/contributing ci/publish-pypi build/lock-versions refactor/utils-paths test/templates-android -release/v0.2.0 -hotfix/cli-regression +fix/cli-regression ``` ### E2E tests (Maestro) From ab162c5b658b2367857ab998d3b3f750eca15b4a Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:21:03 -0700 Subject: [PATCH 22/34] docs(repo): Remove component table from README --- README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/README.md b/README.md index d5c0706..b0c0d9a 100644 --- a/README.md +++ b/README.md @@ -69,22 +69,6 @@ class MainPage(pn.Page): ) ``` -### Available Components - -| Component | Description | -|---|---| -| `Text` | Display text | -| `Button` | Tappable button with `on_click` callback | -| `Column` / `Row` | Vertical / horizontal layout containers | -| `ScrollView` | Scrollable wrapper | -| `TextInput` | Text entry field with `on_change` callback | -| `Image` | Display images | -| `Switch` | Toggle with `on_change` callback | -| `ProgressBar` | Determinate progress (0.0–1.0) | -| `ActivityIndicator` | Indeterminate loading spinner | -| `WebView` | Embedded web content | -| `Spacer` | Empty space | - ## Documentation Visit [docs.pythonnative.com](https://docs.pythonnative.com/) for the full documentation, including getting started guides, platform-specific instructions for Android and iOS, API reference, and working examples. From a529834a7bfe817a51ef2a5846c97c2f4deee321 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:16:22 -0700 Subject: [PATCH 23/34] feat(cli,templates): add pythonVersion config, fix Android build, and wire pip requirements --- .github/workflows/ci.yml | 2 +- examples/hello-world/app/main_page.py | 9 +- examples/hello-world/pythonnative.json | 1 + examples/hello-world/requirements.txt | 2 +- src/pythonnative/cli/pn.py | 97 ++++++++++++++++++- .../android_template/app/build.gradle | 9 +- .../templates/android_template/build.gradle | 2 +- 7 files changed, 106 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27db41c..03d9985 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12'] steps: - name: Checkout diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 9faa74f..b281d92 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,7 +1,11 @@ from typing import Any +import emoji + import pythonnative as pn +MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] + class MainPage(pn.Page): def __init__(self, native_instance: Any) -> None: @@ -12,10 +16,13 @@ def increment(self) -> None: self.set_state(count=self.state["count"] + 1) def render(self) -> pn.Element: + count = self.state["count"] + medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") return pn.ScrollView( pn.Column( pn.Text("Hello from PythonNative Demo!", font_size=24, bold=True), - pn.Text(f"Tapped {self.state['count']} times", font_size=16), + pn.Text(f"Tapped {count} times", font_size=16), + pn.Text(medal, font_size=32), pn.Button("Tap me", on_click=self.increment, background_color="#FF1E88E5"), pn.Button( "Go to Second Page", diff --git a/examples/hello-world/pythonnative.json b/examples/hello-world/pythonnative.json index 04dc187..db72356 100644 --- a/examples/hello-world/pythonnative.json +++ b/examples/hello-world/pythonnative.json @@ -2,6 +2,7 @@ "name": "PythonNative Demo", "appId": "com.pythonnative.demo", "entryPoint": "app/main_page.py", + "pythonVersion": "3.11", "ios": {}, "android": {} } diff --git a/examples/hello-world/requirements.txt b/examples/hello-world/requirements.txt index 7e4d11b..3a917a9 100644 --- a/examples/hello-world/requirements.txt +++ b/examples/hello-world/requirements.txt @@ -1 +1 @@ -pythonnative +emoji diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 12485c5..5a83b36 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -2,6 +2,7 @@ import hashlib import json import os +import re import shutil import subprocess import sys @@ -73,16 +74,17 @@ def render(self): "name": project_name, "appId": "com.example." + project_name.replace(" ", "").lower(), "entryPoint": "app/main_page.py", + "pythonVersion": "3.11", "ios": {}, "android": {}, } with open(config_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=2) - # Requirements + # Requirements (third-party packages only; pythonnative itself is bundled by the CLI) if not os.path.exists(requirements_path) or args.force: with open(requirements_path, "w", encoding="utf-8") as f: - f.write("pythonnative\n") + f.write("") # .gitignore default_gitignore = "# PythonNative\n" "__pycache__/\n" "*.pyc\n" ".venv/\n" "build/\n" ".DS_Store\n" @@ -215,6 +217,43 @@ def create_ios_project(project_name: str, destination: str) -> None: _copy_bundled_template_dir("ios_template", destination) +def _read_project_config() -> dict: + """Read pythonnative.json from the current working directory.""" + config_path = os.path.join(os.getcwd(), "pythonnative.json") + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as f: + return json.load(f) + return {} + + +def _read_requirements(requirements_path: str) -> list[str]: + """Read a requirements file and return non-empty, non-comment lines. + + Exits with an error if pythonnative is listed — the CLI bundles it + directly, so it must not be installed separately via pip/Chaquopy. + """ + if not os.path.exists(requirements_path): + return [] + with open(requirements_path, encoding="utf-8") as f: + lines = f.readlines() + result: list[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("-"): + continue + pkg_name = re.split(r"[\[><=!;]", stripped)[0].strip() + if pkg_name.lower().replace("-", "_") == "pythonnative": + print( + "Error: 'pythonnative' must not be in requirements.txt.\n" + "The pn CLI automatically bundles the installed pythonnative into your app.\n" + "requirements.txt is for third-party packages only (e.g. humanize, requests).\n" + "Remove the pythonnative line from requirements.txt and try again." + ) + sys.exit(1) + result.append(stripped) + return result + + def run_project(args: argparse.Namespace) -> None: """ Run the specified project. @@ -223,8 +262,13 @@ def run_project(args: argparse.Namespace) -> None: platform: str = args.platform prepare_only: bool = getattr(args, "prepare_only", False) + # Read project configuration and save project root before any chdir + project_dir: str = os.getcwd() + config = _read_project_config() + python_version: str = config.get("pythonVersion", "3.11") + # Define the build directory - build_dir: str = os.path.join(os.getcwd(), "build", platform) + build_dir: str = os.path.join(project_dir, "build", platform) # Create the build directory if it doesn't exist os.makedirs(build_dir, exist_ok=True) @@ -268,10 +312,30 @@ def run_project(args: argparse.Namespace) -> None: # Non-fatal; fallback to the packaged PyPI dependency if present pass - # Install any necessary Python packages into the project environment + # Validate and read the user's requirements.txt + requirements_path = os.path.join(project_dir, "requirements.txt") + pip_reqs = _read_requirements(requirements_path) + + if platform == "android": + # Patch the Android build.gradle with the configured Python version + app_build_gradle = os.path.join(build_dir, "android_template", "app", "build.gradle") + if os.path.exists(app_build_gradle): + with open(app_build_gradle, encoding="utf-8") as f: + content = f.read() + content = content.replace('version "3.11"', f'version "{python_version}"') + with open(app_build_gradle, "w", encoding="utf-8") as f: + f.write(content) + # Copy requirements.txt into the Android project for Chaquopy + android_reqs_path = os.path.join(build_dir, "android_template", "app", "requirements.txt") + if os.path.exists(requirements_path): + shutil.copy2(requirements_path, android_reqs_path) + else: + with open(android_reqs_path, "w", encoding="utf-8") as f: + f.write("") + + # Install any necessary Python packages into the host environment # Skip installation during prepare-only to avoid network access and speed up scaffolding if not prepare_only: - requirements_path = os.path.join(os.getcwd(), "requirements.txt") if os.path.exists(requirements_path): subprocess.run([sys.executable, "-m", "pip", "install", "-r", requirements_path], check=False) @@ -523,6 +587,29 @@ def run_project(args: argparse.Namespace) -> None: except Exception: # Non-fatal; if metadata isn't present, rubicon import may fail and fallback UI will appear pass + # Install user's pip requirements (pure-Python packages) into the app bundle + if pip_reqs: + try: + reqs_tmp = os.path.join(build_dir, "ios_requirements.txt") + with open(reqs_tmp, "w", encoding="utf-8") as f: + f.write("\n".join(pip_reqs) + "\n") + tmp_reqs_dir = os.path.join(build_dir, "ios_user_packages") + if os.path.isdir(tmp_reqs_dir): + shutil.rmtree(tmp_reqs_dir) + os.makedirs(tmp_reqs_dir, exist_ok=True) + subprocess.run( + [sys.executable, "-m", "pip", "install", "-t", tmp_reqs_dir, "-r", reqs_tmp], + check=False, + ) + for entry in os.listdir(tmp_reqs_dir): + src_entry = os.path.join(tmp_reqs_dir, entry) + dst_entry = os.path.join(platform_site_dir, entry) + if os.path.isdir(src_entry): + shutil.copytree(src_entry, dst_entry, dirs_exist_ok=True) + else: + shutil.copy2(src_entry, dst_entry) + except Exception: + pass # Note: Python.xcframework provides a static library for Simulator; it must be linked at build time. # We copy the XCFramework into the project directory above so Xcode can link it. except Exception: diff --git a/src/pythonnative/templates/android_template/app/build.gradle b/src/pythonnative/templates/android_template/app/build.gradle index f23ab96..5550ba8 100644 --- a/src/pythonnative/templates/android_template/app/build.gradle +++ b/src/pythonnative/templates/android_template/app/build.gradle @@ -20,14 +20,9 @@ android { abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" } python { - version "3.8" + version "3.11" pip { - install "matplotlib" - install "pythonnative" - - // "-r"` followed by a requirements filename, relative to the - // project directory: -// install "-r", "requirements.txt" + install "-r", "requirements.txt" } } } diff --git a/src/pythonnative/templates/android_template/build.gradle b/src/pythonnative/templates/android_template/build.gradle index 3d20b92..719a616 100644 --- a/src/pythonnative/templates/android_template/build.gradle +++ b/src/pythonnative/templates/android_template/build.gradle @@ -3,5 +3,5 @@ plugins { id 'com.android.application' version '8.2.2' apply false id 'com.android.library' version '8.2.2' apply false id 'org.jetbrains.kotlin.android' version '1.9.22' apply false - id 'com.chaquo.python' version '14.0.2' apply false + id 'com.chaquo.python' version '15.0.1' apply false } \ No newline at end of file From 3bd87de4a8775e23eb4f081a31b9125f9b20861c Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:30:45 -0700 Subject: [PATCH 24/34] feat!: add function components, hooks, layout, styling, hot reload, native APIs, and new UI components --- CONTRIBUTING.md | 4 + docs/api/component-properties.md | 71 ++- docs/api/pythonnative.md | 36 +- docs/concepts/architecture.md | 56 +- docs/concepts/components.md | 93 ++- docs/concepts/hooks.md | 176 ++++++ docs/guides/styling.md | 109 +++- examples/hello-world/app/main_page.py | 38 +- mkdocs.yml | 1 + src/pythonnative/__init__.py | 60 +- src/pythonnative/cli/pn.py | 39 ++ src/pythonnative/collection_view.py | 0 src/pythonnative/components.py | 366 ++++++++++- src/pythonnative/element.py | 22 +- src/pythonnative/hooks.py | 287 +++++++++ src/pythonnative/hot_reload.py | 143 +++++ .../material_bottom_navigation_view.py | 0 src/pythonnative/material_toolbar.py | 0 src/pythonnative/native_modules/__init__.py | 19 + src/pythonnative/native_modules/camera.py | 105 ++++ .../native_modules/file_system.py | 131 ++++ src/pythonnative/native_modules/location.py | 61 ++ .../native_modules/notifications.py | 151 +++++ src/pythonnative/native_views.py | 570 +++++++++++++++++- src/pythonnative/page.py | 1 + src/pythonnative/reconciler.py | 173 +++++- src/pythonnative/style.py | 115 ++++ tests/test_components.py | 106 ++++ tests/test_hooks.py | 433 +++++++++++++ tests/test_reconciler.py | 107 +++- tests/test_smoke.py | 19 + tests/test_style.py | 58 ++ 32 files changed, 3430 insertions(+), 120 deletions(-) create mode 100644 docs/concepts/hooks.md delete mode 100644 src/pythonnative/collection_view.py create mode 100644 src/pythonnative/hooks.py create mode 100644 src/pythonnative/hot_reload.py delete mode 100644 src/pythonnative/material_bottom_navigation_view.py delete mode 100644 src/pythonnative/material_toolbar.py create mode 100644 src/pythonnative/native_modules/__init__.py create mode 100644 src/pythonnative/native_modules/camera.py create mode 100644 src/pythonnative/native_modules/file_system.py create mode 100644 src/pythonnative/native_modules/location.py create mode 100644 src/pythonnative/native_modules/notifications.py create mode 100644 src/pythonnative/style.py create mode 100644 tests/test_hooks.py create mode 100644 tests/test_style.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfc287d..2a24afc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,10 +100,14 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc - `cli` – CLI tool and `pn` command (`src/pythonnative/cli/`) - `components` – declarative element-creating functions (`components.py`) - `element` – Element descriptor class (`element.py`) + - `hooks` – function components and hooks (`hooks.py`) + - `hot_reload` – file watcher and module reloader (`hot_reload.py`) + - `native_modules` – native API modules for device capabilities (`native_modules/`) - `native_views` – platform-specific native view creation and updates (`native_views.py`) - `package` – `src/pythonnative/__init__.py` exports and package boundary - `page` – Page component, lifecycle, and reactive state (`page.py`) - `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`) + - `style` – StyleSheet and theming (`style.py`) - `utils` – shared utilities (`utils.py`) - Other scopes: diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index c6dd7ed..85e7a95 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -2,6 +2,19 @@ All style and behaviour properties are passed as keyword arguments to element functions. +## Common layout properties + +All components accept these layout properties: + +- `width` — fixed width in dp (Android) / pt (iOS) +- `height` — fixed height +- `flex` — flex grow factor within Column/Row +- `margin` — outer spacing (int, float, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) +- `min_width`, `max_width` — width constraints +- `min_height`, `max_height` — height constraints +- `align_self` — override parent alignment (`"fill"`, `"center"`, etc.) +- `key` — stable identity for reconciliation + ## Text ```python @@ -42,6 +55,22 @@ pn.Row(*children, spacing=0, padding=None, alignment=None, background_color=None - `alignment` — cross-axis: `"fill"`, `"center"`, `"leading"`, `"trailing"`, `"start"`, `"end"`, `"top"`, `"bottom"` - `background_color` — container background +## View + +```python +pn.View(*children, background_color=None, padding=None) +``` + +Generic container (UIView / FrameLayout). Supports all layout properties. + +## SafeAreaView + +```python +pn.SafeAreaView(*children, background_color=None, padding=None) +``` + +Container that respects safe area insets (notch, status bar). + ## ScrollView ```python @@ -63,6 +92,9 @@ pn.TextInput(value="", placeholder="", on_change=None, secure=False, pn.Image(source="", width=None, height=None, scale_type=None, background_color=None) ``` +- `source` — image URL (`http://...` / `https://...`) or local resource name +- `scale_type` — `"cover"`, `"contain"`, `"stretch"`, `"center"` + ## Switch ```python @@ -71,6 +103,14 @@ pn.Switch(value=False, on_change=None) - `on_change` — callback `(bool) -> None` +## Slider + +```python +pn.Slider(value=0.0, min_value=0.0, max_value=1.0, on_change=None) +``` + +- `on_change` — callback `(float) -> None` + ## ProgressBar ```python @@ -94,7 +134,36 @@ pn.WebView(url="") ## Spacer ```python -pn.Spacer(size=None) +pn.Spacer(size=None, flex=None) ``` - `size` — fixed dimension in dp / pt +- `flex` — flex grow factor + +## Pressable + +```python +pn.Pressable(child, on_press=None, on_long_press=None) +``` + +Wraps any child element with tap/long-press handling. + +## Modal + +```python +pn.Modal(*children, visible=False, on_dismiss=None, title=None, background_color=None) +``` + +Overlay dialog shown when `visible=True`. + +## FlatList + +```python +pn.FlatList(data=None, render_item=None, key_extractor=None, + separator_height=0, background_color=None) +``` + +- `data` — list of items +- `render_item` — `(item, index) -> Element` function +- `key_extractor` — `(item, index) -> str` for stable keys +- `separator_height` — spacing between items diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 8147bf9..8caac58 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -9,6 +9,7 @@ ### Element functions - `pythonnative.Text`, `Button`, `Column`, `Row`, `ScrollView`, `TextInput`, `Image`, `Switch`, `ProgressBar`, `ActivityIndicator`, `WebView`, `Spacer` +- `pythonnative.View`, `SafeAreaView`, `Modal`, `Slider`, `Pressable`, `FlatList` Each returns an `Element` descriptor. See the Component Property Reference for full signatures. @@ -16,17 +17,48 @@ Each returns an `Element` descriptor. See the Component Property Reference for f `pythonnative.Element` — the descriptor type returned by element functions. You generally don't create these directly. +### Hooks + +Function component primitives: + +- `pythonnative.component` — decorator to create a function component +- `pythonnative.use_state(initial)` — local component state +- `pythonnative.use_effect(effect, deps)` — side effects +- `pythonnative.use_memo(factory, deps)` — memoised values +- `pythonnative.use_callback(fn, deps)` — stable function references +- `pythonnative.use_ref(initial)` — mutable ref object +- `pythonnative.use_context(context)` — read from context +- `pythonnative.create_context(default)` — create a new context +- `pythonnative.Provider(context, value, child)` — provide a context value + +### Styling + +- `pythonnative.StyleSheet` — utility for creating and composing style dicts +- `pythonnative.ThemeContext` — built-in theme context (defaults to light theme) + +## Native API modules + +- `pythonnative.native_modules.Camera` — photo capture and gallery picking +- `pythonnative.native_modules.Location` — GPS / location services +- `pythonnative.native_modules.FileSystem` — app-scoped file I/O +- `pythonnative.native_modules.Notifications` — local push notifications + ## Internal helpers - `pythonnative.utils.IS_ANDROID` — platform flag with robust detection for Chaquopy/Android. - `pythonnative.utils.get_android_context()` — returns the current Android `Activity`/`Context` when running on Android. - `pythonnative.utils.set_android_context(ctx)` — set by `Page` on Android; you generally don't call this directly. - `pythonnative.utils.get_android_fragment_container()` — returns the current Fragment container `ViewGroup` used for page rendering. -- `pythonnative.utils.set_android_fragment_container(viewGroup)` — set by the host `PageFragment`; you generally don't call this directly. ## Reconciler -`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Used internally by `Page`. +`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, and context providers. Used internally by `Page`. + +## Hot reload + +`pythonnative.hot_reload.FileWatcher` — watches a directory for file changes and triggers a callback. Used by `pn run --hot-reload`. + +`pythonnative.hot_reload.ModuleReloader` — reloads changed Python modules on the device and triggers page re-rendering. ## Native view registry diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 9ab94b0..903db1e 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -5,11 +5,13 @@ PythonNative combines **direct native bindings** with a **declarative reconciler ## High-level model 1. **Declarative element tree:** Your `Page.render()` method returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes). -2. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by `set_state`), it diffs the new tree against the old one and applies the minimal set of native mutations. -3. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: +2. **Function components and hooks:** Reusable components with independent state via `@pn.component`, `use_state`, `use_effect`, etc. — inspired by React hooks but designed for Python. +3. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by `set_state` or hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations. +4. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content. +5. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: - **iOS:** rubicon-objc exposes Objective-C/Swift classes (`UILabel`, `UIButton`, `UIStackView`, etc.). - **Android:** Chaquopy exposes Java classes (`android.widget.TextView`, `android.widget.Button`, etc.) via the JNI bridge. -4. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It passes a live instance/pointer into Python, and Python drives the UI through the reconciler. +6. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It passes a live instance/pointer into Python, and Python drives the UI through the reconciler. ## How it works @@ -17,15 +19,44 @@ PythonNative combines **direct native bindings** with a **declarative reconciler Page.render() → Element tree → Reconciler → Native views ↑ Page.set_state() → re-render → diff → patch native views +Hook set_state() → re-render → diff → patch native views ``` -The reconciler uses **positional diffing** (comparing children by index). When a child at a given position has the same element type, its props are updated in-place on the native view. When the type changes, the old native view is destroyed and a new one is created. +The reconciler uses **key-based diffing** (matching children by key first, then by position). When a child with the same key/type is found, its props are updated in-place on the native view. When the type changes, the old native view is destroyed and a new one is created. + +## Component model + +PythonNative supports two kinds of components: + +### Page classes (screens) + +Each screen is a `Page` subclass that bridges native lifecycle events to Python. Pages have `render()`, `set_state()`, navigation (`push`/`pop`), and lifecycle hooks (`on_create`, `on_resume`, etc.). + +### Function components (reusable UI) + +Decorated with `@pn.component`, these are Python functions that return `Element` trees and can use hooks for state, effects, memoisation, and context. Each call site creates an independent instance with its own hook state. + +```python +@pn.component +def counter(initial: int = 0) -> pn.Element: + count, set_count = pn.use_state(initial) + return pn.Text(f"Count: {count}") +``` + +## Styling + +- **Inline styles:** Pass props directly to components (`font_size=24`, `color="#333"`). +- **StyleSheet:** Create reusable named style dictionaries with `pn.StyleSheet.create(...)`. +- **Theming:** Use `pn.ThemeContext` with `pn.Provider` and `pn.use_context` to propagate theme values through the tree. + +## Layout + +All components support layout properties: `width`, `height`, `flex`, `margin`, `min_width`, `max_width`, `min_height`, `max_height`, `align_self`. Containers (`Column`, `Row`) support `spacing`, `padding`, and `alignment`. ## Comparison - **Versus React Native:** RN uses JSX + a JavaScript bridge + Yoga layout. PythonNative uses Python + direct native calls + platform layout managers. No JS bridge, no serialisation overhead. -- **Versus NativeScript:** Similar philosophy (direct, synchronous native access), but PythonNative adds a declarative reconciler layer that NativeScript does not have by default. -- **Versus the old imperative API:** The previous PythonNative API required manual `add_view()` calls and explicit setter methods. The new declarative model handles view lifecycle automatically. +- **Versus NativeScript:** Similar philosophy (direct, synchronous native access), but PythonNative adds a declarative reconciler layer and React-like hooks that NativeScript does not have by default. ## iOS flow (Rubicon-ObjC) @@ -39,6 +70,19 @@ The reconciler uses **positional diffing** (comparing children by index). When a - `PageFragment` calls `on_create()` on the Python `Page`, which renders and attaches views to the fragment container. - State changes trigger re-render; the reconciler patches Android views in-place. +## Hot reload + +During development, `pn run --hot-reload` watches `app/` for file changes and pushes updated Python files to the running app, enabling near-instant UI updates without full rebuilds. + +## Native API modules + +PythonNative provides cross-platform modules for common device APIs: + +- `pythonnative.native_modules.Camera` — photo capture and gallery +- `pythonnative.native_modules.Location` — GPS / location services +- `pythonnative.native_modules.FileSystem` — app-scoped file I/O +- `pythonnative.native_modules.Notifications` — local push notifications + ## Navigation model overview - See the Navigation guide for full details. diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 016c21c..a39d869 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -26,12 +26,14 @@ pn.Column( - `Column(*children, spacing, padding, alignment, background_color)` — vertical stack - `Row(*children, spacing, padding, alignment, background_color)` — horizontal stack - `ScrollView(child, background_color)` — scrollable container -- `Spacer(size)` — empty space +- `View(*children, background_color, padding)` — generic container +- `SafeAreaView(*children, background_color, padding)` — safe-area-aware container +- `Spacer(size, flex)` — empty space **Display:** - `Text(text, font_size, color, bold, text_align, background_color, max_lines)` — text display -- `Image(source, width, height, scale_type)` — image display +- `Image(source, width, height, scale_type)` — image display (supports URLs and resource names) - `WebView(url)` — embedded web content **Input:** @@ -39,12 +41,32 @@ pn.Column( - `Button(title, on_click, color, background_color, font_size, enabled)` — tappable button - `TextInput(value, placeholder, on_change, secure, font_size, color)` — text entry - `Switch(value, on_change)` — toggle switch +- `Slider(value, min_value, max_value, on_change)` — continuous slider +- `Pressable(child, on_press, on_long_press)` — tap handler wrapper **Feedback:** - `ProgressBar(value)` — determinate progress (0.0–1.0) - `ActivityIndicator(animating)` — indeterminate spinner +**Overlay:** + +- `Modal(*children, visible, on_dismiss, title)` — modal dialog + +**Lists:** + +- `FlatList(data, render_item, key_extractor, separator_height)` — scrollable data list + +### Layout properties + +All components support common layout properties: + +- `width`, `height` — fixed dimensions (dp / pt) +- `flex` — flex grow factor +- `margin` — outer margin (int, float, or dict like padding) +- `min_width`, `max_width`, `min_height`, `max_height` — size constraints +- `align_self` — override parent alignment for this child + ## Page — the root component Each screen is a `Page` subclass with a `render()` method that returns an element tree: @@ -80,17 +102,23 @@ class CounterPage(pn.Page): ) ``` -## Reusable components as functions +## Function components with hooks -For reusable UI pieces, use regular Python functions that return elements: +For reusable UI pieces **with their own state**, use the `@pn.component` decorator and hooks: ```python -def greeting_card(name, on_tap): +@pn.component +def counter(label: str = "Count", initial: int = 0) -> pn.Element: + count, set_count = pn.use_state(initial) + return pn.Column( - pn.Text(f"Hello, {name}!", font_size=20, bold=True), - pn.Button("Say hi", on_click=on_tap), - spacing=8, - padding=12, + pn.Text(f"{label}: {count}", font_size=18), + pn.Row( + pn.Button("-", on_click=lambda: set_count(count - 1)), + pn.Button("+", on_click=lambda: set_count(count + 1)), + spacing=8, + ), + spacing=4, ) class MainPage(pn.Page): @@ -99,12 +127,55 @@ class MainPage(pn.Page): def render(self): return pn.Column( - greeting_card("Alice", lambda: print("Hi Alice")), - greeting_card("Bob", lambda: print("Hi Bob")), + counter(label="Apples", initial=0), + counter(label="Oranges", initial=5), spacing=16, + padding=16, ) ``` +Each `counter` instance has **independent state** — changing one doesn't affect the other. + +### Available hooks + +- `use_state(initial)` — local component state; returns `(value, setter)` +- `use_effect(effect, deps)` — side effects (timers, API calls, subscriptions) +- `use_memo(factory, deps)` — memoised computed values +- `use_callback(fn, deps)` — stable function references +- `use_ref(initial)` — mutable ref that persists across renders +- `use_context(context)` — read from a context provider + +### Custom hooks + +Extract reusable stateful logic into plain functions: + +```python +def use_toggle(initial: bool = False): + value, set_value = pn.use_state(initial) + def toggle(): + set_value(not value) + return value, toggle +``` + +### Context and Provider + +Share values across the tree without prop drilling: + +```python +theme = pn.create_context({"primary": "#007AFF"}) + +# In a page's render(): +pn.Provider(theme, {"primary": "#FF0000"}, + my_component() +) + +# In my_component: +@pn.component +def my_component() -> pn.Element: + t = pn.use_context(theme) + return pn.Button("Click", color=t["primary"]) +``` + ## Platform detection Use `pythonnative.utils.IS_ANDROID` when you need platform-specific logic: diff --git a/docs/concepts/hooks.md b/docs/concepts/hooks.md new file mode 100644 index 0000000..a52197a --- /dev/null +++ b/docs/concepts/hooks.md @@ -0,0 +1,176 @@ +# Function Components and Hooks + +PythonNative supports React-like function components with hooks for managing state, effects, memoisation, and context. This is the recommended way to build reusable UI pieces. + +## Creating a function component + +Decorate a Python function with `@pn.component`: + +```python +import pythonnative as pn + +@pn.component +def greeting(name: str = "World") -> pn.Element: + return pn.Text(f"Hello, {name}!", font_size=20) +``` + +Use it like any other component: + +```python +class MyPage(pn.Page): + def render(self): + return pn.Column( + greeting(name="Alice"), + greeting(name="Bob"), + spacing=12, + ) +``` + +## Hooks + +Hooks let function components manage state and side effects. They must be called at the top level of a `@pn.component` function (not inside loops or conditions). + +### use_state + +Local component state. Returns `(value, setter)`. + +```python +@pn.component +def counter(initial: int = 0) -> pn.Element: + count, set_count = pn.use_state(initial) + + return pn.Column( + pn.Text(f"Count: {count}"), + pn.Button("+", on_click=lambda: set_count(count + 1)), + ) +``` + +The setter accepts a value or a function that receives the current value: + +```python +set_count(10) # set directly +set_count(lambda prev: prev + 1) # functional update +``` + +If the initial value is expensive to compute, pass a callable: + +```python +count, set_count = pn.use_state(lambda: compute_default()) +``` + +### use_effect + +Run side effects after render. The effect function may return a cleanup callable. + +```python +@pn.component +def timer() -> pn.Element: + seconds, set_seconds = pn.use_state(0) + + def tick(): + import threading + t = threading.Timer(1.0, lambda: set_seconds(seconds + 1)) + t.start() + return t.cancel # cleanup: cancel the timer + + pn.use_effect(tick, [seconds]) + + return pn.Text(f"Elapsed: {seconds}s") +``` + +Dependency control: + +- `pn.use_effect(fn, None)` — run on every render +- `pn.use_effect(fn, [])` — run on mount only +- `pn.use_effect(fn, [a, b])` — run when `a` or `b` change + +### use_memo + +Memoise an expensive computation: + +```python +sorted_items = pn.use_memo(lambda: sorted(items, key=lambda x: x.name), [items]) +``` + +### use_callback + +Return a stable function reference (avoids unnecessary re-renders of children): + +```python +handle_click = pn.use_callback(lambda: set_count(count + 1), [count]) +``` + +### use_ref + +A mutable container that persists across renders without triggering re-renders: + +```python +render_count = pn.use_ref(0) +render_count["current"] += 1 +``` + +### use_context + +Read a value from the nearest `Provider` ancestor: + +```python +theme = pn.use_context(pn.ThemeContext) +color = theme["primary_color"] +``` + +## Context and Provider + +Share values through the component tree without passing props manually: + +```python +# Create a context with a default value +user_context = pn.create_context({"name": "Guest"}) + +# Provide a value to descendants +pn.Provider(user_context, {"name": "Alice"}, + user_profile() +) + +# Consume in any descendant +@pn.component +def user_profile() -> pn.Element: + user = pn.use_context(user_context) + return pn.Text(f"Welcome, {user['name']}") +``` + +## Custom hooks + +Extract reusable stateful logic into plain functions: + +```python +def use_toggle(initial: bool = False): + value, set_value = pn.use_state(initial) + toggle = pn.use_callback(lambda: set_value(not value), [value]) + return value, toggle + +def use_text_input(initial: str = ""): + text, set_text = pn.use_state(initial) + return text, set_text +``` + +Use them in any component: + +```python +@pn.component +def settings() -> pn.Element: + dark_mode, toggle_dark = use_toggle(False) + + return pn.Column( + pn.Text("Settings", font_size=24, bold=True), + pn.Row( + pn.Text("Dark mode"), + pn.Switch(value=dark_mode, on_change=lambda v: toggle_dark()), + ), + ) +``` + +## Rules of hooks + +1. Only call hooks inside `@pn.component` functions +2. Call hooks at the top level — not inside loops, conditions, or nested functions +3. Hooks must be called in the same order on every render diff --git a/docs/guides/styling.md b/docs/guides/styling.md index 000e40f..cf93f8f 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -1,6 +1,57 @@ # Styling -Style properties are passed as keyword arguments to element functions. This replaces the old fluent setter pattern. +Style properties are passed as keyword arguments to element functions. PythonNative also provides a `StyleSheet` utility for creating reusable styles and a theming system via context. + +## Inline styles + +Pass style props directly to components: + +```python +pn.Text("Hello", color="#FF3366", font_size=24, bold=True) +pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") +pn.Column(pn.Text("Content"), background_color="#FFF5F5F5") +``` + +## StyleSheet + +Create reusable named styles with `StyleSheet.create()`: + +```python +import pythonnative as pn + +styles = pn.StyleSheet.create( + title={"font_size": 28, "bold": True, "color": "#333"}, + subtitle={"font_size": 14, "color": "#666"}, + container={"padding": 16, "spacing": 12, "alignment": "fill"}, +) + +# Apply with dict unpacking +pn.Text("Welcome", **styles["title"]) +pn.Column( + pn.Text("Subtitle", **styles["subtitle"]), + **styles["container"], +) +``` + +### Composing styles + +Merge multiple style dicts with `StyleSheet.compose()`: + +```python +base = {"font_size": 16, "color": "#000"} +highlight = {"color": "#FF0000", "bold": True} +merged = pn.StyleSheet.compose(base, highlight) +# Result: {"font_size": 16, "color": "#FF0000", "bold": True} +``` + +### Flattening styles + +Flatten a style or list of styles into a single dict: + +```python +pn.StyleSheet.flatten([base, highlight]) +pn.StyleSheet.flatten(None) # returns {} +``` ## Colors @@ -9,7 +60,6 @@ Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color props: ```python pn.Text("Hello", color="#FF3366") pn.Button("Tap", background_color="#FF1E88E5", color="#FFFFFF") -pn.Column(pn.Text("Content"), background_color="#FFF5F5F5") ``` ## Text styling @@ -21,9 +71,25 @@ pn.Text("Title", font_size=24, bold=True, text_align="center") pn.Text("Subtitle", font_size=14, color="#666666") ``` +## Layout properties + +All components support common layout properties: + +```python +pn.Text("Fixed size", width=200, height=50) +pn.View(child, flex=1, margin=8) +pn.Column(items, margin={"horizontal": 16, "vertical": 8}) +``` + +- `width`, `height` — fixed dimensions in dp (Android) / pt (iOS) +- `flex` — flex grow factor within Column/Row +- `margin` — outer spacing (int for all sides, or dict) +- `min_width`, `max_width`, `min_height`, `max_height` — size constraints +- `align_self` — override parent alignment + ## Layout with Column and Row -`Column` (vertical) and `Row` (horizontal) replace the old `StackView`: +`Column` (vertical) and `Row` (horizontal): ```python pn.Column( @@ -48,12 +114,45 @@ pn.Column( - `padding={"horizontal": 12, "vertical": 8}` — per axis - `padding={"left": 8, "top": 16, "right": 8, "bottom": 16}` — per side -Android: applied via `setPadding` in dp. iOS: best-effort via layout margins. - ### Alignment Cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"`. +## Theming + +PythonNative includes a built-in theme context with light and dark themes: + +```python +import pythonnative as pn +from pythonnative.style import DEFAULT_DARK_THEME + +@pn.component +def themed_text(text: str = "") -> pn.Element: + theme = pn.use_context(pn.ThemeContext) + return pn.Text(text, color=theme["text_color"], font_size=theme["font_size"]) + +class MyPage(pn.Page): + def render(self): + return pn.Provider(pn.ThemeContext, DEFAULT_DARK_THEME, + pn.Column( + themed_text(text="Dark mode!"), + spacing=8, + ) + ) +``` + +### Theme properties + +Both light and dark themes include: + +- `primary_color`, `secondary_color` — accent colors +- `background_color`, `surface_color` — background colors +- `text_color`, `text_secondary_color` — text colors +- `error_color`, `success_color`, `warning_color` — semantic colors +- `font_size`, `font_size_small`, `font_size_large`, `font_size_title` — typography +- `spacing`, `spacing_large` — layout spacing +- `border_radius` — corner rounding + ## ScrollView Wrap content in a `ScrollView`: diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index b281d92..dbf2fe8 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -7,23 +7,37 @@ MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] +styles = pn.StyleSheet.create( + title={"font_size": 24, "bold": True}, + subtitle={"font_size": 16, "color": "#666666"}, + medal={"font_size": 32}, + section={"spacing": 12, "padding": 16, "alignment": "fill"}, +) + + +@pn.component +def counter_badge(initial: int = 0) -> pn.Element: + """Reusable counter component with its own hook-based state.""" + count, set_count = pn.use_state(initial) + medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") + + return pn.Column( + pn.Text(f"Tapped {count} times", **styles["subtitle"]), + pn.Text(medal, **styles["medal"]), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + spacing=4, + ) + + class MainPage(pn.Page): def __init__(self, native_instance: Any) -> None: super().__init__(native_instance) - self.state = {"count": 0} - - def increment(self) -> None: - self.set_state(count=self.state["count"] + 1) def render(self) -> pn.Element: - count = self.state["count"] - medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") return pn.ScrollView( pn.Column( - pn.Text("Hello from PythonNative Demo!", font_size=24, bold=True), - pn.Text(f"Tapped {count} times", font_size=16), - pn.Text(medal, font_size=32), - pn.Button("Tap me", on_click=self.increment, background_color="#FF1E88E5"), + pn.Text("Hello from PythonNative Demo!", **styles["title"]), + counter_badge(), pn.Button( "Go to Second Page", on_click=lambda: self.push( @@ -31,8 +45,6 @@ def render(self) -> pn.Element: args={"message": "Greetings from MainPage"}, ), ), - spacing=12, - padding=16, - alignment="fill", + **styles["section"], ) ) diff --git a/mkdocs.yml b/mkdocs.yml index 7aeff94..308e6c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ nav: - Concepts: - Architecture: concepts/architecture.md - Components: concepts/components.md + - Hooks: concepts/hooks.md - Examples: - Overview: examples.md - Hello World: examples/hello-world.md diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 053a95e..923a403 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -4,16 +4,25 @@ import pythonnative as pn + @pn.component + def counter(initial=0): + count, set_count = pn.use_state(initial) + return pn.Column( + pn.Text(f"Count: {count}", font_size=24), + pn.Button("+", on_click=lambda: set_count(count + 1)), + spacing=12, + ) + class MainPage(pn.Page): def __init__(self, native_instance): super().__init__(native_instance) - self.state = {"count": 0} def render(self): return pn.Column( - pn.Text(f"Count: {self.state['count']}", font_size=24), - pn.Button("Increment", on_click=lambda: self.set_state(count=self.state["count"] + 1)), - spacing=12, + counter(initial=0), + counter(initial=10), + spacing=16, + padding=16, ) """ @@ -23,32 +32,71 @@ def render(self): ActivityIndicator, Button, Column, + FlatList, Image, + Modal, + Pressable, ProgressBar, Row, + SafeAreaView, ScrollView, + Slider, Spacer, Switch, Text, TextInput, + View, WebView, ) from .element import Element +from .hooks import ( + Provider, + component, + create_context, + use_callback, + use_context, + use_effect, + use_memo, + use_ref, + use_state, +) from .page import Page +from .style import StyleSheet, ThemeContext __all__ = [ + # Components "ActivityIndicator", "Button", "Column", - "Element", + "FlatList", "Image", - "Page", + "Modal", + "Pressable", "ProgressBar", "Row", + "SafeAreaView", "ScrollView", + "Slider", "Spacer", "Switch", "Text", "TextInput", + "View", "WebView", + # Core + "Element", + "Page", + # Hooks + "component", + "create_context", + "use_callback", + "use_context", + "use_effect", + "use_memo", + "use_ref", + "use_state", + "Provider", + # Styling + "StyleSheet", + "ThemeContext", ] diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 5a83b36..18d1bf8 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -261,6 +261,7 @@ def run_project(args: argparse.Namespace) -> None: # Determine the platform platform: str = args.platform prepare_only: bool = getattr(args, "prepare_only", False) + hot_reload: bool = getattr(args, "hot_reload", False) # Read project configuration and save project root before any chdir project_dir: str = os.getcwd() @@ -656,6 +657,39 @@ def run_project(args: argparse.Namespace) -> None: except Exception: print("Failed to auto-run on Simulator; open the project in Xcode to run.") + # Hot-reload file watcher + if hot_reload and not prepare_only: + _run_hot_reload(platform, project_dir, build_dir) + + +def _run_hot_reload(platform: str, project_dir: str, build_dir: str) -> None: + """Watch ``app/`` for changes and push updated files to the device.""" + from .hot_reload import FileWatcher + + app_dir = os.path.join(project_dir, "app") + + def on_change(changed_files: List[str]) -> None: + for fpath in changed_files: + rel = os.path.relpath(fpath, project_dir) + print(f"[hot-reload] Changed: {rel}") + if platform == "android": + dest = f"/data/data/com.pythonnative.android_template/files/{rel}" + subprocess.run(["adb", "push", fpath, dest], check=False, capture_output=True) + elif platform == "ios": + pass # simctl file push would go here + + print("[hot-reload] Watching app/ for changes. Press Ctrl+C to stop.") + watcher = FileWatcher(app_dir, on_change, interval=1.0) + watcher.start() + try: + import time + + while True: + time.sleep(1) + except KeyboardInterrupt: + watcher.stop() + print("\n[hot-reload] Stopped.") + def clean_project(args: argparse.Namespace) -> None: """ @@ -690,6 +724,11 @@ def main() -> None: action="store_true", help="Extract templates and stage app without building", ) + parser_run.add_argument( + "--hot-reload", + action="store_true", + help="Watch app/ for changes and push updates to the running app", + ) parser_run.set_defaults(func=run_project) # Create a new command 'clean' that calls clean_project diff --git a/src/pythonnative/collection_view.py b/src/pythonnative/collection_view.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py index ad57eb5..08bb221 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -4,34 +4,56 @@ These are pure data — no native views are created until the reconciler mounts the element tree. -Naming follows React Native conventions: - -- ``Text`` (was *Label*) -- ``Button`` -- ``Column`` / ``Row`` (was *StackView* vertical/horizontal) -- ``ScrollView`` -- ``TextInput`` (was *TextField*) -- ``Image`` (was *ImageView*) -- ``Switch`` -- ``ProgressBar`` (was *ProgressView*) -- ``ActivityIndicator`` (was *ActivityIndicatorView*) -- ``WebView`` -- ``Spacer`` (new) +Layout properties (``width``, ``height``, ``flex``, ``margin``, +``min_width``, ``max_width``, ``min_height``, ``max_height``, +``align_self``) are supported by all components. """ -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from .element import Element +# ====================================================================== +# Shared helpers +# ====================================================================== + +PaddingValue = Union[int, float, Dict[str, Union[int, float]]] +MarginValue = Union[int, float, Dict[str, Union[int, float]]] + def _filter_none(**kwargs: Any) -> Dict[str, Any]: """Return *kwargs* with ``None``-valued entries removed.""" return {k: v for k, v in kwargs.items() if v is not None} -# --------------------------------------------------------------------------- +def _layout_props( + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, +) -> Dict[str, Any]: + """Collect common layout props into a dict (excluding Nones).""" + return _filter_none( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + + +# ====================================================================== # Leaf components -# --------------------------------------------------------------------------- +# ====================================================================== def Text( @@ -43,6 +65,15 @@ def Text( text_align: Optional[str] = None, background_color: Optional[str] = None, max_lines: Optional[int] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Display text.""" @@ -55,6 +86,19 @@ def Text( background_color=background_color, max_lines=max_lines, ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Text", props, [], key=key) @@ -66,6 +110,15 @@ def Button( background_color: Optional[str] = None, font_size: Optional[float] = None, enabled: bool = True, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Create a tappable button.""" @@ -80,6 +133,19 @@ def Button( props["font_size"] = font_size if not enabled: props["enabled"] = False + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Button", props, [], key=key) @@ -92,6 +158,15 @@ def TextInput( font_size: Optional[float] = None, color: Optional[str] = None, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Create a single-line text entry field.""" @@ -108,6 +183,19 @@ def TextInput( props["color"] = color if background_color is not None: props["background_color"] = background_color + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("TextInput", props, [], key=key) @@ -118,6 +206,13 @@ def Image( height: Optional[float] = None, scale_type: Optional[str] = None, background_color: Optional[str] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Display an image from a resource path or URL.""" @@ -128,6 +223,17 @@ def Image( scale_type=scale_type, background_color=background_color, ) + props.update( + _layout_props( + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Image", props, [], key=key) @@ -135,12 +241,18 @@ def Switch( *, value: bool = False, on_change: Optional[Callable[[bool], None]] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Create a toggle switch.""" props: Dict[str, Any] = {"value": value} if on_change is not None: props["on_change"] = on_change + props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) return Element("Switch", props, [], key=key) @@ -148,49 +260,66 @@ def ProgressBar( *, value: float = 0.0, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Show determinate progress (0.0 – 1.0).""" props = _filter_none(value=value, background_color=background_color) + props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) return Element("ProgressBar", props, [], key=key) def ActivityIndicator( *, animating: bool = True, + width: Optional[float] = None, + height: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Show an indeterminate loading spinner.""" - return Element("ActivityIndicator", {"animating": animating}, [], key=key) + props: Dict[str, Any] = {"animating": animating} + props.update(_layout_props(width=width, height=height, margin=margin, align_self=align_self)) + return Element("ActivityIndicator", props, [], key=key) def WebView( *, url: str = "", + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Embed web content.""" props: Dict[str, Any] = {} if url: props["url"] = url + props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) return Element("WebView", props, [], key=key) def Spacer( *, size: Optional[float] = None, + flex: Optional[float] = None, key: Optional[str] = None, ) -> Element: """Insert empty space with an optional fixed size.""" - props = _filter_none(size=size) + props = _filter_none(size=size, flex=flex) return Element("Spacer", props, [], key=key) -# --------------------------------------------------------------------------- +# ====================================================================== # Container components -# --------------------------------------------------------------------------- - -PaddingValue = Union[int, float, Dict[str, Union[int, float]]] +# ====================================================================== def Column( @@ -199,6 +328,15 @@ def Column( padding: Optional[PaddingValue] = None, alignment: Optional[str] = None, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Arrange children vertically.""" @@ -208,6 +346,19 @@ def Column( alignment=alignment, background_color=background_color, ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Column", props, list(children), key=key) @@ -217,6 +368,15 @@ def Row( padding: Optional[PaddingValue] = None, alignment: Optional[str] = None, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Arrange children horizontally.""" @@ -226,6 +386,19 @@ def Row( alignment=alignment, background_color=background_color, ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) return Element("Row", props, list(children), key=key) @@ -233,9 +406,158 @@ def ScrollView( child: Optional[Element] = None, *, background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, key: Optional[str] = None, ) -> Element: """Wrap a single child in a scrollable container.""" children = [child] if child is not None else [] props = _filter_none(background_color=background_color) + props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) return Element("ScrollView", props, children, key=key) + + +def View( + *children: Element, + background_color: Optional[str] = None, + padding: Optional[PaddingValue] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + min_width: Optional[float] = None, + max_width: Optional[float] = None, + min_height: Optional[float] = None, + max_height: Optional[float] = None, + align_self: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Generic container view (``UIView`` / ``android.view.View``).""" + props = _filter_none( + background_color=background_color, + padding=padding, + ) + props.update( + _layout_props( + width=width, + height=height, + flex=flex, + margin=margin, + min_width=min_width, + max_width=max_width, + min_height=min_height, + max_height=max_height, + align_self=align_self, + ) + ) + return Element("View", props, list(children), key=key) + + +def SafeAreaView( + *children: Element, + background_color: Optional[str] = None, + padding: Optional[PaddingValue] = None, + key: Optional[str] = None, +) -> Element: + """Container that respects safe area insets (notch, status bar).""" + props = _filter_none(background_color=background_color, padding=padding) + return Element("SafeAreaView", props, list(children), key=key) + + +def Modal( + *children: Element, + visible: bool = False, + on_dismiss: Optional[Callable[[], None]] = None, + title: Optional[str] = None, + background_color: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Overlay modal dialog. + + The modal is shown when ``visible=True`` and hidden when ``False``. + """ + props: Dict[str, Any] = {"visible": visible} + if on_dismiss is not None: + props["on_dismiss"] = on_dismiss + if title is not None: + props["title"] = title + if background_color is not None: + props["background_color"] = background_color + return Element("Modal", props, list(children), key=key) + + +def Slider( + *, + value: float = 0.0, + min_value: float = 0.0, + max_value: float = 1.0, + on_change: Optional[Callable[[float], None]] = None, + width: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Continuous value slider.""" + props: Dict[str, Any] = { + "value": value, + "min_value": min_value, + "max_value": max_value, + } + if on_change is not None: + props["on_change"] = on_change + props.update(_layout_props(width=width, margin=margin, align_self=align_self)) + return Element("Slider", props, [], key=key) + + +def Pressable( + child: Optional[Element] = None, + *, + on_press: Optional[Callable[[], None]] = None, + on_long_press: Optional[Callable[[], None]] = None, + key: Optional[str] = None, +) -> Element: + """Wrapper that adds press handling to any child element.""" + props: Dict[str, Any] = {} + if on_press is not None: + props["on_press"] = on_press + if on_long_press is not None: + props["on_long_press"] = on_long_press + children = [child] if child is not None else [] + return Element("Pressable", props, children, key=key) + + +def FlatList( + *, + data: Optional[List[Any]] = None, + render_item: Optional[Callable[[Any, int], Element]] = None, + key_extractor: Optional[Callable[[Any, int], str]] = None, + separator_height: float = 0, + background_color: Optional[str] = None, + width: Optional[float] = None, + height: Optional[float] = None, + flex: Optional[float] = None, + margin: Optional[MarginValue] = None, + align_self: Optional[str] = None, + key: Optional[str] = None, +) -> Element: + """Scrollable list that renders items from *data* using *render_item*. + + Each item is rendered by calling ``render_item(item, index)``. If + ``key_extractor`` is provided, it is called as ``key_extractor(item, index)`` + to produce a stable key for each child element. This enables the + reconciler to preserve widget state across data changes. + """ + items: List[Element] = [] + for i, item in enumerate(data or []): + el = render_item(item, i) if render_item else Text(str(item)) + if key_extractor is not None: + el = Element(el.type, el.props, el.children, key=key_extractor(item, i)) + items.append(el) + + inner = Column(*items, spacing=separator_height) + sv_props = _filter_none(background_color=background_color) + sv_props.update(_layout_props(width=width, height=height, flex=flex, margin=margin, align_self=align_self)) + return Element("ScrollView", sv_props, [inner], key=key) diff --git a/src/pythonnative/element.py b/src/pythonnative/element.py index 8930386..63684d3 100644 --- a/src/pythonnative/element.py +++ b/src/pythonnative/element.py @@ -1,23 +1,28 @@ """Lightweight element descriptors for the virtual view tree. An Element is an immutable description of a UI node — analogous to a React -element. It captures a type name, a props dictionary, and an ordered list -of children without creating any native platform objects. The reconciler -consumes these trees to determine what native views must be created, -updated, or removed. +element. It captures a type (name string **or** component function), a props +dictionary, and an ordered list of children without creating any native +platform objects. The reconciler consumes these trees to determine what +native views must be created, updated, or removed. """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union class Element: - """Immutable description of a single UI node.""" + """Immutable description of a single UI node. + + ``type_name`` may be a *string* (e.g. ``"Text"``) for built-in native + elements or a *callable* for function components decorated with + :func:`~pythonnative.hooks.component`. + """ __slots__ = ("type", "props", "children", "key") def __init__( self, - type_name: str, + type_name: Union[str, Any], props: Dict[str, Any], children: List["Element"], key: Optional[str] = None, @@ -28,7 +33,8 @@ def __init__( self.key = key def __repr__(self) -> str: - return f"Element({self.type!r}, props={set(self.props)}, children={len(self.children)})" + t = self.type if isinstance(self.type, str) else getattr(self.type, "__name__", repr(self.type)) + return f"Element({t!r}, props={set(self.props)}, children={len(self.children)})" def __eq__(self, other: object) -> bool: if not isinstance(other, Element): diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py new file mode 100644 index 0000000..79ff061 --- /dev/null +++ b/src/pythonnative/hooks.py @@ -0,0 +1,287 @@ +"""Hook primitives for function components. + +Provides React-like hooks for managing state, effects, memoisation, +and context within function components decorated with :func:`component`. + +Usage:: + + import pythonnative as pn + + @pn.component + def counter(initial=0): + count, set_count = pn.use_state(initial) + return pn.Column( + pn.Text(f"Count: {count}"), + pn.Button("+", on_click=lambda: set_count(count + 1)), + ) +""" + +import inspect +import threading +from typing import Any, Callable, List, Optional, Tuple, TypeVar + +from .element import Element + +T = TypeVar("T") + +_SENTINEL = object() + +_hook_context: threading.local = threading.local() + + +# ====================================================================== +# Hook state container +# ====================================================================== + + +class HookState: + """Stores all hook data for a single function component instance.""" + + __slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render") + + def __init__(self) -> None: + self.states: List[Any] = [] + self.effects: List[Tuple[Any, Any]] = [] + self.memos: List[Tuple[Any, Any]] = [] + self.refs: List[dict] = [] + self.hook_index: int = 0 + self._trigger_render: Optional[Callable[[], None]] = None + + def reset_index(self) -> None: + self.hook_index = 0 + + def run_pending_effects(self) -> None: + """Execute effects whose deps changed during the last render pass.""" + for i, (deps, cleanup) in enumerate(self.effects): + if deps is _SENTINEL: + continue + + def cleanup_all_effects(self) -> None: + """Run all outstanding cleanup functions (called on unmount).""" + for i, (deps, cleanup) in enumerate(self.effects): + if callable(cleanup): + try: + cleanup() + except Exception: + pass + self.effects[i] = (_SENTINEL, None) + + +# ====================================================================== +# Thread-local context helpers +# ====================================================================== + + +def _get_hook_state() -> Optional[HookState]: + return getattr(_hook_context, "current", None) + + +def _set_hook_state(state: Optional[HookState]) -> None: + _hook_context.current = state + + +def _deps_changed(prev: Any, current: Any) -> bool: + if prev is _SENTINEL: + return True + if prev is None or current is None: + return True + if len(prev) != len(current): + return True + return any(p is not c and p != c for p, c in zip(prev, current)) + + +# ====================================================================== +# Public hooks +# ====================================================================== + + +def use_state(initial: Any = None) -> Tuple[Any, Callable]: + """Return ``(value, setter)`` for component-local state. + + If *initial* is callable it is invoked once (lazy initialisation). + The setter accepts a value **or** a ``current -> new`` callable. + """ + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_state must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.states): + val = initial() if callable(initial) else initial + ctx.states.append(val) + + current = ctx.states[idx] + + def setter(new_value: Any) -> None: + if callable(new_value): + new_value = new_value(ctx.states[idx]) + if ctx.states[idx] is not new_value and ctx.states[idx] != new_value: + ctx.states[idx] = new_value + if ctx._trigger_render: + ctx._trigger_render() + + return current, setter + + +def use_effect(effect: Callable, deps: Optional[list] = None) -> None: + """Schedule *effect* to run after render. + + *deps* controls when the effect re-runs: + + - ``None`` -> every render + - ``[]`` -> mount only + - ``[a, b]``-> when *a* or *b* change + + *effect* may return a cleanup callable. + """ + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_effect must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.effects): + ctx.effects.append((_SENTINEL, None)) + + prev_deps, prev_cleanup = ctx.effects[idx] + if _deps_changed(prev_deps, deps): + if callable(prev_cleanup): + try: + prev_cleanup() + except Exception: + pass + cleanup = effect() + ctx.effects[idx] = (list(deps) if deps is not None else None, cleanup) + else: + ctx.effects[idx] = (prev_deps, prev_cleanup) + + +def use_memo(factory: Callable[[], T], deps: list) -> T: + """Return a memoised value, recomputed only when *deps* change.""" + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_memo must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.memos): + value = factory() + ctx.memos.append((list(deps), value)) + return value + + prev_deps, prev_value = ctx.memos[idx] + if not _deps_changed(prev_deps, deps): + return prev_value + + value = factory() + ctx.memos[idx] = (list(deps), value) + return value + + +def use_callback(callback: Callable, deps: list) -> Callable: + """Return a stable reference to *callback*, updated only when *deps* change.""" + return use_memo(lambda: callback, deps) + + +def use_ref(initial: Any = None) -> dict: + """Return a mutable ref dict ``{"current": initial}`` that persists across renders.""" + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_ref must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.refs): + ref: dict = {"current": initial} + ctx.refs.append(ref) + return ref + + return ctx.refs[idx] + + +# ====================================================================== +# Context +# ====================================================================== + + +class Context: + """A context object created by :func:`create_context`.""" + + def __init__(self, default: Any = None) -> None: + self.default = default + self._stack: List[Any] = [] + + def _current(self) -> Any: + return self._stack[-1] if self._stack else self.default + + +def create_context(default: Any = None) -> Context: + """Create a new context with an optional default value.""" + return Context(default) + + +def use_context(context: Context) -> Any: + """Read the current value of *context* from the nearest ``Provider`` ancestor.""" + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_context must be called inside a @component function") + return context._current() + + +# ====================================================================== +# Provider element helper +# ====================================================================== + + +def Provider(context: Context, value: Any, child: Element) -> Element: + """Create a context provider element. + + All descendants of *child* will read *value* via ``use_context(context)``. + """ + return Element("__Provider__", {"__context__": context, "__value__": value}, [child]) + + +# ====================================================================== +# @component decorator +# ====================================================================== + + +def component(func: Callable) -> Callable[..., Element]: + """Decorator that turns a Python function into a PythonNative component. + + The decorated function can use hooks (``use_state``, ``use_effect``, etc.) + and returns an ``Element`` tree. Each call site creates an independent + component instance with its own hook state. + """ + sig = inspect.signature(func) + positional_params = [ + name + for name, p in sig.parameters.items() + if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()) + + def wrapper(*args: Any, **kwargs: Any) -> Element: + props: dict = dict(kwargs) + + if args: + if has_var_positional: + props["children"] = list(args) + else: + for i, arg in enumerate(args): + if i < len(positional_params): + props[positional_params[i]] = arg + + key = props.pop("key", None) + return Element(func, props, [], key=key) + + wrapper.__wrapped__ = func # noqa: B010 + wrapper.__name__ = func.__name__ + wrapper.__qualname__ = func.__qualname__ + wrapper._pn_component = True # noqa: B010 + return wrapper diff --git a/src/pythonnative/hot_reload.py b/src/pythonnative/hot_reload.py new file mode 100644 index 0000000..1933118 --- /dev/null +++ b/src/pythonnative/hot_reload.py @@ -0,0 +1,143 @@ +"""Hot-reload support for PythonNative development. + +Host-side +~~~~~~~~~ +:class:`FileWatcher` monitors the ``app/`` directory for changes and +triggers a push-and-reload cycle via ``adb push`` (Android) or +``simctl`` file copy (iOS). + +Device-side +~~~~~~~~~~~ +:class:`ModuleReloader` reloads changed Python modules using +``importlib.reload`` and triggers a page re-render. + +Usage (host-side, integrated into ``pn run --hot-reload``):: + + watcher = FileWatcher("app/", on_change=push_files) + watcher.start() +""" + +import importlib +import os +import sys +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +# ====================================================================== +# Host-side file watcher +# ====================================================================== + + +class FileWatcher: + """Watch a directory tree for ``.py`` file changes. + + Parameters + ---------- + watch_dir: + Directory to watch. + on_change: + Called with a list of changed file paths when modifications are detected. + interval: + Polling interval in seconds. + """ + + def __init__(self, watch_dir: str, on_change: Callable[[List[str]], None], interval: float = 1.0) -> None: + self.watch_dir = watch_dir + self.on_change = on_change + self.interval = interval + self._running = False + self._thread: Optional[threading.Thread] = None + self._mtimes: Dict[str, float] = {} + + def start(self) -> None: + """Begin watching in a background daemon thread.""" + self._running = True + self._scan() + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop the watcher.""" + self._running = False + if self._thread is not None: + self._thread.join(timeout=self.interval * 2) + self._thread = None + + def _scan(self) -> List[str]: + changed: List[str] = [] + current_files: set = set() + + for root, _dirs, files in os.walk(self.watch_dir): + for fname in files: + if not fname.endswith(".py"): + continue + fpath = os.path.join(root, fname) + current_files.add(fpath) + try: + mtime = os.path.getmtime(fpath) + except OSError: + continue + if fpath in self._mtimes: + if mtime > self._mtimes[fpath]: + changed.append(fpath) + self._mtimes[fpath] = mtime + + for old in list(self._mtimes): + if old not in current_files: + del self._mtimes[old] + + return changed + + def _loop(self) -> None: + while self._running: + time.sleep(self.interval) + changed = self._scan() + if changed: + try: + self.on_change(changed) + except Exception: + pass + + +# ====================================================================== +# Device-side module reloader +# ====================================================================== + + +class ModuleReloader: + """Reload changed Python modules on device and trigger re-render.""" + + @staticmethod + def reload_module(module_name: str) -> bool: + """Reload a single module by its dotted name. + + Returns ``True`` if the module was found and reloaded successfully. + """ + mod = sys.modules.get(module_name) + if mod is None: + return False + try: + importlib.reload(mod) + return True + except Exception: + return False + + @staticmethod + def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]: + """Convert a file path to a dotted module name relative to *base_dir*.""" + rel = os.path.relpath(file_path, base_dir) if base_dir else file_path + if rel.endswith(".py"): + rel = rel[:-3] + parts = rel.replace(os.sep, ".").split(".") + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) if parts else None + + @staticmethod + def reload_page(page_instance: Any) -> None: + """Force a page re-render after module reload.""" + from .page import _re_render + + if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None: + _re_render(page_instance) diff --git a/src/pythonnative/material_bottom_navigation_view.py b/src/pythonnative/material_bottom_navigation_view.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pythonnative/material_toolbar.py b/src/pythonnative/material_toolbar.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pythonnative/native_modules/__init__.py b/src/pythonnative/native_modules/__init__.py new file mode 100644 index 0000000..fbefc86 --- /dev/null +++ b/src/pythonnative/native_modules/__init__.py @@ -0,0 +1,19 @@ +"""Native API modules for device capabilities. + +Provides cross-platform Python interfaces to common device APIs: + +- :mod:`~.camera` — photo capture and gallery picking +- :mod:`~.location` — GPS / location services +- :mod:`~.file_system` — app-scoped file I/O +- :mod:`~.notifications` — local push notifications + +Each module auto-detects the platform and calls the appropriate native +APIs via Chaquopy (Android) or rubicon-objc (iOS). +""" + +from .camera import Camera +from .file_system import FileSystem +from .location import Location +from .notifications import Notifications + +__all__ = ["Camera", "FileSystem", "Location", "Notifications"] diff --git a/src/pythonnative/native_modules/camera.py b/src/pythonnative/native_modules/camera.py new file mode 100644 index 0000000..d0ae705 --- /dev/null +++ b/src/pythonnative/native_modules/camera.py @@ -0,0 +1,105 @@ +"""Cross-platform camera access. + +Provides methods for capturing photos and picking images from the gallery. +Uses Android's ``Intent``/``MediaStore`` or iOS's ``UIImagePickerController``. +""" + +from typing import Any, Callable, Optional + +from ..utils import IS_ANDROID + + +class Camera: + """Camera and image picker interface. + + All methods accept an ``on_result`` callback that receives the image + file path (or ``None`` on cancellation). + """ + + @staticmethod + def take_photo(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None: + """Launch the device camera to capture a photo. + + Parameters + ---------- + on_result: + ``(path_or_none) -> None`` called with the saved image path, + or ``None`` if the user cancelled. + """ + if IS_ANDROID: + Camera._android_take_photo(on_result, **options) + else: + Camera._ios_take_photo(on_result, **options) + + @staticmethod + def pick_from_gallery(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None: + """Open the system gallery picker. + + Parameters + ---------- + on_result: + ``(path_or_none) -> None`` called with the selected image path, + or ``None`` if the user cancelled. + """ + if IS_ANDROID: + Camera._android_pick_gallery(on_result, **options) + else: + Camera._ios_pick_gallery(on_result, **options) + + # -- Android implementations ----------------------------------------- + + @staticmethod + def _android_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from java import jclass + + Intent = jclass("android.content.Intent") + MediaStore = jclass("android.provider.MediaStore") + intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + from ..utils import get_android_context + + ctx = get_android_context() + ctx.startActivity(intent) + except Exception: + if on_result: + on_result(None) + + @staticmethod + def _android_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from java import jclass + + Intent = jclass("android.content.Intent") + intent = Intent(Intent.ACTION_PICK) + intent.setType("image/*") + from ..utils import get_android_context + + ctx = get_android_context() + ctx.startActivity(intent) + except Exception: + if on_result: + on_result(None) + + # -- iOS implementations --------------------------------------------- + + @staticmethod + def _ios_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + picker = ObjCClass("UIImagePickerController").alloc().init() + picker.setSourceType_(1) # UIImagePickerControllerSourceTypeCamera + except Exception: + if on_result: + on_result(None) + + @staticmethod + def _ios_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + picker = ObjCClass("UIImagePickerController").alloc().init() + picker.setSourceType_(0) # PhotoLibrary + except Exception: + if on_result: + on_result(None) diff --git a/src/pythonnative/native_modules/file_system.py b/src/pythonnative/native_modules/file_system.py new file mode 100644 index 0000000..8585d47 --- /dev/null +++ b/src/pythonnative/native_modules/file_system.py @@ -0,0 +1,131 @@ +"""Cross-platform file system access. + +Provides helpers for reading and writing files within the app's +sandboxed storage area. +""" + +import os +from typing import Any, Optional + +from ..utils import IS_ANDROID + + +class FileSystem: + """App-scoped file I/O.""" + + @staticmethod + def app_dir() -> str: + """Return the app's writable data directory.""" + if IS_ANDROID: + try: + from ..utils import get_android_context + + return str(get_android_context().getFilesDir().getAbsolutePath()) + except Exception: + pass + else: + try: + from rubicon.objc import ObjCClass + + NSSearchPathForDirectoriesInDomains = ObjCClass( + "NSFileManager" + ).defaultManager.URLsForDirectory_inDomains_ + docs = NSSearchPathForDirectoriesInDomains(9, 1) # NSDocumentDirectory, NSUserDomainMask + if docs and docs.count > 0: + return str(docs.objectAtIndex_(0).path) + except Exception: + pass + return os.path.join(os.path.expanduser("~"), ".pythonnative_data") + + @staticmethod + def read_text(path: str, encoding: str = "utf-8") -> Optional[str]: + """Read a text file relative to :meth:`app_dir` (or an absolute path).""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + with open(full, encoding=encoding) as f: + return f.read() + except OSError: + return None + + @staticmethod + def write_text(path: str, content: str, encoding: str = "utf-8") -> bool: + """Write a text file. Returns ``True`` on success.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w", encoding=encoding) as f: + f.write(content) + return True + except OSError: + return False + + @staticmethod + def exists(path: str) -> bool: + """Check if a file or directory exists.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + return os.path.exists(full) + + @staticmethod + def delete(path: str) -> bool: + """Delete a file. Returns ``True`` on success.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.remove(full) + return True + except OSError: + return False + + @staticmethod + def list_dir(path: str = "") -> list: + """List entries in a directory.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + return os.listdir(full) + except OSError: + return [] + + @staticmethod + def read_bytes(path: str) -> Optional[bytes]: + """Read a binary file.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + with open(full, "rb") as f: + return f.read() + except OSError: + return None + + @staticmethod + def write_bytes(path: str, data: bytes) -> bool: + """Write a binary file. Returns ``True`` on success.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "wb") as f: + f.write(data) + return True + except OSError: + return False + + @staticmethod + def get_size(path: str) -> Optional[int]: + """Return file size in bytes, or ``None`` if not found.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + return os.path.getsize(full) + except OSError: + return None + + @staticmethod + def ensure_dir(path: str) -> bool: + """Create a directory (and parents) if it doesn't exist.""" + full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path) + try: + os.makedirs(full, exist_ok=True) + return True + except OSError: + return False + + @staticmethod + def join(*parts: Any) -> str: + """Join path components.""" + return os.path.join(*[str(p) for p in parts]) diff --git a/src/pythonnative/native_modules/location.py b/src/pythonnative/native_modules/location.py new file mode 100644 index 0000000..8f5a673 --- /dev/null +++ b/src/pythonnative/native_modules/location.py @@ -0,0 +1,61 @@ +"""Cross-platform location / GPS access. + +Provides methods for requesting the current device location. +Uses Android's ``LocationManager`` or iOS's ``CLLocationManager``. +""" + +from typing import Any, Callable, Optional, Tuple + +from ..utils import IS_ANDROID + + +class Location: + """GPS / Location services interface.""" + + @staticmethod + def get_current( + on_result: Optional[Callable[[Optional[Tuple[float, float]]], None]] = None, + **options: Any, + ) -> None: + """Request the current location. + + Parameters + ---------- + on_result: + ``((lat, lon) | None) -> None`` called with coordinates or + ``None`` if location is unavailable. + """ + if IS_ANDROID: + Location._android_get(on_result, **options) + else: + Location._ios_get(on_result, **options) + + @staticmethod + def _android_get(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from java import jclass + + from ..utils import get_android_context + + ctx = get_android_context() + lm = ctx.getSystemService(jclass("android.content.Context").LOCATION_SERVICE) + loc = lm.getLastKnownLocation("gps") + if loc and on_result: + on_result((loc.getLatitude(), loc.getLongitude())) + elif on_result: + on_result(None) + except Exception: + if on_result: + on_result(None) + + @staticmethod + def _ios_get(on_result: Optional[Callable] = None, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + lm = ObjCClass("CLLocationManager").alloc().init() + lm.requestWhenInUseAuthorization() + lm.startUpdatingLocation() + except Exception: + if on_result: + on_result(None) diff --git a/src/pythonnative/native_modules/notifications.py b/src/pythonnative/native_modules/notifications.py new file mode 100644 index 0000000..40711ca --- /dev/null +++ b/src/pythonnative/native_modules/notifications.py @@ -0,0 +1,151 @@ +"""Cross-platform local notifications. + +Provides methods for scheduling and cancelling local push notifications. +Uses Android's ``NotificationManager`` or iOS's ``UNUserNotificationCenter``. +""" + +from typing import Any, Callable, Optional + +from ..utils import IS_ANDROID + + +class Notifications: + """Local notification interface.""" + + @staticmethod + def request_permission(on_result: Optional[Callable[[bool], None]] = None) -> None: + """Request notification permission from the user. + + Parameters + ---------- + on_result: + ``(granted: bool) -> None`` called with the permission result. + """ + if IS_ANDROID: + if on_result: + on_result(True) + else: + Notifications._ios_request_permission(on_result) + + @staticmethod + def schedule( + title: str, + body: str = "", + delay_seconds: float = 0, + identifier: str = "default", + **options: Any, + ) -> None: + """Schedule a local notification. + + Parameters + ---------- + title: + Notification title. + body: + Notification body text. + delay_seconds: + Seconds from now until delivery (0 = immediate). + identifier: + Unique ID for this notification (for cancellation). + """ + if IS_ANDROID: + Notifications._android_schedule(title, body, delay_seconds, identifier, **options) + else: + Notifications._ios_schedule(title, body, delay_seconds, identifier, **options) + + @staticmethod + def cancel(identifier: str = "default") -> None: + """Cancel a pending notification by its identifier.""" + if IS_ANDROID: + Notifications._android_cancel(identifier) + else: + Notifications._ios_cancel(identifier) + + # -- Android --------------------------------------------------------- + + @staticmethod + def _android_schedule(title: str, body: str, delay_seconds: float, identifier: str, **options: Any) -> None: + try: + from java import jclass + + from ..utils import get_android_context + + ctx = get_android_context() + nm = ctx.getSystemService(jclass("android.content.Context").NOTIFICATION_SERVICE) + channel_id = "pn_default" + NotificationChannel = jclass("android.app.NotificationChannel") + channel = NotificationChannel(channel_id, "PythonNative", 3) # IMPORTANCE_DEFAULT + nm.createNotificationChannel(channel) + + Builder = jclass("android.app.Notification$Builder") + builder = Builder(ctx, channel_id) + builder.setContentTitle(title) + builder.setContentText(body) + builder.setSmallIcon(jclass("android.R$drawable").ic_dialog_info) + nm.notify(abs(hash(identifier)) % (2**31), builder.build()) + except Exception: + pass + + @staticmethod + def _android_cancel(identifier: str) -> None: + try: + from java import jclass + + from ..utils import get_android_context + + ctx = get_android_context() + nm = ctx.getSystemService(jclass("android.content.Context").NOTIFICATION_SERVICE) + nm.cancel(abs(hash(identifier)) % (2**31)) + except Exception: + pass + + # -- iOS ------------------------------------------------------------- + + @staticmethod + def _ios_request_permission(on_result: Optional[Callable[[bool], None]] = None) -> None: + try: + from rubicon.objc import ObjCClass + + center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter() + center.requestAuthorizationWithOptions_completionHandler_(0x07, None) + if on_result: + on_result(True) + except Exception: + if on_result: + on_result(False) + + @staticmethod + def _ios_schedule(title: str, body: str, delay_seconds: float, identifier: str, **options: Any) -> None: + try: + from rubicon.objc import ObjCClass + + content = ObjCClass("UNMutableNotificationContent").alloc().init() + content.setTitle_(title) + content.setBody_(body) + + if delay_seconds > 0: + trigger = ObjCClass("UNTimeIntervalNotificationTrigger").triggerWithTimeInterval_repeats_( + delay_seconds, False + ) + else: + trigger = ObjCClass("UNTimeIntervalNotificationTrigger").triggerWithTimeInterval_repeats_(1, False) + + request = ObjCClass("UNNotificationRequest").requestWithIdentifier_content_trigger_( + identifier, content, trigger + ) + center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter() + center.addNotificationRequest_withCompletionHandler_(request, None) + except Exception: + pass + + @staticmethod + def _ios_cancel(identifier: str) -> None: + try: + from rubicon.objc import ObjCClass + + center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter() + NSArray = ObjCClass("NSArray") + arr = NSArray.arrayWithObject_(identifier) + center.removePendingNotificationRequestsWithIdentifiers_(arr) + except Exception: + pass diff --git a/src/pythonnative/native_views.py b/src/pythonnative/native_views.py index 2efab0e..ff6435f 100644 --- a/src/pythonnative/native_views.py +++ b/src/pythonnative/native_views.py @@ -123,6 +123,21 @@ def _resolve_padding( return (0, 0, 0, 0) +_LAYOUT_KEYS = frozenset( + { + "width", + "height", + "flex", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + } +) + + # ====================================================================== # Platform handler registration (lazy imports inside functions) # ====================================================================== @@ -142,15 +157,52 @@ def _density() -> float: def _dp(value: float) -> int: return int(value * _density()) + def _apply_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout properties to an Android view.""" + lp = view.getLayoutParams() + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + needs_set = False + + if lp is None: + lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT) + needs_set = True + + if "width" in props and props["width"] is not None: + lp.width = _dp(float(props["width"])) + needs_set = True + if "height" in props and props["height"] is not None: + lp.height = _dp(float(props["height"])) + needs_set = True + if "flex" in props and props["flex"] is not None: + try: + lp.weight = float(props["flex"]) + needs_set = True + except Exception: + pass + if "margin" in props and props["margin"] is not None: + left, top, right, bottom = _resolve_padding(props["margin"]) + try: + lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom)) + needs_set = True + except Exception: + pass + + if needs_set: + view.setLayoutParams(lp) + # ---- Text ----------------------------------------------------------- class AndroidTextHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: tv = jclass("android.widget.TextView")(_ctx()) self._apply(tv, props) + _apply_layout(tv, props) return tv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) def _apply(self, tv: Any, props: Dict[str, Any]) -> None: if "text" in props: @@ -175,10 +227,13 @@ class AndroidButtonHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: btn = jclass("android.widget.Button")(_ctx()) self._apply(btn, props) + _apply_layout(btn, props) return btn def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) def _apply(self, btn: Any, props: Dict[str, Any]) -> None: if "title" in props: @@ -213,10 +268,13 @@ def create(self, props: Dict[str, Any]) -> Any: ll = jclass("android.widget.LinearLayout")(_ctx()) ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) self._apply(ll, props) + _apply_layout(ll, props) return ll def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) def _apply(self, ll: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -259,10 +317,13 @@ def create(self, props: Dict[str, Any]) -> Any: ll = jclass("android.widget.LinearLayout")(_ctx()) ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) self._apply(ll, props) + _apply_layout(ll, props) return ll def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) def _apply(self, ll: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -303,11 +364,14 @@ def create(self, props: Dict[str, Any]) -> Any: sv = jclass("android.widget.ScrollView")(_ctx()) if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor(parse_color_int(props["background_color"])) + _apply_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: if "background_color" in changed and changed["background_color"] is not None: native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) def add_child(self, parent: Any, child: Any) -> None: parent.addView(child) @@ -315,15 +379,18 @@ def add_child(self, parent: Any, child: Any) -> None: def remove_child(self, parent: Any, child: Any) -> None: parent.removeView(child) - # ---- TextInput (EditText) ------------------------------------------- + # ---- TextInput (EditText) with on_change ---------------------------- class AndroidTextInputHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: et = jclass("android.widget.EditText")(_ctx()) self._apply(et, props) + _apply_layout(et, props) return et def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) def _apply(self, et: Any, props: Dict[str, Any]) -> None: if "value" in props: @@ -339,26 +406,113 @@ def _apply(self, et: Any, props: Dict[str, Any]) -> None: if "secure" in props and props["secure"]: InputType = jclass("android.text.InputType") et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + if "on_change" in props: + cb = props["on_change"] + if cb is not None: + TextWatcher = jclass("android.text.TextWatcher") + + class ChangeProxy(dynamic_proxy(TextWatcher)): + def __init__(self, callback: Callable[[str], None]) -> None: + super().__init__() + self.callback = callback + + def afterTextChanged(self, s: Any) -> None: + self.callback(str(s)) + + def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None: + pass + + def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None: + pass + + et.addTextChangedListener(ChangeProxy(cb)) - # ---- Image ---------------------------------------------------------- + # ---- Image (with URL loading) --------------------------------------- class AndroidImageHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: iv = jclass("android.widget.ImageView")(_ctx()) self._apply(iv, props) + _apply_layout(iv, props) return iv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) def _apply(self, iv: Any, props: Dict[str, Any]) -> None: if "background_color" in props and props["background_color"] is not None: iv.setBackgroundColor(parse_color_int(props["background_color"])) - - # ---- Switch --------------------------------------------------------- + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + ScaleType = jclass("android.widget.ImageView$ScaleType") + mapping = { + "cover": ScaleType.CENTER_CROP, + "contain": ScaleType.FIT_CENTER, + "stretch": ScaleType.FIT_XY, + "center": ScaleType.CENTER, + } + st = mapping.get(props["scale_type"]) + if st: + iv.setScaleType(st) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + Thread = jclass("java.lang.Thread") + Runnable = jclass("java.lang.Runnable") + URL = jclass("java.net.URL") + BitmapFactory = jclass("android.graphics.BitmapFactory") + Handler = jclass("android.os.Handler") + Looper = jclass("android.os.Looper") + handler = Handler(Looper.getMainLooper()) + + class LoadTask(dynamic_proxy(Runnable)): + def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None: + super().__init__() + self.image_view = image_view + self.url_str = url_str + self.main_handler = main_handler + + def run(self) -> None: + try: + url = URL(self.url_str) + stream = url.openStream() + bitmap = BitmapFactory.decodeStream(stream) + stream.close() + + class SetImage(dynamic_proxy(Runnable)): + def __init__(self, view: Any, bmp: Any) -> None: + super().__init__() + self.view = view + self.bmp = bmp + + def run(self) -> None: + self.view.setImageBitmap(self.bmp) + + self.main_handler.post(SetImage(self.image_view, bitmap)) + except Exception: + pass + + Thread(LoadTask(iv, source, handler)).start() + else: + ctx = _ctx() + res = ctx.getResources() + pkg = ctx.getPackageName() + res_name = source.rsplit(".", 1)[0] if "." in source else source + res_id = res.getIdentifier(res_name, "drawable", pkg) + if res_id != 0: + iv.setImageResource(res_id) + except Exception: + pass + + # ---- Switch (with on_change) ---------------------------------------- class AndroidSwitchHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sw = jclass("android.widget.Switch")(_ctx()) self._apply(sw, props) + _apply_layout(sw, props) return sw def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -387,6 +541,7 @@ def create(self, props: Dict[str, Any]) -> Any: pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) pb.setMax(1000) self._apply(pb, props) + _apply_layout(pb, props) return pb def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -402,6 +557,7 @@ def create(self, props: Dict[str, Any]) -> Any: pb = jclass("android.widget.ProgressBar")(_ctx()) if not props.get("animating", True): pb.setVisibility(jclass("android.view.View").GONE) + _apply_layout(pb, props) return pb def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -415,6 +571,7 @@ def create(self, props: Dict[str, Any]) -> Any: wv = jclass("android.webkit.WebView")(_ctx()) if "url" in props and props["url"]: wv.loadUrl(str(props["url"])) + _apply_layout(wv, props) return wv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -429,6 +586,12 @@ def create(self, props: Dict[str, Any]) -> Any: px = _dp(float(props["size"])) lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) v.setLayoutParams(lp) + if "flex" in props and props["flex"] is not None: + lp = v.getLayoutParams() + if lp is None: + lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0) + lp.weight = float(props["flex"]) + v.setLayoutParams(lp) return v def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -437,6 +600,156 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) native_view.setLayoutParams(lp) + # ---- View (generic container FrameLayout) --------------------------- + class AndroidViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + if "background_color" in props and props["background_color"] is not None: + fl.setBackgroundColor(parse_color_int(props["background_color"])) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + _apply_layout(fl, props) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + if "padding" in changed: + left, top, right, bottom = _resolve_padding(changed["padding"]) + native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + if changed.keys() & _LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + # ---- SafeAreaView (FrameLayout with fitsSystemWindows) --------------- + class AndroidSafeAreaViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setFitsSystemWindows(True) + if "background_color" in props and props["background_color"] is not None: + fl.setBackgroundColor(parse_color_int(props["background_color"])) + if "padding" in props: + left, top, right, bottom = _resolve_padding(props["padding"]) + fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + # ---- Modal (AlertDialog) ------------------------------------------- + class AndroidModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + placeholder = jclass("android.view.View")(_ctx()) + placeholder.setVisibility(jclass("android.view.View").GONE) + return placeholder + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + pass + + # ---- Slider (SeekBar) ----------------------------------------------- + class AndroidSliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sb = jclass("android.widget.SeekBar")(_ctx()) + sb.setMax(1000) + self._apply(sb, props) + _apply_layout(sb, props) + return sb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sb: Any, props: Dict[str, Any]) -> None: + min_val = float(props.get("min_value", 0)) + max_val = float(props.get("max_value", 1)) + rng = max_val - min_val if max_val != min_val else 1 + if "value" in props: + normalized = (float(props["value"]) - min_val) / rng + sb.setProgress(int(normalized * 1000)) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)): + def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None: + super().__init__() + self.callback = callback + self.mn = mn + self.rn = rn + + def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None: + if fromUser: + self.callback(self.mn + (progress / 1000.0) * self.rn) + + def onStartTrackingTouch(self, seekBar: Any) -> None: + pass + + def onStopTrackingTouch(self, seekBar: Any) -> None: + pass + + sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) + + # ---- Pressable (FrameLayout with click listener) -------------------- + class AndroidPressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setClickable(True) + self._apply(fl, props) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, fl: Any, props: Dict[str, Any]) -> None: + if "on_press" in props and props["on_press"] is not None: + cb = props["on_press"] + + class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + fl.setOnClickListener(PressProxy(cb)) + if "on_long_press" in props and props["on_long_press"] is not None: + cb = props["on_long_press"] + + class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onLongClick(self, view: Any) -> bool: + self.callback() + return True + + fl.setOnLongClickListener(LongPressProxy(cb)) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + registry.register("Text", AndroidTextHandler()) registry.register("Button", AndroidButtonHandler()) registry.register("Column", AndroidColumnHandler()) @@ -449,6 +762,11 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: registry.register("ActivityIndicator", AndroidActivityIndicatorHandler()) registry.register("WebView", AndroidWebViewHandler()) registry.register("Spacer", AndroidSpacerHandler()) + registry.register("View", AndroidViewHandler()) + registry.register("SafeAreaView", AndroidSafeAreaViewHandler()) + registry.register("Modal", AndroidModalHandler()) + registry.register("Slider", AndroidSliderHandler()) + registry.register("Pressable", AndroidPressableHandler()) def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 @@ -468,15 +786,37 @@ def _uicolor(color: Any) -> Any: b = (argb & 0xFF) / 255.0 return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout constraints to an iOS view.""" + if "width" in props and props["width"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 7: # NSLayoutAttributeWidth + c.setActive_(False) + view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True) + except Exception: + pass + if "height" in props and props["height"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 8: # NSLayoutAttributeHeight + c.setActive_(False) + view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) + except Exception: + pass + # ---- Text ----------------------------------------------------------- class IOSTextHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: label = ObjCClass("UILabel").alloc().init() self._apply(label, props) + _apply_ios_layout(label, props) return label def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) def _apply(self, label: Any, props: Dict[str, Any]) -> None: if "text" in props: @@ -501,10 +841,6 @@ def _apply(self, label: Any, props: Dict[str, Any]) -> None: # ---- Button --------------------------------------------------------- - # btn id(ObjCInstance) -> _PNButtonTarget. Keeps a strong ref to - # each handler (preventing GC) and lets us swap the callback on - # re-render without calling removeTarget/addTarget (which crashes - # due to rubicon-objc wrapper lifecycle issues). _pn_btn_handler_map: dict = {} class _PNButtonTarget(NSObject): # type: ignore[valid-type] @@ -515,9 +851,6 @@ def onTap_(self, sender: object) -> None: if self._callback is not None: self._callback() - # Strong refs to retained UIButton wrappers so the ObjCInstance - # (and its prevent-deallocation retain) stays alive for the - # lifetime of the app. _pn_retained_views: list = [] class IOSButtonHandler(ViewHandler): @@ -528,10 +861,13 @@ def create(self, props: Dict[str, Any]) -> Any: _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) btn.setTitleColor_forState_(_ios_blue, 0) self._apply(btn, props) + _apply_ios_layout(btn, props) return btn def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) def _apply(self, btn: Any, props: Dict[str, Any]) -> None: if "title" in props: @@ -563,10 +899,13 @@ def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) sv.setAxis_(1) # vertical self._apply(sv, props) + _apply_ios_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) def _apply(self, sv: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -600,10 +939,13 @@ def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) sv.setAxis_(0) # horizontal self._apply(sv, props) + _apply_ios_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) def _apply(self, sv: Any, props: Dict[str, Any]) -> None: if "spacing" in props and props["spacing"]: @@ -630,6 +972,7 @@ def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIScrollView").alloc().init() if "background_color" in props and props["background_color"] is not None: sv.setBackgroundColor_(_uicolor(props["background_color"])) + _apply_ios_layout(sv, props) return sv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -650,16 +993,33 @@ def add_child(self, parent: Any, child: Any) -> None: def remove_child(self, parent: Any, child: Any) -> None: child.removeFromSuperview() - # ---- TextInput (UITextField) ---------------------------------------- + # ---- TextInput (UITextField with on_change) ------------------------- + _pn_tf_handler_map: dict = {} + + class _PNTextFieldTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[str], None]] = None + + @objc_method + def onEdit_(self, sender: object) -> None: + if self._callback is not None: + try: + text = str(sender.text) if sender and hasattr(sender, "text") else "" + self._callback(text) + except Exception: + pass + class IOSTextInputHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: tf = ObjCClass("UITextField").alloc().init() tf.setBorderStyle_(2) # RoundedRect self._apply(tf, props) + _apply_ios_layout(tf, props) return tf def update(self, native_view: Any, changed: Dict[str, Any]) -> None: self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) def _apply(self, tf: Any, props: Dict[str, Any]) -> None: if "value" in props: @@ -674,20 +1034,72 @@ def _apply(self, tf: Any, props: Dict[str, Any]) -> None: tf.setBackgroundColor_(_uicolor(props["background_color"])) if "secure" in props and props["secure"]: tf.setSecureTextEntry_(True) + if "on_change" in props: + existing = _pn_tf_handler_map.get(id(tf)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNTextFieldTarget.new() + handler._callback = props["on_change"] + _pn_tf_handler_map[id(tf)] = handler + tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17) - # ---- Image ---------------------------------------------------------- + # ---- Image (with URL loading) --------------------------------------- class IOSImageHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: iv = ObjCClass("UIImageView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - iv.setBackgroundColor_(_uicolor(props["background_color"])) + self._apply(iv, props) + _apply_ios_layout(iv, props) return iv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + self._apply(native_view, changed) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor_(_uicolor(props["background_color"])) + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4} + iv.setContentMode_(mapping.get(props["scale_type"], 1)) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + NSURL = ObjCClass("NSURL") + NSData = ObjCClass("NSData") + UIImage = ObjCClass("UIImage") + url = NSURL.URLWithString_(source) + data = NSData.dataWithContentsOfURL_(url) + if data: + image = UIImage.imageWithData_(data) + if image: + iv.setImage_(image) + else: + UIImage = ObjCClass("UIImage") + image = UIImage.imageNamed_(source) + if image: + iv.setImage_(image) + except Exception: + pass + + # ---- Switch (with on_change) ---------------------------------------- + _pn_switch_handler_map: dict = {} + + class _PNSwitchTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[bool], None]] = None + + @objc_method + def onToggle_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(bool(sender.isOn())) + except Exception: + pass - # ---- Switch --------------------------------------------------------- class IOSSwitchHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sw = ObjCClass("UISwitch").alloc().init() @@ -700,6 +1112,15 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: def _apply(self, sw: Any, props: Dict[str, Any]) -> None: if "value" in props: sw.setOn_animated_(bool(props["value"]), False) + if "on_change" in props: + existing = _pn_switch_handler_map.get(id(sw)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSwitchTarget.new() + handler._callback = props["on_change"] + _pn_switch_handler_map[id(sw)] = handler + sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12) # ---- ProgressBar (UIProgressView) ----------------------------------- class IOSProgressBarHandler(ViewHandler): @@ -707,6 +1128,7 @@ def create(self, props: Dict[str, Any]) -> Any: pv = ObjCClass("UIProgressView").alloc().init() if "value" in props: pv.setProgress_(float(props["value"])) + _apply_ios_layout(pv, props) return pv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -737,6 +1159,7 @@ def create(self, props: Dict[str, Any]) -> Any: NSURLRequest = ObjCClass("NSURLRequest") url_obj = NSURL.URLWithString_(str(props["url"])) wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + _apply_ios_layout(wv, props) return wv def update(self, native_view: Any, changed: Dict[str, Any]) -> None: @@ -760,6 +1183,112 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: size = float(changed["size"]) native_view.setFrame_(((0, 0), (size, size))) + # ---- View (generic UIView) ----------------------------------------- + class IOSViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + v.setBackgroundColor_(_uicolor(props["background_color"])) + _apply_ios_layout(v, props) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + if changed.keys() & _LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + # ---- SafeAreaView --------------------------------------------------- + class IOSSafeAreaViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + v.setBackgroundColor_(_uicolor(props["background_color"])) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + # ---- Modal ---------------------------------------------------------- + class IOSModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setHidden_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + # ---- Slider (UISlider) ---------------------------------------------- + _pn_slider_handler_map: dict = {} + + class _PNSliderTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[float], None]] = None + + @objc_method + def onSlide_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(float(sender.value)) + except Exception: + pass + + class IOSSliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sl = ObjCClass("UISlider").alloc().init() + self._apply(sl, props) + _apply_ios_layout(sl, props) + return sl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sl: Any, props: Dict[str, Any]) -> None: + if "min_value" in props: + sl.setMinimumValue_(float(props["min_value"])) + if "max_value" in props: + sl.setMaximumValue_(float(props["max_value"])) + if "value" in props: + sl.setValue_(float(props["value"])) + if "on_change" in props: + existing = _pn_slider_handler_map.get(id(sl)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSliderTarget.new() + handler._callback = props["on_change"] + _pn_slider_handler_map[id(sl)] = handler + sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) + + # ---- Pressable (UIView with tap gesture) ---------------------------- + class IOSPressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setUserInteractionEnabled_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + registry.register("Text", IOSTextHandler()) registry.register("Button", IOSButtonHandler()) registry.register("Column", IOSColumnHandler()) @@ -772,6 +1301,11 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: registry.register("ActivityIndicator", IOSActivityIndicatorHandler()) registry.register("WebView", IOSWebViewHandler()) registry.register("Spacer", IOSSpacerHandler()) + registry.register("View", IOSViewHandler()) + registry.register("SafeAreaView", IOSSafeAreaViewHandler()) + registry.register("Modal", IOSModalHandler()) + registry.register("Slider", IOSSliderHandler()) + registry.register("Pressable", IOSPressableHandler()) # ====================================================================== diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index 421bed4..942db1d 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -122,6 +122,7 @@ def _on_create(page: Any) -> None: from .reconciler import Reconciler page._reconciler = Reconciler(get_registry()) + page._reconciler._page_re_render = lambda: _re_render(page) element = page.render() page._root_native_view = page._reconciler.mount(element) page._attach_root(page._root_native_view) diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index b7aa5bc..8f2c5ab 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -3,6 +3,16 @@ Maintains a tree of :class:`VNode` objects (each wrapping a native view) and diffs incoming :class:`Element` trees to apply the minimal set of native mutations. + +Supports: + +- **Native elements** (type is a string like ``"Text"``). +- **Function components** (type is a callable decorated with + ``@component``). Their hook state is preserved across renders. +- **Provider elements** (type ``"__Provider__"``), which push/pop + context values during tree traversal. +- **Key-based child reconciliation** for stable identity across + re-renders. """ from typing import Any, List, Optional @@ -13,12 +23,14 @@ class VNode: """A mounted element paired with its native view and child VNodes.""" - __slots__ = ("element", "native_view", "children") + __slots__ = ("element", "native_view", "children", "hook_state", "_rendered") def __init__(self, element: Element, native_view: Any, children: List["VNode"]) -> None: self.element = element self.native_view = native_view self.children = children + self.hook_state: Any = None + self._rendered: Optional[Element] = None class Reconciler: @@ -35,6 +47,7 @@ class Reconciler: def __init__(self, backend: Any) -> None: self.backend = backend self._tree: Optional[VNode] = None + self._page_re_render: Optional[Any] = None # ------------------------------------------------------------------ # Public API @@ -62,6 +75,37 @@ def reconcile(self, new_element: Element) -> Any: # ------------------------------------------------------------------ def _create_tree(self, element: Element) -> VNode: + # Provider: push context, create child, pop context + if element.type == "__Provider__": + context = element.props["__context__"] + context._stack.append(element.props["__value__"]) + try: + child_node = self._create_tree(element.children[0]) if element.children else None + finally: + context._stack.pop() + native_view = child_node.native_view if child_node else None + children = [child_node] if child_node else [] + return VNode(element, native_view, children) + + # Function component: call with hook context + if callable(element.type): + from .hooks import HookState, _set_hook_state + + hook_state = HookState() + hook_state._trigger_render = self._page_re_render + _set_hook_state(hook_state) + try: + rendered = element.type(**element.props) + finally: + _set_hook_state(None) + + child_node = self._create_tree(rendered) + vnode = VNode(element, child_node.native_view, [child_node]) + vnode.hook_state = hook_state + vnode._rendered = rendered + return vnode + + # Native element native_view = self.backend.create_view(element.type, element.props) children: List[VNode] = [] for child_el in element.children: @@ -71,11 +115,58 @@ def _create_tree(self, element: Element) -> VNode: return VNode(element, native_view, children) def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: - if old.element.type != new_el.type: + if not self._same_type(old.element, new_el): new_node = self._create_tree(new_el) self._destroy_tree(old) return new_node + # Provider + if new_el.type == "__Provider__": + context = new_el.props["__context__"] + context._stack.append(new_el.props["__value__"]) + try: + if old.children and new_el.children: + child = self._reconcile_node(old.children[0], new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + elif new_el.children: + child = self._create_tree(new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + finally: + context._stack.pop() + old.element = new_el + return old + + # Function component + if callable(new_el.type): + from .hooks import _set_hook_state + + hook_state = old.hook_state + if hook_state is None: + from .hooks import HookState + + hook_state = HookState() + hook_state.reset_index() + hook_state._trigger_render = self._page_re_render + _set_hook_state(hook_state) + try: + rendered = new_el.type(**new_el.props) + finally: + _set_hook_state(None) + + if old.children: + child = self._reconcile_node(old.children[0], rendered) + else: + child = self._create_tree(rendered) + old.children = [child] + old.native_view = child.native_view + old.element = new_el + old.hook_state = hook_state + old._rendered = rendered + return old + + # Native element changed = self._diff_props(old.element.props, new_el.props) if changed: self.backend.update_view(old.native_view, old.element.type, changed) @@ -86,44 +177,86 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None: old_children = parent.children + parent_type = parent.element.type + is_native = isinstance(parent_type, str) and parent_type != "__Provider__" + + old_by_key: dict = {} + old_unkeyed: list = [] + for child in old_children: + if child.element.key is not None: + old_by_key[child.element.key] = child + else: + old_unkeyed.append(child) + new_child_nodes: List[VNode] = [] - max_len = max(len(old_children), len(new_children)) - - for i in range(max_len): - if i >= len(new_children): - self.backend.remove_child(parent.native_view, old_children[i].native_view, parent.element.type) - self._destroy_tree(old_children[i]) - elif i >= len(old_children): - node = self._create_tree(new_children[i]) - self.backend.add_child(parent.native_view, node.native_view, parent.element.type) + used_keyed: set = set() + unkeyed_iter = iter(old_unkeyed) + + for i, new_el in enumerate(new_children): + matched: Optional[VNode] = None + + if new_el.key is not None and new_el.key in old_by_key: + matched = old_by_key[new_el.key] + used_keyed.add(new_el.key) + elif new_el.key is None: + matched = next(unkeyed_iter, None) + + if matched is None: + node = self._create_tree(new_el) + if is_native: + self.backend.add_child(parent.native_view, node.native_view, parent_type) + new_child_nodes.append(node) + elif not self._same_type(matched.element, new_el): + if is_native: + self.backend.remove_child(parent.native_view, matched.native_view, parent_type) + self._destroy_tree(matched) + node = self._create_tree(new_el) + if is_native: + self.backend.insert_child(parent.native_view, node.native_view, parent_type, i) new_child_nodes.append(node) else: - if old_children[i].element.type != new_children[i].type: - self.backend.remove_child(parent.native_view, old_children[i].native_view, parent.element.type) - self._destroy_tree(old_children[i]) - node = self._create_tree(new_children[i]) - self.backend.insert_child(parent.native_view, node.native_view, parent.element.type, i) - new_child_nodes.append(node) - else: - updated = self._reconcile_node(old_children[i], new_children[i]) - new_child_nodes.append(updated) + updated = self._reconcile_node(matched, new_el) + new_child_nodes.append(updated) + + # Destroy unused old nodes + for key, node in old_by_key.items(): + if key not in used_keyed: + if is_native: + self.backend.remove_child(parent.native_view, node.native_view, parent_type) + self._destroy_tree(node) + for node in unkeyed_iter: + if is_native: + self.backend.remove_child(parent.native_view, node.native_view, parent_type) + self._destroy_tree(node) parent.children = new_child_nodes def _destroy_tree(self, node: VNode) -> None: + if node.hook_state is not None: + node.hook_state.cleanup_all_effects() for child in node.children: self._destroy_tree(child) node.children = [] + @staticmethod + def _same_type(old_el: Element, new_el: Element) -> bool: + if isinstance(old_el.type, str): + return old_el.type == new_el.type + return old_el.type is new_el.type + @staticmethod def _diff_props(old: dict, new: dict) -> dict: """Return props that changed (callables always count as changed).""" changed = {} for key, new_val in new.items(): + if key.startswith("__"): + continue old_val = old.get(key) if callable(new_val) or old_val != new_val: changed[key] = new_val for key in old: + if key.startswith("__"): + continue if key not in new: changed[key] = None return changed diff --git a/src/pythonnative/style.py b/src/pythonnative/style.py new file mode 100644 index 0000000..c8df328 --- /dev/null +++ b/src/pythonnative/style.py @@ -0,0 +1,115 @@ +"""StyleSheet and theming support. + +Provides a :class:`StyleSheet` helper for creating and composing +reusable style dictionaries, plus a built-in theme context. + +Usage:: + + import pythonnative as pn + + styles = pn.StyleSheet.create( + title={"font_size": 24, "bold": True, "color": "#333"}, + container={"padding": 16, "spacing": 12}, + ) + + pn.Text("Hello", **styles["title"]) + pn.Column(..., **styles["container"]) +""" + +from typing import Any, Dict + +from .hooks import Context, create_context + +# ====================================================================== +# StyleSheet +# ====================================================================== + +_StyleDict = Dict[str, Any] + + +class StyleSheet: + """Utility for creating and composing style dictionaries.""" + + @staticmethod + def create(**named_styles: _StyleDict) -> Dict[str, _StyleDict]: + """Create a set of named styles. + + Each keyword argument is a style name mapping to a dict of + property values:: + + styles = StyleSheet.create( + heading={"font_size": 28, "bold": True}, + body={"font_size": 16}, + ) + """ + return {name: dict(props) for name, props in named_styles.items()} + + @staticmethod + def compose(*styles: _StyleDict) -> _StyleDict: + """Merge multiple style dicts, later values overriding earlier ones.""" + merged: _StyleDict = {} + for style in styles: + if style: + merged.update(style) + return merged + + @staticmethod + def flatten(styles: Any) -> _StyleDict: + """Flatten a style or list of styles into a single dict. + + Accepts a single dict, a list of dicts, or ``None``. + """ + if styles is None: + return {} + if isinstance(styles, dict): + return dict(styles) + result: _StyleDict = {} + for s in styles: + if s: + result.update(s) + return result + + +# ====================================================================== +# Theming +# ====================================================================== + +DEFAULT_LIGHT_THEME: _StyleDict = { + "primary_color": "#007AFF", + "secondary_color": "#5856D6", + "background_color": "#FFFFFF", + "surface_color": "#F2F2F7", + "text_color": "#000000", + "text_secondary_color": "#8E8E93", + "error_color": "#FF3B30", + "success_color": "#34C759", + "warning_color": "#FF9500", + "font_size": 16, + "font_size_small": 13, + "font_size_large": 20, + "font_size_title": 28, + "spacing": 8, + "spacing_large": 16, + "border_radius": 8, +} + +DEFAULT_DARK_THEME: _StyleDict = { + "primary_color": "#0A84FF", + "secondary_color": "#5E5CE6", + "background_color": "#000000", + "surface_color": "#1C1C1E", + "text_color": "#FFFFFF", + "text_secondary_color": "#8E8E93", + "error_color": "#FF453A", + "success_color": "#30D158", + "warning_color": "#FF9F0A", + "font_size": 16, + "font_size_small": 13, + "font_size_large": 20, + "font_size_title": 28, + "spacing": 8, + "spacing_large": 16, + "border_radius": 8, +} + +ThemeContext: Context = create_context(DEFAULT_LIGHT_THEME) diff --git a/tests/test_components.py b/tests/test_components.py index 8ff7393..353adc3 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -4,14 +4,20 @@ ActivityIndicator, Button, Column, + FlatList, Image, + Modal, + Pressable, ProgressBar, Row, + SafeAreaView, ScrollView, + Slider, Spacer, Switch, Text, TextInput, + View, WebView, ) @@ -42,6 +48,15 @@ def test_text_none_props_excluded() -> None: assert "color" not in el.props +def test_text_layout_props() -> None: + el = Text("Hi", width=100, height=50, flex=1, margin=8, align_self="center") + assert el.props["width"] == 100 + assert el.props["height"] == 50 + assert el.props["flex"] == 1 + assert el.props["margin"] == 8 + assert el.props["align_self"] == "center" + + # --------------------------------------------------------------------------- # Button # --------------------------------------------------------------------------- @@ -93,6 +108,12 @@ def test_column_no_spacing_omitted() -> None: assert "spacing" not in el.props +def test_column_layout_props() -> None: + el = Column(flex=2, margin={"horizontal": 8}) + assert el.props["flex"] == 2 + assert el.props["margin"] == {"horizontal": 8} + + # --------------------------------------------------------------------------- # ScrollView # --------------------------------------------------------------------------- @@ -192,3 +213,88 @@ def test_key_propagation() -> None: def test_column_key() -> None: el = Column(key="col-1") assert el.key == "col-1" + + +# --------------------------------------------------------------------------- +# New components +# --------------------------------------------------------------------------- + + +def test_view_container() -> None: + child = Text("inside") + el = View(child, background_color="#FFF", padding=8, width=200) + assert el.type == "View" + assert len(el.children) == 1 + assert el.props["background_color"] == "#FFF" + assert el.props["padding"] == 8 + assert el.props["width"] == 200 + + +def test_safe_area_view() -> None: + el = SafeAreaView(Text("safe"), background_color="#000") + assert el.type == "SafeAreaView" + assert len(el.children) == 1 + + +def test_modal() -> None: + cb = lambda: None # noqa: E731 + el = Modal(Text("content"), visible=True, on_dismiss=cb, title="Alert") + assert el.type == "Modal" + assert el.props["visible"] is True + assert el.props["on_dismiss"] is cb + assert el.props["title"] == "Alert" + assert len(el.children) == 1 + + +def test_slider() -> None: + cb = lambda v: None # noqa: E731 + el = Slider(value=0.5, min_value=0, max_value=10, on_change=cb) + assert el.type == "Slider" + assert el.props["value"] == 0.5 + assert el.props["min_value"] == 0 + assert el.props["max_value"] == 10 + assert el.props["on_change"] is cb + + +def test_pressable() -> None: + cb = lambda: None # noqa: E731 + child = Text("tap me") + el = Pressable(child, on_press=cb) + assert el.type == "Pressable" + assert el.props["on_press"] is cb + assert len(el.children) == 1 + + +def test_flat_list_basic() -> None: + el = FlatList( + data=["a", "b", "c"], + render_item=lambda item, i: Text(item), + ) + assert el.type == "ScrollView" + assert len(el.children) == 1 + inner = el.children[0] + assert inner.type == "Column" + assert len(inner.children) == 3 + assert inner.children[0].props["text"] == "a" + + +def test_flat_list_with_keys() -> None: + el = FlatList( + data=[{"id": "x", "name": "X"}, {"id": "y", "name": "Y"}], + render_item=lambda item, i: Text(item["name"]), + key_extractor=lambda item, i: item["id"], + ) + inner = el.children[0] + assert inner.children[0].key == "x" + assert inner.children[1].key == "y" + + +def test_flat_list_empty() -> None: + el = FlatList(data=[], render_item=lambda item, i: Text(str(item))) + inner = el.children[0] + assert len(inner.children) == 0 + + +def test_spacer_flex() -> None: + el = Spacer(flex=1) + assert el.props["flex"] == 1 diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..ca7520a --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,433 @@ +"""Unit tests for function components and hooks.""" + +from typing import Any, Dict, List + +from pythonnative.element import Element +from pythonnative.hooks import ( + HookState, + Provider, + _set_hook_state, + component, + create_context, + use_callback, + use_context, + use_effect, + use_memo, + use_ref, + use_state, +) +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend (shared with test_reconciler) +# ====================================================================== + + +class MockView: + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + +class MockBackend: + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, view: MockView, type_name: str, changed: Dict[str, Any]) -> None: + view.props.update(changed) + self.ops.append(("update", type_name, view.id, tuple(sorted(changed.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# use_state +# ====================================================================== + + +def test_use_state_returns_initial_value() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + val, setter = use_state(42) + assert val == 42 + finally: + _set_hook_state(None) + + +def test_use_state_lazy_initialiser() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + val, _ = use_state(lambda: 99) + assert val == 99 + finally: + _set_hook_state(None) + + +def test_use_state_setter_triggers_render() -> None: + renders = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + val, setter = use_state(0) + setter(1) + assert len(renders) == 1 + assert ctx.states[0] == 1 + finally: + _set_hook_state(None) + + +def test_use_state_setter_functional_update() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + _, setter = use_state(10) + _set_hook_state(None) + setter(lambda prev: prev + 5) + assert ctx.states[0] == 15 + finally: + _set_hook_state(None) + + +# ====================================================================== +# use_effect +# ====================================================================== + + +def test_use_effect_runs_on_mount() -> None: + calls: list = [] + ctx = HookState() + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("mounted"), []) + assert calls == ["mounted"] + finally: + _set_hook_state(None) + + +def test_use_effect_cleanup_on_rerun() -> None: + cleanups: list = [] + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(lambda: cleanups.append, None) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + use_effect(lambda: cleanups.append, None) + finally: + _set_hook_state(None) + + +def test_use_effect_skips_with_same_deps() -> None: + calls: list = [] + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("run"), [1, 2]) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + use_effect(lambda: calls.append("run"), [1, 2]) + finally: + _set_hook_state(None) + + assert calls == ["run"] + + +# ====================================================================== +# use_memo / use_callback +# ====================================================================== + + +def test_use_memo_caches() -> None: + calls: list = [] + ctx = HookState() + + def factory_a() -> int: + calls.append(1) + return 42 + + def factory_b() -> int: + calls.append(1) + return 99 + + _set_hook_state(ctx) + try: + val1 = use_memo(factory_a, [1]) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + val2 = use_memo(factory_b, [1]) + finally: + _set_hook_state(None) + + assert val1 == 42 + assert val2 == 42 + assert len(calls) == 1 + + +def test_use_memo_recomputes_on_dep_change() -> None: + ctx = HookState() + + _set_hook_state(ctx) + try: + val1 = use_memo(lambda: "first", ["a"]) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + val2 = use_memo(lambda: "second", ["b"]) + finally: + _set_hook_state(None) + + assert val1 == "first" + assert val2 == "second" + + +def test_use_callback_returns_stable_reference() -> None: + ctx = HookState() + fn = lambda: None # noqa: E731 + + _set_hook_state(ctx) + try: + cb1 = use_callback(fn, [1]) + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + cb2 = use_callback(lambda: None, [1]) + finally: + _set_hook_state(None) + + assert cb1 is fn + assert cb2 is fn + + +# ====================================================================== +# use_ref +# ====================================================================== + + +def test_use_ref_persists() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + ref = use_ref(0) + assert ref["current"] == 0 + ref["current"] = 5 + finally: + _set_hook_state(None) + + ctx.reset_index() + _set_hook_state(ctx) + try: + ref2 = use_ref(0) + assert ref2["current"] == 5 + assert ref2 is ref + finally: + _set_hook_state(None) + + +# ====================================================================== +# Context +# ====================================================================== + + +def test_create_context_default() -> None: + ctx = create_context("default_val") + assert ctx._current() == "default_val" + + +def test_context_stack() -> None: + ctx = create_context("default") + ctx._stack.append("override") + assert ctx._current() == "override" + ctx._stack.pop() + assert ctx._current() == "default" + + +def test_use_context_reads_current() -> None: + my_ctx = create_context("fallback") + my_ctx._stack.append("active") + hook_state = HookState() + _set_hook_state(hook_state) + try: + val = use_context(my_ctx) + assert val == "active" + finally: + _set_hook_state(None) + my_ctx._stack.pop() + + +# ====================================================================== +# @component decorator +# ====================================================================== + + +def test_component_decorator_creates_element() -> None: + @component + def my_comp(label: str = "hello") -> Element: + return Element("Text", {"text": label}, []) + + el = my_comp(label="world") + assert isinstance(el, Element) + assert el.type is getattr(my_comp, "__wrapped__") + assert el.props == {"label": "world"} + + +def test_component_with_positional_args() -> None: + @component + def greeting(name: str, age: int = 0) -> Element: + return Element("Text", {"text": f"{name}, {age}"}, []) + + el = greeting("Alice", age=30) + assert el.props == {"name": "Alice", "age": 30} + + +def test_component_key_extraction() -> None: + @component + def widget(text: str = "") -> Element: + return Element("Text", {"text": text}, []) + + el = widget(text="hi", key="k1") + assert el.key == "k1" + assert "key" not in el.props + + +# ====================================================================== +# Function components in reconciler +# ====================================================================== + + +def test_reconciler_mounts_function_component() -> None: + @component + def greeting(name: str = "World") -> Element: + return Element("Text", {"text": f"Hello {name}"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + root = rec.mount(greeting(name="Python")) + assert root.type_name == "Text" + assert root.props["text"] == "Hello Python" + + +def test_reconciler_reconciles_function_component() -> None: + @component + def display(value: int = 0) -> Element: + return Element("Text", {"text": str(value)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(display(value=1)) + + backend.ops.clear() + rec.reconcile(display(value=2)) + + update_ops = [op for op in backend.ops if op[0] == "update"] + assert len(update_ops) == 1 + assert "text" in update_ops[0][3] + + +def test_function_component_use_state() -> None: + render_count = [0] + captured_setter: list = [None] + + @component + def counter() -> Element: + count, set_count = use_state(0) + render_count[0] += 1 + captured_setter[0] = set_count + return Element("Text", {"text": str(count)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + re_rendered: list = [] + rec._page_re_render = lambda: re_rendered.append(1) + + root = rec.mount(counter()) + assert root.props["text"] == "0" + assert render_count[0] == 1 + + setter_fn = captured_setter[0] + assert setter_fn is not None + setter_fn(5) + assert len(re_rendered) == 1 + + +def test_function_component_preserves_state_across_reconcile() -> None: + @component + def stateful(label: str = "") -> Element: + count, set_count = use_state(0) + return Element("Text", {"text": f"{label}:{count}"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(stateful(label="A")) + + tree_node = rec._tree + assert tree_node is not None + assert tree_node.hook_state is not None + tree_node.hook_state.states[0] = 42 + + rec.reconcile(stateful(label="B")) + assert rec._tree is not None + assert rec._tree.hook_state is not None + assert rec._tree.hook_state.states[0] == 42 + + +# ====================================================================== +# Provider in reconciler +# ====================================================================== + + +def test_provider_in_reconciler() -> None: + theme = create_context("light") + + @component + def themed() -> Element: + t = use_context(theme) + return Element("Text", {"text": t}, []) + + backend = MockBackend() + rec = Reconciler(backend) + el = Provider(theme, "dark", themed()) + root = rec.mount(el) + assert root.props["text"] == "dark" diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 735592c..a97ecc0 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -160,8 +160,6 @@ def test_reconcile_add_child() -> None: ) rec.reconcile(el2) - add_ops = [op for op in backend.ops if op[0] == "add_child"] - assert len(add_ops) == 1 assert len(root.children) == 2 @@ -180,8 +178,6 @@ def test_reconcile_remove_child() -> None: el2 = Element("Column", {}, [Element("Text", {"text": "a"}, [])]) rec.reconcile(el2) - remove_ops = [op for op in backend.ops if op[0] == "remove_child"] - assert len(remove_ops) == 1 assert len(root.children) == 1 @@ -195,10 +191,6 @@ def test_reconcile_replace_child_type() -> None: el2 = Element("Column", {}, [Element("Button", {"title": "b"}, [])]) rec.reconcile(el2) - remove_ops = [op for op in backend.ops if op[0] == "remove_child"] - insert_ops = [op for op in backend.ops if op[0] == "insert_child"] - assert len(remove_ops) == 1 - assert len(insert_ops) == 1 assert root.children[0].type_name == "Button" @@ -278,3 +270,102 @@ def test_multiple_reconcile_cycles() -> None: assert rec._tree is not None assert rec._tree.children[0].element.props["text"] == "4" + + +# ====================================================================== +# Tests: key-based reconciliation +# ====================================================================== + + +def test_keyed_children_preserve_identity() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + el1 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.mount(el1) + view_a = rec._tree.children[0].native_view + view_b = rec._tree.children[1].native_view + view_c = rec._tree.children[2].native_view + + backend.ops.clear() + el2 = Element( + "Column", + {}, + [ + Element("Text", {"text": "C"}, [], key="c"), + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + ], + ) + rec.reconcile(el2) + + assert rec._tree.children[0].native_view is view_c + assert rec._tree.children[1].native_view is view_a + assert rec._tree.children[2].native_view is view_b + + +def test_keyed_children_remove_by_key() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + el1 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.mount(el1) + + el2 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.reconcile(el2) + + assert len(rec._tree.children) == 2 + assert rec._tree.children[0].element.key == "a" + assert rec._tree.children[1].element.key == "c" + + +def test_keyed_children_insert_new() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + el1 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.mount(el1) + + el2 = Element( + "Column", + {}, + [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + Element("Text", {"text": "C"}, [], key="c"), + ], + ) + rec.reconcile(el2) + + assert len(rec._tree.children) == 3 + assert rec._tree.children[1].element.key == "b" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0dad9fc..0e57dbb 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -18,15 +18,34 @@ def test_public_api_names() -> None: "Button", "Column", "Element", + "FlatList", "Image", + "Modal", "Page", + "Pressable", "ProgressBar", "Row", + "SafeAreaView", "ScrollView", + "Slider", "Spacer", "Switch", "Text", "TextInput", + "View", "WebView", + # Hooks + "component", + "create_context", + "use_callback", + "use_context", + "use_effect", + "use_memo", + "use_ref", + "use_state", + "Provider", + # Styling + "StyleSheet", + "ThemeContext", } assert expected.issubset(set(pn.__all__)) diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..9837876 --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,58 @@ +"""Unit tests for StyleSheet and theming.""" + +from pythonnative.style import ( + DEFAULT_DARK_THEME, + DEFAULT_LIGHT_THEME, + StyleSheet, + ThemeContext, +) + + +def test_stylesheet_create() -> None: + styles = StyleSheet.create( + heading={"font_size": 28, "bold": True}, + body={"font_size": 16}, + ) + assert "heading" in styles + assert styles["heading"]["font_size"] == 28 + assert styles["body"]["font_size"] == 16 + + +def test_stylesheet_compose() -> None: + base = {"font_size": 16, "color": "#000"} + override = {"color": "#FFF", "bold": True} + merged = StyleSheet.compose(base, override) + assert merged["font_size"] == 16 + assert merged["color"] == "#FFF" + assert merged["bold"] is True + + +def test_stylesheet_compose_none_safe() -> None: + result = StyleSheet.compose(None, {"a": 1}, None) + assert result == {"a": 1} + + +def test_stylesheet_flatten_dict() -> None: + result = StyleSheet.flatten({"font_size": 20}) + assert result == {"font_size": 20} + + +def test_stylesheet_flatten_list() -> None: + result = StyleSheet.flatten([{"a": 1}, {"b": 2}]) + assert result == {"a": 1, "b": 2} + + +def test_stylesheet_flatten_none() -> None: + result = StyleSheet.flatten(None) + assert result == {} + + +def test_theme_context_has_default() -> None: + val = ThemeContext._current() + assert val is DEFAULT_LIGHT_THEME + assert "primary_color" in val + + +def test_light_and_dark_themes_differ() -> None: + assert DEFAULT_LIGHT_THEME["background_color"] != DEFAULT_DARK_THEME["background_color"] + assert DEFAULT_LIGHT_THEME["text_color"] != DEFAULT_DARK_THEME["text_color"] From 7fbf9c07988d4c543253dec8ba28da42c38cc3a9 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:56:19 -0700 Subject: [PATCH 25/34] ci(workflows): add package build step to verify sdist and wheel before release --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03d9985..a177889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,11 @@ jobs: run: | pip install . + - name: Build package + run: | + pip install build + python -m build + - name: Run tests run: | pytest -q From 7a5923c64d8990635a49dec46f62132719bf7560 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 3 Apr 2026 07:58:14 +0000 Subject: [PATCH 26/34] chore(release): v0.6.0 --- CHANGELOG.md | 45 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/pythonnative/__init__.py | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a714b..d885a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,51 @@ # CHANGELOG +## v0.6.0 (2026-04-03) + +### Build System + +- **deps**: Drop Python 3.9 support (EOL October 2025) + ([`552cd99`](https://github.com/pythonnative/pythonnative/commit/552cd9958c463a51af9e33f0e254dab18135130f)) + +### Code Style + +- **cli**: Reformat pn.py for Black 2026 stable style + ([`298f884`](https://github.com/pythonnative/pythonnative/commit/298f884ce3e1c58a17c92484c5832ebae6f1beaa)) + +### Continuous Integration + +- **workflows**: Add package build step to verify sdist and wheel before release + ([`7fbf9c0`](https://github.com/pythonnative/pythonnative/commit/7fbf9c07988d4c543253dec8ba28da42c38cc3a9)) + +- **workflows,cli**: Fix e2e workflow script chaining and GitHub API auth + ([`01d1968`](https://github.com/pythonnative/pythonnative/commit/01d19683f41a4b00048dfbce687e510bec2e2d31)) + +### Documentation + +- Align branch prefixes with conventional commit types + ([`c6e0e08`](https://github.com/pythonnative/pythonnative/commit/c6e0e08cb0757dad6495c6fee36063699afba87a)) + +- **repo**: Align conventional commit scopes with module structure + ([`ecc39af`](https://github.com/pythonnative/pythonnative/commit/ecc39af78708bc5a83ba81501c7b65d985890de9)) + +- **repo**: Remove component table from README + ([`ab162c5`](https://github.com/pythonnative/pythonnative/commit/ab162c5b658b2367857ab998d3b3f750eca15b4a)) + +### Features + +- Add function components, hooks, layout, styling, hot reload, native APIs, and new UI components + ([`3bd87de`](https://github.com/pythonnative/pythonnative/commit/3bd87de4a8775e23eb4f081a31b9125f9b20861c)) + +- **cli,templates**: Add pythonVersion config, fix Android build, and wire pip requirements + ([`a529834`](https://github.com/pythonnative/pythonnative/commit/a529834a7bfe817a51ef2a5846c97c2f4deee321)) + +### Testing + +- Increase app startup wait for slow CI emulators + ([`4ff6b94`](https://github.com/pythonnative/pythonnative/commit/4ff6b9453a7687eeaf7777bf4a2ab542b32a7e25)) + + ## v0.5.0 (2026-03-31) ### Continuous Integration diff --git a/pyproject.toml b/pyproject.toml index 1a00c42..6979cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.5.0" +version = "0.6.0" description = "Cross-platform native UI toolkit for Android and iOS" authors = [ { name = "Owen Carey" } diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 923a403..f3e4be4 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -26,7 +26,7 @@ def render(self): ) """ -__version__ = "0.5.0" +__version__ = "0.6.0" from .components import ( ActivityIndicator, 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 27/34] 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 28/34] 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, From d0068fdbcceb4745b02d8043b03eade2b54dde66 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:44:46 -0700 Subject: [PATCH 29/34] refactor(native_views)!: split monolithic module into platform-specific package --- CONTRIBUTING.md | 2 +- docs/api/pythonnative.md | 6 + docs/concepts/architecture.md | 10 + mypy.ini | 5 +- src/pythonnative/native_views.py | 1404 --------------------- src/pythonnative/native_views/__init__.py | 87 ++ src/pythonnative/native_views/android.py | 678 ++++++++++ src/pythonnative/native_views/base.py | 97 ++ src/pythonnative/native_views/ios.py | 614 +++++++++ tests/test_native_views.py | 199 +++ 10 files changed, 1696 insertions(+), 1406 deletions(-) delete mode 100644 src/pythonnative/native_views.py create mode 100644 src/pythonnative/native_views/__init__.py create mode 100644 src/pythonnative/native_views/android.py create mode 100644 src/pythonnative/native_views/base.py create mode 100644 src/pythonnative/native_views/ios.py create mode 100644 tests/test_native_views.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a24afc..37daba4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,7 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc - `hooks` – function components and hooks (`hooks.py`) - `hot_reload` – file watcher and module reloader (`hot_reload.py`) - `native_modules` – native API modules for device capabilities (`native_modules/`) - - `native_views` – platform-specific native view creation and updates (`native_views.py`) + - `native_views` – platform-specific native view creation and updates (`native_views/`) - `package` – `src/pythonnative/__init__.py` exports and package boundary - `page` – Page component, lifecycle, and reactive state (`page.py`) - `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`) diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 849cf95..dfe8033 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -64,3 +64,9 @@ Function component primitives: ## Native view registry `pythonnative.native_views.NativeViewRegistry` — maps element type names to platform-specific handlers. Use `set_registry()` to inject a mock for testing. + +The `native_views` package is organised into submodules: + +- `pythonnative.native_views.base` — shared `ViewHandler` protocol and utilities (`parse_color_int`, `resolve_padding`, `LAYOUT_KEYS`) +- `pythonnative.native_views.android` — Android handlers (only imported at runtime on Android via Chaquopy) +- `pythonnative.native_views.ios` — iOS handlers (only imported at runtime on iOS via rubicon-objc) diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 6ededb9..677d6d7 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -56,6 +56,16 @@ The entry point `create_page()` is called internally by native templates to boot 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`. +## Native view handlers + +Platform-specific rendering logic lives in the `native_views` package, organised into dedicated submodules: + +- `native_views.base` — shared `ViewHandler` protocol and common utilities (colour parsing, padding resolution, layout keys) +- `native_views.android` — Android handlers using Chaquopy's Java bridge (`jclass`, `dynamic_proxy`) +- `native_views.ios` — iOS handlers using rubicon-objc (`ObjCClass`, `objc_method`) + +Each handler class maps an element type name (e.g. `"Text"`, `"Button"`) to platform-native widget creation, property updates, and child management. The `NativeViewRegistry` lazily imports only the relevant platform module at runtime, so the package can be imported on any platform for testing. + ## Comparison - **Versus React Native:** RN uses JSX + a JavaScript bridge + Yoga layout. PythonNative uses Python + direct native calls + platform layout managers. No JS bridge, no serialisation overhead. diff --git a/mypy.ini b/mypy.ini index 9657f4d..370f2f6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.10 ignore_missing_imports = True warn_redundant_casts = True warn_unused_ignores = True @@ -17,3 +17,6 @@ disable_error_code = attr-defined,no-redef [mypy-pythonnative.native_views] disable_error_code = misc + +[mypy-pythonnative.native_views.*] +disable_error_code = misc diff --git a/src/pythonnative/native_views.py b/src/pythonnative/native_views.py deleted file mode 100644 index 0d75e61..0000000 --- a/src/pythonnative/native_views.py +++ /dev/null @@ -1,1404 +0,0 @@ -"""Platform-specific native view creation and update logic. - -This module replaces the old per-widget files. All platform-branching -lives here, guarded behind lazy imports so the module can be imported -on desktop for testing. -""" - -from typing import Any, Callable, Dict, Optional, Union - -from .utils import IS_ANDROID - -# ====================================================================== -# Abstract handler protocol -# ====================================================================== - - -class ViewHandler: - """Protocol for creating, updating, and managing children of a native view type.""" - - def create(self, props: Dict[str, Any]) -> Any: - raise NotImplementedError - - def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: - raise NotImplementedError - - def add_child(self, parent: Any, child: Any) -> None: - pass - - def remove_child(self, parent: Any, child: Any) -> None: - pass - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - self.add_child(parent, child) - - -# ====================================================================== -# Registry -# ====================================================================== - - -class NativeViewRegistry: - """Maps element type names to platform-specific :class:`ViewHandler` instances.""" - - def __init__(self) -> None: - self._handlers: Dict[str, ViewHandler] = {} - - def register(self, type_name: str, handler: ViewHandler) -> None: - self._handlers[type_name] = handler - - def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: - handler = self._handlers.get(type_name) - if handler is None: - raise ValueError(f"Unknown element type: {type_name!r}") - return handler.create(props) - - def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: - handler = self._handlers.get(type_name) - if handler is not None: - handler.update(native_view, changed_props) - - def add_child(self, parent: Any, child: Any, parent_type: str) -> None: - handler = self._handlers.get(parent_type) - if handler is not None: - handler.add_child(parent, child) - - def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: - handler = self._handlers.get(parent_type) - if handler is not None: - handler.remove_child(parent, child) - - def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: - handler = self._handlers.get(parent_type) - if handler is not None: - handler.insert_child(parent, child, index) - - -# ====================================================================== -# Shared helpers -# ====================================================================== - - -def parse_color_int(color: Union[str, int]) -> int: - """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. - - Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values - with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. - """ - if isinstance(color, int): - val = color - else: - c = color.strip().lstrip("#") - if len(c) == 6: - c = "FF" + c - val = int(c, 16) - if val > 0x7FFFFFFF: - val -= 0x100000000 - return val - - -def _resolve_padding( - padding: Any, -) -> tuple: - """Normalise various padding representations to ``(left, top, right, bottom)``.""" - if padding is None: - return (0, 0, 0, 0) - if isinstance(padding, (int, float)): - v = int(padding) - return (v, v, v, v) - if isinstance(padding, dict): - h = int(padding.get("horizontal", 0)) - v = int(padding.get("vertical", 0)) - left = int(padding.get("left", h)) - right = int(padding.get("right", h)) - top = int(padding.get("top", v)) - bottom = int(padding.get("bottom", v)) - a = int(padding.get("all", 0)) - if a: - left = left or a - right = right or a - top = top or a - bottom = bottom or a - return (left, top, right, bottom) - return (0, 0, 0, 0) - - -_LAYOUT_KEYS = frozenset( - { - "width", - "height", - "flex", - "margin", - "min_width", - "max_width", - "min_height", - "max_height", - "align_self", - } -) - - -# ====================================================================== -# Platform handler registration (lazy imports inside functions) -# ====================================================================== - - -def _register_android_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 - from java import dynamic_proxy, jclass - - from .utils import get_android_context - - def _ctx() -> Any: - return get_android_context() - - def _density() -> float: - return float(_ctx().getResources().getDisplayMetrics().density) - - def _dp(value: float) -> int: - return int(value * _density()) - - def _apply_layout(view: Any, props: Dict[str, Any]) -> None: - """Apply common layout properties to an Android view.""" - lp = view.getLayoutParams() - LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") - ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") - needs_set = False - - if lp is None: - lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT) - needs_set = True - - if "width" in props and props["width"] is not None: - lp.width = _dp(float(props["width"])) - needs_set = True - if "height" in props and props["height"] is not None: - lp.height = _dp(float(props["height"])) - needs_set = True - if "flex" in props and props["flex"] is not None: - try: - lp.weight = float(props["flex"]) - needs_set = True - except Exception: - pass - if "margin" in props and props["margin"] is not None: - left, top, right, bottom = _resolve_padding(props["margin"]) - try: - lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom)) - needs_set = True - except Exception: - pass - - if needs_set: - view.setLayoutParams(lp) - - # ---- Text ----------------------------------------------------------- - class AndroidTextHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - tv = jclass("android.widget.TextView")(_ctx()) - self._apply(tv, props) - _apply_layout(tv, props) - return tv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, tv: Any, props: Dict[str, Any]) -> None: - if "text" in props: - tv.setText(str(props["text"])) - if "font_size" in props and props["font_size"] is not None: - tv.setTextSize(float(props["font_size"])) - if "color" in props and props["color"] is not None: - tv.setTextColor(parse_color_int(props["color"])) - if "background_color" in props and props["background_color"] is not None: - tv.setBackgroundColor(parse_color_int(props["background_color"])) - if "bold" in props and props["bold"]: - tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 - if "max_lines" in props and props["max_lines"] is not None: - tv.setMaxLines(int(props["max_lines"])) - if "text_align" in props: - Gravity = jclass("android.view.Gravity") - mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} - tv.setGravity(mapping.get(props["text_align"], Gravity.START)) - - # ---- Button --------------------------------------------------------- - class AndroidButtonHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - btn = jclass("android.widget.Button")(_ctx()) - self._apply(btn, props) - _apply_layout(btn, props) - return btn - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, btn: Any, props: Dict[str, Any]) -> None: - if "title" in props: - btn.setText(str(props["title"])) - if "font_size" in props and props["font_size"] is not None: - btn.setTextSize(float(props["font_size"])) - if "color" in props and props["color"] is not None: - btn.setTextColor(parse_color_int(props["color"])) - if "background_color" in props and props["background_color"] is not None: - btn.setBackgroundColor(parse_color_int(props["background_color"])) - if "enabled" in props: - btn.setEnabled(bool(props["enabled"])) - if "on_click" in props: - cb = props["on_click"] - if cb is not None: - - class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback: Callable[[], None]) -> None: - super().__init__() - self.callback = callback - - def onClick(self, view: Any) -> None: - self.callback() - - btn.setOnClickListener(ClickProxy(cb)) - else: - btn.setOnClickListener(None) - - # ---- Column (vertical LinearLayout) --------------------------------- - class AndroidColumnHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ll = jclass("android.widget.LinearLayout")(_ctx()) - ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) - self._apply(ll, props) - _apply_layout(ll, props) - return ll - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, ll: Any, props: Dict[str, Any]) -> None: - Gravity = jclass("android.view.Gravity") - if "spacing" in props and props["spacing"]: - px = _dp(float(props["spacing"])) - GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") - d = GradientDrawable() - d.setColor(0x00000000) - d.setSize(1, px) - ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) - ll.setDividerDrawable(d) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - gravity = 0 - ai = props.get("align_items") or props.get("alignment") - if ai: - cross_map = { - "stretch": Gravity.FILL_HORIZONTAL, - "fill": Gravity.FILL_HORIZONTAL, - "flex_start": Gravity.START, - "leading": Gravity.START, - "start": Gravity.START, - "center": Gravity.CENTER_HORIZONTAL, - "flex_end": Gravity.END, - "trailing": Gravity.END, - "end": Gravity.END, - } - 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"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - # ---- Row (horizontal LinearLayout) ---------------------------------- - class AndroidRowHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ll = jclass("android.widget.LinearLayout")(_ctx()) - ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) - self._apply(ll, props) - _apply_layout(ll, props) - return ll - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, ll: Any, props: Dict[str, Any]) -> None: - Gravity = jclass("android.view.Gravity") - if "spacing" in props and props["spacing"]: - px = _dp(float(props["spacing"])) - GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") - d = GradientDrawable() - d.setColor(0x00000000) - d.setSize(px, 1) - ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) - ll.setDividerDrawable(d) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - gravity = 0 - ai = props.get("align_items") or props.get("alignment") - if ai: - cross_map = { - "stretch": Gravity.FILL_VERTICAL, - "fill": Gravity.FILL_VERTICAL, - "flex_start": Gravity.TOP, - "top": Gravity.TOP, - "center": Gravity.CENTER_VERTICAL, - "flex_end": Gravity.BOTTOM, - "bottom": Gravity.BOTTOM, - } - gravity |= cross_map.get(ai, 0) - jc = props.get("justify_content") - if jc: - 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"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - # ---- ScrollView ----------------------------------------------------- - class AndroidScrollViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = jclass("android.widget.ScrollView")(_ctx()) - if "background_color" in props and props["background_color"] is not None: - sv.setBackgroundColor(parse_color_int(props["background_color"])) - _apply_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor(parse_color_int(changed["background_color"])) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - # ---- TextInput (EditText) with on_change ---------------------------- - class AndroidTextInputHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - et = jclass("android.widget.EditText")(_ctx()) - self._apply(et, props) - _apply_layout(et, props) - return et - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, et: Any, props: Dict[str, Any]) -> None: - if "value" in props: - et.setText(str(props["value"])) - if "placeholder" in props: - et.setHint(str(props["placeholder"])) - if "font_size" in props and props["font_size"] is not None: - et.setTextSize(float(props["font_size"])) - if "color" in props and props["color"] is not None: - et.setTextColor(parse_color_int(props["color"])) - if "background_color" in props and props["background_color"] is not None: - et.setBackgroundColor(parse_color_int(props["background_color"])) - if "secure" in props and props["secure"]: - InputType = jclass("android.text.InputType") - et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) - if "on_change" in props: - cb = props["on_change"] - if cb is not None: - TextWatcher = jclass("android.text.TextWatcher") - - class ChangeProxy(dynamic_proxy(TextWatcher)): - def __init__(self, callback: Callable[[str], None]) -> None: - super().__init__() - self.callback = callback - - def afterTextChanged(self, s: Any) -> None: - self.callback(str(s)) - - def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None: - pass - - def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None: - pass - - et.addTextChangedListener(ChangeProxy(cb)) - - # ---- Image (with URL loading) --------------------------------------- - class AndroidImageHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - iv = jclass("android.widget.ImageView")(_ctx()) - self._apply(iv, props) - _apply_layout(iv, props) - return iv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, iv: Any, props: Dict[str, Any]) -> None: - if "background_color" in props and props["background_color"] is not None: - iv.setBackgroundColor(parse_color_int(props["background_color"])) - if "source" in props and props["source"]: - self._load_source(iv, props["source"]) - if "scale_type" in props and props["scale_type"]: - ScaleType = jclass("android.widget.ImageView$ScaleType") - mapping = { - "cover": ScaleType.CENTER_CROP, - "contain": ScaleType.FIT_CENTER, - "stretch": ScaleType.FIT_XY, - "center": ScaleType.CENTER, - } - st = mapping.get(props["scale_type"]) - if st: - iv.setScaleType(st) - - def _load_source(self, iv: Any, source: str) -> None: - try: - if source.startswith(("http://", "https://")): - Thread = jclass("java.lang.Thread") - Runnable = jclass("java.lang.Runnable") - URL = jclass("java.net.URL") - BitmapFactory = jclass("android.graphics.BitmapFactory") - Handler = jclass("android.os.Handler") - Looper = jclass("android.os.Looper") - handler = Handler(Looper.getMainLooper()) - - class LoadTask(dynamic_proxy(Runnable)): - def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None: - super().__init__() - self.image_view = image_view - self.url_str = url_str - self.main_handler = main_handler - - def run(self) -> None: - try: - url = URL(self.url_str) - stream = url.openStream() - bitmap = BitmapFactory.decodeStream(stream) - stream.close() - - class SetImage(dynamic_proxy(Runnable)): - def __init__(self, view: Any, bmp: Any) -> None: - super().__init__() - self.view = view - self.bmp = bmp - - def run(self) -> None: - self.view.setImageBitmap(self.bmp) - - self.main_handler.post(SetImage(self.image_view, bitmap)) - except Exception: - pass - - Thread(LoadTask(iv, source, handler)).start() - else: - ctx = _ctx() - res = ctx.getResources() - pkg = ctx.getPackageName() - res_name = source.rsplit(".", 1)[0] if "." in source else source - res_id = res.getIdentifier(res_name, "drawable", pkg) - if res_id != 0: - iv.setImageResource(res_id) - except Exception: - pass - - # ---- Switch (with on_change) ---------------------------------------- - class AndroidSwitchHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sw = jclass("android.widget.Switch")(_ctx()) - self._apply(sw, props) - _apply_layout(sw, props) - return sw - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sw: Any, props: Dict[str, Any]) -> None: - if "value" in props: - sw.setChecked(bool(props["value"])) - if "on_change" in props and props["on_change"] is not None: - cb = props["on_change"] - - class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): - def __init__(self, callback: Callable[[bool], None]) -> None: - super().__init__() - self.callback = callback - - def onCheckedChanged(self, button: Any, checked: bool) -> None: - self.callback(checked) - - sw.setOnCheckedChangeListener(CheckedProxy(cb)) - - # ---- ProgressBar ---------------------------------------------------- - class AndroidProgressBarHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - style = jclass("android.R$attr").progressBarStyleHorizontal - pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) - pb.setMax(1000) - self._apply(pb, props) - _apply_layout(pb, props) - return pb - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, pb: Any, props: Dict[str, Any]) -> None: - if "value" in props: - pb.setProgress(int(float(props["value"]) * 1000)) - - # ---- ActivityIndicator (circular ProgressBar) ----------------------- - class AndroidActivityIndicatorHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - pb = jclass("android.widget.ProgressBar")(_ctx()) - if not props.get("animating", True): - pb.setVisibility(jclass("android.view.View").GONE) - _apply_layout(pb, props) - return pb - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - View = jclass("android.view.View") - if "animating" in changed: - native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) - - # ---- WebView -------------------------------------------------------- - class AndroidWebViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - wv = jclass("android.webkit.WebView")(_ctx()) - if "url" in props and props["url"]: - wv.loadUrl(str(props["url"])) - _apply_layout(wv, props) - return wv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "url" in changed and changed["url"]: - native_view.loadUrl(str(changed["url"])) - - # ---- Spacer --------------------------------------------------------- - class AndroidSpacerHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = jclass("android.view.View")(_ctx()) - if "size" in props and props["size"] is not None: - px = _dp(float(props["size"])) - lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) - v.setLayoutParams(lp) - if "flex" in props and props["flex"] is not None: - lp = v.getLayoutParams() - if lp is None: - lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0) - lp.weight = float(props["flex"]) - v.setLayoutParams(lp) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "size" in changed and changed["size"] is not None: - px = _dp(float(changed["size"])) - lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) - native_view.setLayoutParams(lp) - - # ---- View (generic container FrameLayout) --------------------------- - class AndroidViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - fl = jclass("android.widget.FrameLayout")(_ctx()) - if "background_color" in props and props["background_color"] is not None: - fl.setBackgroundColor(parse_color_int(props["background_color"])) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - _apply_layout(fl, props) - return fl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor(parse_color_int(changed["background_color"])) - if "padding" in changed: - left, top, right, bottom = _resolve_padding(changed["padding"]) - native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - # ---- SafeAreaView (FrameLayout with fitsSystemWindows) --------------- - class AndroidSafeAreaViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - fl = jclass("android.widget.FrameLayout")(_ctx()) - fl.setFitsSystemWindows(True) - if "background_color" in props and props["background_color"] is not None: - fl.setBackgroundColor(parse_color_int(props["background_color"])) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - return fl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor(parse_color_int(changed["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - # ---- Modal (AlertDialog) ------------------------------------------- - class AndroidModalHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - placeholder = jclass("android.view.View")(_ctx()) - placeholder.setVisibility(jclass("android.view.View").GONE) - return placeholder - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - pass - - def add_child(self, parent: Any, child: Any) -> None: - pass - - # ---- Slider (SeekBar) ----------------------------------------------- - class AndroidSliderHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sb = jclass("android.widget.SeekBar")(_ctx()) - sb.setMax(1000) - self._apply(sb, props) - _apply_layout(sb, props) - return sb - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sb: Any, props: Dict[str, Any]) -> None: - min_val = float(props.get("min_value", 0)) - max_val = float(props.get("max_value", 1)) - rng = max_val - min_val if max_val != min_val else 1 - if "value" in props: - normalized = (float(props["value"]) - min_val) / rng - sb.setProgress(int(normalized * 1000)) - if "on_change" in props and props["on_change"] is not None: - cb = props["on_change"] - - class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)): - def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None: - super().__init__() - self.callback = callback - self.mn = mn - self.rn = rn - - def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None: - if fromUser: - self.callback(self.mn + (progress / 1000.0) * self.rn) - - def onStartTrackingTouch(self, seekBar: Any) -> None: - pass - - def onStopTrackingTouch(self, seekBar: Any) -> None: - pass - - sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) - - # ---- Pressable (FrameLayout with click listener) -------------------- - class AndroidPressableHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - fl = jclass("android.widget.FrameLayout")(_ctx()) - fl.setClickable(True) - self._apply(fl, props) - return fl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, fl: Any, props: Dict[str, Any]) -> None: - if "on_press" in props and props["on_press"] is not None: - cb = props["on_press"] - - class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback: Callable[[], None]) -> None: - super().__init__() - self.callback = callback - - def onClick(self, view: Any) -> None: - self.callback() - - fl.setOnClickListener(PressProxy(cb)) - if "on_long_press" in props and props["on_long_press"] is not None: - cb = props["on_long_press"] - - class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)): - def __init__(self, callback: Callable[[], None]) -> None: - super().__init__() - self.callback = callback - - def onLongClick(self, view: Any) -> bool: - self.callback() - return True - - fl.setOnLongClickListener(LongPressProxy(cb)) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - registry.register("Text", AndroidTextHandler()) - registry.register("Button", AndroidButtonHandler()) - registry.register("Column", AndroidColumnHandler()) - registry.register("Row", AndroidRowHandler()) - registry.register("ScrollView", AndroidScrollViewHandler()) - registry.register("TextInput", AndroidTextInputHandler()) - registry.register("Image", AndroidImageHandler()) - registry.register("Switch", AndroidSwitchHandler()) - registry.register("ProgressBar", AndroidProgressBarHandler()) - registry.register("ActivityIndicator", AndroidActivityIndicatorHandler()) - registry.register("WebView", AndroidWebViewHandler()) - registry.register("Spacer", AndroidSpacerHandler()) - registry.register("View", AndroidViewHandler()) - registry.register("SafeAreaView", AndroidSafeAreaViewHandler()) - registry.register("Modal", AndroidModalHandler()) - registry.register("Slider", AndroidSliderHandler()) - registry.register("Pressable", AndroidPressableHandler()) - - -def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 - from rubicon.objc import SEL, ObjCClass, objc_method - - NSObject = ObjCClass("NSObject") - UIColor = ObjCClass("UIColor") - UIFont = ObjCClass("UIFont") - - def _uicolor(color: Any) -> Any: - argb = parse_color_int(color) - if argb < 0: - argb += 0x100000000 - a = ((argb >> 24) & 0xFF) / 255.0 - r = ((argb >> 16) & 0xFF) / 255.0 - g = ((argb >> 8) & 0xFF) / 255.0 - b = (argb & 0xFF) / 255.0 - return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - - def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: - """Apply common layout constraints to an iOS view.""" - if "width" in props and props["width"] is not None: - try: - for c in list(view.constraints or []): - if c.firstAttribute == 7: # NSLayoutAttributeWidth - c.setActive_(False) - view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True) - except Exception: - pass - if "height" in props and props["height"] is not None: - try: - for c in list(view.constraints or []): - if c.firstAttribute == 8: # NSLayoutAttributeHeight - c.setActive_(False) - view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) - except Exception: - pass - - # ---- Text ----------------------------------------------------------- - class IOSTextHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - label = ObjCClass("UILabel").alloc().init() - self._apply(label, props) - _apply_ios_layout(label, props) - return label - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, label: Any, props: Dict[str, Any]) -> None: - if "text" in props: - label.setText_(str(props["text"])) - if "font_size" in props and props["font_size"] is not None: - if props.get("bold"): - label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) - else: - label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) - elif "bold" in props and props["bold"]: - size = label.font().pointSize() if label.font() else 17.0 - label.setFont_(UIFont.boldSystemFontOfSize_(size)) - if "color" in props and props["color"] is not None: - label.setTextColor_(_uicolor(props["color"])) - if "background_color" in props and props["background_color"] is not None: - label.setBackgroundColor_(_uicolor(props["background_color"])) - if "max_lines" in props and props["max_lines"] is not None: - label.setNumberOfLines_(int(props["max_lines"])) - if "text_align" in props: - mapping = {"left": 0, "center": 1, "right": 2} - label.setTextAlignment_(mapping.get(props["text_align"], 0)) - - # ---- Button --------------------------------------------------------- - - _pn_btn_handler_map: dict = {} - - class _PNButtonTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[], None]] = None - - @objc_method - def onTap_(self, sender: object) -> None: - if self._callback is not None: - self._callback() - - _pn_retained_views: list = [] - - class IOSButtonHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - btn = ObjCClass("UIButton").alloc().init() - btn.retain() - _pn_retained_views.append(btn) - _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) - btn.setTitleColor_forState_(_ios_blue, 0) - self._apply(btn, props) - _apply_ios_layout(btn, props) - return btn - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, btn: Any, props: Dict[str, Any]) -> None: - if "title" in props: - btn.setTitle_forState_(str(props["title"]), 0) - if "font_size" in props and props["font_size"] is not None: - btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) - if "background_color" in props and props["background_color"] is not None: - btn.setBackgroundColor_(_uicolor(props["background_color"])) - if "color" not in props: - _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) - btn.setTitleColor_forState_(_white, 0) - if "color" in props and props["color"] is not None: - btn.setTitleColor_forState_(_uicolor(props["color"]), 0) - if "enabled" in props: - btn.setEnabled_(bool(props["enabled"])) - if "on_click" in props: - existing = _pn_btn_handler_map.get(id(btn)) - if existing is not None: - existing._callback = props["on_click"] - else: - handler = _PNButtonTarget.new() - handler._callback = props["on_click"] - _pn_btn_handler_map[id(btn)] = handler - btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) - - # ---- Column (vertical UIStackView) ---------------------------------- - class IOSColumnHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) - sv.setAxis_(1) # vertical - self._apply(sv, props) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, sv: Any, props: Dict[str, Any]) -> None: - if "spacing" in props and props["spacing"]: - sv.setSpacing_(float(props["spacing"])) - 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: - left, top, right, bottom = _resolve_padding(props["padding"]) - sv.setLayoutMarginsRelativeArrangement_(True) - try: - sv.setDirectionalLayoutMargins_((top, left, bottom, right)) - except Exception: - sv.setLayoutMargins_((top, left, bottom, right)) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addArrangedSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeArrangedSubview_(child) - child.removeFromSuperview() - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.insertArrangedSubview_atIndex_(child, index) - - # ---- Row (horizontal UIStackView) ----------------------------------- - class IOSRowHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) - sv.setAxis_(0) # horizontal - self._apply(sv, props) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, sv: Any, props: Dict[str, Any]) -> None: - if "spacing" in props and props["spacing"]: - sv.setSpacing_(float(props["spacing"])) - 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"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addArrangedSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeArrangedSubview_(child) - child.removeFromSuperview() - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.insertArrangedSubview_atIndex_(child, index) - - # ---- ScrollView ----------------------------------------------------- - class IOSScrollViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIScrollView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - sv.setBackgroundColor_(_uicolor(props["background_color"])) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - child.setTranslatesAutoresizingMaskIntoConstraints_(False) - parent.addSubview_(child) - content_guide = parent.contentLayoutGuide - frame_guide = parent.frameLayoutGuide - child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) - child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) - child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) - child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) - child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - # ---- TextInput (UITextField with on_change) ------------------------- - _pn_tf_handler_map: dict = {} - - class _PNTextFieldTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[str], None]] = None - - @objc_method - def onEdit_(self, sender: object) -> None: - if self._callback is not None: - try: - text = str(sender.text) if sender and hasattr(sender, "text") else "" - self._callback(text) - except Exception: - pass - - class IOSTextInputHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - tf = ObjCClass("UITextField").alloc().init() - tf.setBorderStyle_(2) # RoundedRect - self._apply(tf, props) - _apply_ios_layout(tf, props) - return tf - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, tf: Any, props: Dict[str, Any]) -> None: - if "value" in props: - tf.setText_(str(props["value"])) - if "placeholder" in props: - tf.setPlaceholder_(str(props["placeholder"])) - if "font_size" in props and props["font_size"] is not None: - tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) - if "color" in props and props["color"] is not None: - tf.setTextColor_(_uicolor(props["color"])) - if "background_color" in props and props["background_color"] is not None: - tf.setBackgroundColor_(_uicolor(props["background_color"])) - if "secure" in props and props["secure"]: - tf.setSecureTextEntry_(True) - if "on_change" in props: - existing = _pn_tf_handler_map.get(id(tf)) - if existing is not None: - existing._callback = props["on_change"] - else: - handler = _PNTextFieldTarget.new() - handler._callback = props["on_change"] - _pn_tf_handler_map[id(tf)] = handler - tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17) - - # ---- Image (with URL loading) --------------------------------------- - class IOSImageHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - iv = ObjCClass("UIImageView").alloc().init() - self._apply(iv, props) - _apply_ios_layout(iv, props) - return iv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, iv: Any, props: Dict[str, Any]) -> None: - if "background_color" in props and props["background_color"] is not None: - iv.setBackgroundColor_(_uicolor(props["background_color"])) - if "source" in props and props["source"]: - self._load_source(iv, props["source"]) - if "scale_type" in props and props["scale_type"]: - mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4} - iv.setContentMode_(mapping.get(props["scale_type"], 1)) - - def _load_source(self, iv: Any, source: str) -> None: - try: - if source.startswith(("http://", "https://")): - NSURL = ObjCClass("NSURL") - NSData = ObjCClass("NSData") - UIImage = ObjCClass("UIImage") - url = NSURL.URLWithString_(source) - data = NSData.dataWithContentsOfURL_(url) - if data: - image = UIImage.imageWithData_(data) - if image: - iv.setImage_(image) - else: - UIImage = ObjCClass("UIImage") - image = UIImage.imageNamed_(source) - if image: - iv.setImage_(image) - except Exception: - pass - - # ---- Switch (with on_change) ---------------------------------------- - _pn_switch_handler_map: dict = {} - - class _PNSwitchTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[bool], None]] = None - - @objc_method - def onToggle_(self, sender: object) -> None: - if self._callback is not None: - try: - self._callback(bool(sender.isOn())) - except Exception: - pass - - class IOSSwitchHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sw = ObjCClass("UISwitch").alloc().init() - self._apply(sw, props) - return sw - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sw: Any, props: Dict[str, Any]) -> None: - if "value" in props: - sw.setOn_animated_(bool(props["value"]), False) - if "on_change" in props: - existing = _pn_switch_handler_map.get(id(sw)) - if existing is not None: - existing._callback = props["on_change"] - else: - handler = _PNSwitchTarget.new() - handler._callback = props["on_change"] - _pn_switch_handler_map[id(sw)] = handler - sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12) - - # ---- ProgressBar (UIProgressView) ----------------------------------- - class IOSProgressBarHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - pv = ObjCClass("UIProgressView").alloc().init() - if "value" in props: - pv.setProgress_(float(props["value"])) - _apply_ios_layout(pv, props) - return pv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "value" in changed: - native_view.setProgress_(float(changed["value"])) - - # ---- ActivityIndicator ---------------------------------------------- - class IOSActivityIndicatorHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ai = ObjCClass("UIActivityIndicatorView").alloc().init() - if props.get("animating", True): - ai.startAnimating() - return ai - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "animating" in changed: - if changed["animating"]: - native_view.startAnimating() - else: - native_view.stopAnimating() - - # ---- WebView (WKWebView) -------------------------------------------- - class IOSWebViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - wv = ObjCClass("WKWebView").alloc().init() - if "url" in props and props["url"]: - NSURL = ObjCClass("NSURL") - NSURLRequest = ObjCClass("NSURLRequest") - url_obj = NSURL.URLWithString_(str(props["url"])) - wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) - _apply_ios_layout(wv, props) - return wv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "url" in changed and changed["url"]: - NSURL = ObjCClass("NSURL") - NSURLRequest = ObjCClass("NSURLRequest") - url_obj = NSURL.URLWithString_(str(changed["url"])) - native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) - - # ---- Spacer --------------------------------------------------------- - class IOSSpacerHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - if "size" in props and props["size"] is not None: - size = float(props["size"]) - v.setFrame_(((0, 0), (size, size))) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "size" in changed and changed["size"] is not None: - size = float(changed["size"]) - native_view.setFrame_(((0, 0), (size, size))) - - # ---- View (generic UIView) ----------------------------------------- - class IOSViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - v.setBackgroundColor_(_uicolor(props["background_color"])) - _apply_ios_layout(v, props) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - # ---- SafeAreaView --------------------------------------------------- - class IOSSafeAreaViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - v.setBackgroundColor_(_uicolor(props["background_color"])) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - # ---- Modal ---------------------------------------------------------- - class IOSModalHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - v.setHidden_(True) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - pass - - # ---- Slider (UISlider) ---------------------------------------------- - _pn_slider_handler_map: dict = {} - - class _PNSliderTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[float], None]] = None - - @objc_method - def onSlide_(self, sender: object) -> None: - if self._callback is not None: - try: - self._callback(float(sender.value)) - except Exception: - pass - - class IOSSliderHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sl = ObjCClass("UISlider").alloc().init() - self._apply(sl, props) - _apply_ios_layout(sl, props) - return sl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sl: Any, props: Dict[str, Any]) -> None: - if "min_value" in props: - sl.setMinimumValue_(float(props["min_value"])) - if "max_value" in props: - sl.setMaximumValue_(float(props["max_value"])) - if "value" in props: - sl.setValue_(float(props["value"])) - if "on_change" in props: - existing = _pn_slider_handler_map.get(id(sl)) - if existing is not None: - existing._callback = props["on_change"] - else: - handler = _PNSliderTarget.new() - handler._callback = props["on_change"] - _pn_slider_handler_map[id(sl)] = handler - sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) - - # ---- Pressable (UIView with tap gesture) ---------------------------- - class IOSPressableHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - v.setUserInteractionEnabled_(True) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - pass - - def add_child(self, parent: Any, child: Any) -> None: - parent.addSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - registry.register("Text", IOSTextHandler()) - registry.register("Button", IOSButtonHandler()) - registry.register("Column", IOSColumnHandler()) - registry.register("Row", IOSRowHandler()) - registry.register("ScrollView", IOSScrollViewHandler()) - registry.register("TextInput", IOSTextInputHandler()) - registry.register("Image", IOSImageHandler()) - registry.register("Switch", IOSSwitchHandler()) - registry.register("ProgressBar", IOSProgressBarHandler()) - registry.register("ActivityIndicator", IOSActivityIndicatorHandler()) - registry.register("WebView", IOSWebViewHandler()) - registry.register("Spacer", IOSSpacerHandler()) - registry.register("View", IOSViewHandler()) - registry.register("SafeAreaView", IOSSafeAreaViewHandler()) - registry.register("Modal", IOSModalHandler()) - registry.register("Slider", IOSSliderHandler()) - registry.register("Pressable", IOSPressableHandler()) - - -# ====================================================================== -# Factory -# ====================================================================== - -_registry: Optional[NativeViewRegistry] = None - - -def get_registry() -> NativeViewRegistry: - """Return the singleton registry, lazily creating platform handlers.""" - global _registry - if _registry is not None: - return _registry - _registry = NativeViewRegistry() - if IS_ANDROID: - _register_android_handlers(_registry) - else: - _register_ios_handlers(_registry) - return _registry - - -def set_registry(registry: NativeViewRegistry) -> None: - """Inject a custom or mock registry (primarily for testing).""" - global _registry - _registry = registry diff --git a/src/pythonnative/native_views/__init__.py b/src/pythonnative/native_views/__init__.py new file mode 100644 index 0000000..2630f36 --- /dev/null +++ b/src/pythonnative/native_views/__init__.py @@ -0,0 +1,87 @@ +"""Platform-specific native view creation and update logic. + +This package provides the :class:`NativeViewRegistry` that maps element type +names to platform-specific :class:`~.base.ViewHandler` implementations. + +Platform handlers live in dedicated submodules: + +- :mod:`~.base` — shared :class:`~.base.ViewHandler` protocol and utilities +- :mod:`~.android` — Android handlers (Chaquopy / Java bridge) +- :mod:`~.ios` — iOS handlers (rubicon-objc) + +All platform-branching is handled at registration time via lazy imports, +so the package can be imported on any platform for testing. +""" + +from typing import Any, Dict, Optional + +from .base import ViewHandler + + +class NativeViewRegistry: + """Maps element type names to platform-specific :class:`ViewHandler` instances.""" + + def __init__(self) -> None: + self._handlers: Dict[str, ViewHandler] = {} + + def register(self, type_name: str, handler: ViewHandler) -> None: + self._handlers[type_name] = handler + + def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: + handler = self._handlers.get(type_name) + if handler is None: + raise ValueError(f"Unknown element type: {type_name!r}") + return handler.create(props) + + def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: + handler = self._handlers.get(type_name) + if handler is not None: + handler.update(native_view, changed_props) + + def add_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.add_child(parent, child) + + def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.remove_child(parent, child) + + def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.insert_child(parent, child, index) + + +# ====================================================================== +# Singleton registry +# ====================================================================== + +_registry: Optional[NativeViewRegistry] = None + + +def get_registry() -> NativeViewRegistry: + """Return the singleton registry, lazily creating platform handlers.""" + global _registry + if _registry is not None: + return _registry + _registry = NativeViewRegistry() + + from ..utils import IS_ANDROID + + if IS_ANDROID: + from .android import register_handlers + + register_handlers(_registry) + else: + from .ios import register_handlers + + register_handlers(_registry) + return _registry + + +def set_registry(registry: NativeViewRegistry) -> None: + """Inject a custom or mock registry (primarily for testing).""" + global _registry + _registry = registry diff --git a/src/pythonnative/native_views/android.py b/src/pythonnative/native_views/android.py new file mode 100644 index 0000000..5a0db11 --- /dev/null +++ b/src/pythonnative/native_views/android.py @@ -0,0 +1,678 @@ +"""Android native view handlers (Chaquopy / Java bridge). + +Each handler class maps a PythonNative element type to an Android widget, +implementing view creation, property updates, and child management. + +This module is only imported on Android at runtime; desktop tests inject +a mock registry via :func:`~.set_registry` and never trigger this import. +""" + +from typing import Any, Callable, Dict + +from java import dynamic_proxy, jclass + +from ..utils import get_android_context +from .base import LAYOUT_KEYS, ViewHandler, parse_color_int, resolve_padding + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def _ctx() -> Any: + return get_android_context() + + +def _density() -> float: + return float(_ctx().getResources().getDisplayMetrics().density) + + +def _dp(value: float) -> int: + return int(value * _density()) + + +def _apply_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout properties to an Android view.""" + lp = view.getLayoutParams() + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + needs_set = False + + if lp is None: + lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT) + needs_set = True + + if "width" in props and props["width"] is not None: + lp.width = _dp(float(props["width"])) + needs_set = True + if "height" in props and props["height"] is not None: + lp.height = _dp(float(props["height"])) + needs_set = True + if "flex" in props and props["flex"] is not None: + try: + lp.weight = float(props["flex"]) + needs_set = True + except Exception: + pass + if "margin" in props and props["margin"] is not None: + left, top, right, bottom = resolve_padding(props["margin"]) + try: + lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom)) + needs_set = True + except Exception: + pass + + if needs_set: + view.setLayoutParams(lp) + + +def _apply_stack_props(ll: Any, props: Dict[str, Any], *, vertical: bool) -> None: + """Apply spacing, padding, alignment, and background to a Column/Row LinearLayout. + + Column and Row handlers share identical logic except for axis-dependent + constants. This helper consolidates that logic. + """ + Gravity = jclass("android.view.Gravity") + + if "spacing" in props and props["spacing"]: + px = _dp(float(props["spacing"])) + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + d = GradientDrawable() + d.setColor(0x00000000) + d.setSize(1 if vertical else px, px if vertical else 1) + ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) + ll.setDividerDrawable(d) + + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + + gravity = 0 + ai = props.get("align_items") or props.get("alignment") + if ai: + if vertical: + cross_map = { + "stretch": Gravity.FILL_HORIZONTAL, + "fill": Gravity.FILL_HORIZONTAL, + "flex_start": Gravity.START, + "leading": Gravity.START, + "start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, + "trailing": Gravity.END, + "end": Gravity.END, + } + else: + cross_map = { + "stretch": Gravity.FILL_VERTICAL, + "fill": Gravity.FILL_VERTICAL, + "flex_start": Gravity.TOP, + "top": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, + "bottom": Gravity.BOTTOM, + } + gravity |= cross_map.get(ai, 0) + + jc = props.get("justify_content") + if jc: + if vertical: + main_map = { + "flex_start": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, + } + else: + main_map = { + "flex_start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, + } + gravity |= main_map.get(jc, 0) + + if gravity: + ll.setGravity(gravity) + if "background_color" in props and props["background_color"] is not None: + ll.setBackgroundColor(parse_color_int(props["background_color"])) + + +# ====================================================================== +# Handlers +# ====================================================================== + + +class TextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tv = jclass("android.widget.TextView")(_ctx()) + self._apply(tv, props) + _apply_layout(tv, props) + return tv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, tv: Any, props: Dict[str, Any]) -> None: + if "text" in props: + tv.setText(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + tv.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + tv.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tv.setBackgroundColor(parse_color_int(props["background_color"])) + if "bold" in props and props["bold"]: + tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 + if "max_lines" in props and props["max_lines"] is not None: + tv.setMaxLines(int(props["max_lines"])) + if "text_align" in props: + Gravity = jclass("android.view.Gravity") + mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} + tv.setGravity(mapping.get(props["text_align"], Gravity.START)) + + +class ButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = jclass("android.widget.Button")(_ctx()) + self._apply(btn, props) + _apply_layout(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setText(str(props["title"])) + if "font_size" in props and props["font_size"] is not None: + btn.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + btn.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor(parse_color_int(props["background_color"])) + if "enabled" in props: + btn.setEnabled(bool(props["enabled"])) + if "on_click" in props: + cb = props["on_click"] + if cb is not None: + + class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(ClickProxy(cb)) + else: + btn.setOnClickListener(None) + + +class ColumnHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ll = jclass("android.widget.LinearLayout")(_ctx()) + ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) + _apply_stack_props(ll, props, vertical=True) + _apply_layout(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + _apply_stack_props(native_view, changed, vertical=True) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + +class RowHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ll = jclass("android.widget.LinearLayout")(_ctx()) + ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) + _apply_stack_props(ll, props, vertical=False) + _apply_layout(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + _apply_stack_props(native_view, changed, vertical=False) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + +class ScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = jclass("android.widget.ScrollView")(_ctx()) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor(parse_color_int(props["background_color"])) + _apply_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +class TextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + et = jclass("android.widget.EditText")(_ctx()) + self._apply(et, props) + _apply_layout(et, props) + return et + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, et: Any, props: Dict[str, Any]) -> None: + if "value" in props: + et.setText(str(props["value"])) + if "placeholder" in props: + et.setHint(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + et.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + et.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + et.setBackgroundColor(parse_color_int(props["background_color"])) + if "secure" in props and props["secure"]: + InputType = jclass("android.text.InputType") + et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + if "on_change" in props: + cb = props["on_change"] + if cb is not None: + TextWatcher = jclass("android.text.TextWatcher") + + class ChangeProxy(dynamic_proxy(TextWatcher)): + def __init__(self, callback: Callable[[str], None]) -> None: + super().__init__() + self.callback = callback + + def afterTextChanged(self, s: Any) -> None: + self.callback(str(s)) + + def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None: + pass + + def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None: + pass + + et.addTextChangedListener(ChangeProxy(cb)) + + +class ImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = jclass("android.widget.ImageView")(_ctx()) + self._apply(iv, props) + _apply_layout(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor(parse_color_int(props["background_color"])) + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + ScaleType = jclass("android.widget.ImageView$ScaleType") + mapping = { + "cover": ScaleType.CENTER_CROP, + "contain": ScaleType.FIT_CENTER, + "stretch": ScaleType.FIT_XY, + "center": ScaleType.CENTER, + } + st = mapping.get(props["scale_type"]) + if st: + iv.setScaleType(st) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + Thread = jclass("java.lang.Thread") + Runnable = jclass("java.lang.Runnable") + URL = jclass("java.net.URL") + BitmapFactory = jclass("android.graphics.BitmapFactory") + Handler = jclass("android.os.Handler") + Looper = jclass("android.os.Looper") + handler = Handler(Looper.getMainLooper()) + + class LoadTask(dynamic_proxy(Runnable)): + def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None: + super().__init__() + self.image_view = image_view + self.url_str = url_str + self.main_handler = main_handler + + def run(self) -> None: + try: + url = URL(self.url_str) + stream = url.openStream() + bitmap = BitmapFactory.decodeStream(stream) + stream.close() + + class SetImage(dynamic_proxy(Runnable)): + def __init__(self, view: Any, bmp: Any) -> None: + super().__init__() + self.view = view + self.bmp = bmp + + def run(self) -> None: + self.view.setImageBitmap(self.bmp) + + self.main_handler.post(SetImage(self.image_view, bitmap)) + except Exception: + pass + + Thread(LoadTask(iv, source, handler)).start() + else: + ctx = _ctx() + res = ctx.getResources() + pkg = ctx.getPackageName() + res_name = source.rsplit(".", 1)[0] if "." in source else source + res_id = res.getIdentifier(res_name, "drawable", pkg) + if res_id != 0: + iv.setImageResource(res_id) + except Exception: + pass + + +class SwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = jclass("android.widget.Switch")(_ctx()) + self._apply(sw, props) + _apply_layout(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setChecked(bool(props["value"])) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): + def __init__(self, callback: Callable[[bool], None]) -> None: + super().__init__() + self.callback = callback + + def onCheckedChanged(self, button: Any, checked: bool) -> None: + self.callback(checked) + + sw.setOnCheckedChangeListener(CheckedProxy(cb)) + + +class ProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + style = jclass("android.R$attr").progressBarStyleHorizontal + pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) + pb.setMax(1000) + self._apply(pb, props) + _apply_layout(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, pb: Any, props: Dict[str, Any]) -> None: + if "value" in props: + pb.setProgress(int(float(props["value"]) * 1000)) + + +class ActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pb = jclass("android.widget.ProgressBar")(_ctx()) + if not props.get("animating", True): + pb.setVisibility(jclass("android.view.View").GONE) + _apply_layout(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + View = jclass("android.view.View") + if "animating" in changed: + native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) + + +class WebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = jclass("android.webkit.WebView")(_ctx()) + if "url" in props and props["url"]: + wv.loadUrl(str(props["url"])) + _apply_layout(wv, props) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + native_view.loadUrl(str(changed["url"])) + + +class SpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = jclass("android.view.View")(_ctx()) + if "size" in props and props["size"] is not None: + px = _dp(float(props["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + v.setLayoutParams(lp) + if "flex" in props and props["flex"] is not None: + lp = v.getLayoutParams() + if lp is None: + lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0) + lp.weight = float(props["flex"]) + v.setLayoutParams(lp) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + px = _dp(float(changed["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + native_view.setLayoutParams(lp) + + +class GenericViewHandler(ViewHandler): + """Handler for the ``View`` element (FrameLayout container).""" + + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + if "background_color" in props and props["background_color"] is not None: + fl.setBackgroundColor(parse_color_int(props["background_color"])) + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + _apply_layout(fl, props) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + if "padding" in changed: + left, top, right, bottom = resolve_padding(changed["padding"]) + native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + +class SafeAreaViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setFitsSystemWindows(True) + if "background_color" in props and props["background_color"] is not None: + fl.setBackgroundColor(parse_color_int(props["background_color"])) + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +class ModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + placeholder = jclass("android.view.View")(_ctx()) + placeholder.setVisibility(jclass("android.view.View").GONE) + return placeholder + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + pass + + +class SliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sb = jclass("android.widget.SeekBar")(_ctx()) + sb.setMax(1000) + self._apply(sb, props) + _apply_layout(sb, props) + return sb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sb: Any, props: Dict[str, Any]) -> None: + min_val = float(props.get("min_value", 0)) + max_val = float(props.get("max_value", 1)) + rng = max_val - min_val if max_val != min_val else 1 + if "value" in props: + normalized = (float(props["value"]) - min_val) / rng + sb.setProgress(int(normalized * 1000)) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)): + def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None: + super().__init__() + self.callback = callback + self.mn = mn + self.rn = rn + + def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None: + if fromUser: + self.callback(self.mn + (progress / 1000.0) * self.rn) + + def onStartTrackingTouch(self, seekBar: Any) -> None: + pass + + def onStopTrackingTouch(self, seekBar: Any) -> None: + pass + + sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) + + +class PressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setClickable(True) + self._apply(fl, props) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, fl: Any, props: Dict[str, Any]) -> None: + if "on_press" in props and props["on_press"] is not None: + cb = props["on_press"] + + class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + fl.setOnClickListener(PressProxy(cb)) + if "on_long_press" in props and props["on_long_press"] is not None: + cb = props["on_long_press"] + + class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onLongClick(self, view: Any) -> bool: + self.callback() + return True + + fl.setOnLongClickListener(LongPressProxy(cb)) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +# ====================================================================== +# Registration +# ====================================================================== + + +def register_handlers(registry: Any) -> None: + """Register all Android view handlers with the given registry.""" + registry.register("Text", TextHandler()) + registry.register("Button", ButtonHandler()) + registry.register("Column", ColumnHandler()) + registry.register("Row", RowHandler()) + registry.register("ScrollView", ScrollViewHandler()) + registry.register("TextInput", TextInputHandler()) + registry.register("Image", ImageHandler()) + registry.register("Switch", SwitchHandler()) + registry.register("ProgressBar", ProgressBarHandler()) + registry.register("ActivityIndicator", ActivityIndicatorHandler()) + registry.register("WebView", WebViewHandler()) + registry.register("Spacer", SpacerHandler()) + registry.register("View", GenericViewHandler()) + registry.register("SafeAreaView", SafeAreaViewHandler()) + registry.register("Modal", ModalHandler()) + registry.register("Slider", SliderHandler()) + registry.register("Pressable", PressableHandler()) diff --git a/src/pythonnative/native_views/base.py b/src/pythonnative/native_views/base.py new file mode 100644 index 0000000..b36ec8a --- /dev/null +++ b/src/pythonnative/native_views/base.py @@ -0,0 +1,97 @@ +"""Shared base classes and utilities for native view handlers. + +Provides the :class:`ViewHandler` abstract base class and common helper +functions used by both Android and iOS platform implementations. +""" + +from typing import Any, Dict, Union + + +class ViewHandler: + """Protocol for creating, updating, and managing children of a native view type.""" + + def create(self, props: Dict[str, Any]) -> Any: + raise NotImplementedError + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + raise NotImplementedError + + def add_child(self, parent: Any, child: Any) -> None: + pass + + def remove_child(self, parent: Any, child: Any) -> None: + pass + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + self.add_child(parent, child) + + +# ====================================================================== +# Color parsing +# ====================================================================== + + +def parse_color_int(color: Union[str, int]) -> int: + """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. + + Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values + with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. + """ + if isinstance(color, int): + val = color + else: + c = color.strip().lstrip("#") + if len(c) == 6: + c = "FF" + c + val = int(c, 16) + if val > 0x7FFFFFFF: + val -= 0x100000000 + return val + + +# ====================================================================== +# Padding / margin helpers +# ====================================================================== + + +def resolve_padding(padding: Any) -> tuple: + """Normalise various padding representations to ``(left, top, right, bottom)``.""" + if padding is None: + return (0, 0, 0, 0) + if isinstance(padding, (int, float)): + v = int(padding) + return (v, v, v, v) + if isinstance(padding, dict): + h = int(padding.get("horizontal", 0)) + v = int(padding.get("vertical", 0)) + left = int(padding.get("left", h)) + right = int(padding.get("right", h)) + top = int(padding.get("top", v)) + bottom = int(padding.get("bottom", v)) + a = int(padding.get("all", 0)) + if a: + left = left or a + right = right or a + top = top or a + bottom = bottom or a + return (left, top, right, bottom) + return (0, 0, 0, 0) + + +# ====================================================================== +# Layout property keys +# ====================================================================== + +LAYOUT_KEYS = frozenset( + { + "width", + "height", + "flex", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + } +) diff --git a/src/pythonnative/native_views/ios.py b/src/pythonnative/native_views/ios.py new file mode 100644 index 0000000..a1af536 --- /dev/null +++ b/src/pythonnative/native_views/ios.py @@ -0,0 +1,614 @@ +"""iOS native view handlers (rubicon-objc). + +Each handler class maps a PythonNative element type to a UIKit widget, +implementing view creation, property updates, and child management. + +This module is only imported on iOS at runtime; desktop tests inject +a mock registry via :func:`~.set_registry` and never trigger this import. +""" + +from typing import Any, Callable, Dict, Optional + +from rubicon.objc import SEL, ObjCClass, objc_method + +from .base import LAYOUT_KEYS, ViewHandler, parse_color_int, resolve_padding + +NSObject = ObjCClass("NSObject") +UIColor = ObjCClass("UIColor") +UIFont = ObjCClass("UIFont") + + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def _uicolor(color: Any) -> Any: + """Convert a color value to a ``UIColor`` instance.""" + argb = parse_color_int(color) + if argb < 0: + argb += 0x100000000 + a = ((argb >> 24) & 0xFF) / 255.0 + r = ((argb >> 16) & 0xFF) / 255.0 + g = ((argb >> 8) & 0xFF) / 255.0 + b = (argb & 0xFF) / 255.0 + return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + + +def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout constraints to an iOS view.""" + if "width" in props and props["width"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 7: # NSLayoutAttributeWidth + c.setActive_(False) + view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True) + except Exception: + pass + if "height" in props and props["height"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 8: # NSLayoutAttributeHeight + c.setActive_(False) + view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) + except Exception: + pass + + +def _apply_stack_props(sv: Any, props: Dict[str, Any], *, vertical: bool) -> None: + """Apply spacing, alignment, distribution, background, and padding to a UIStackView. + + Column and Row handlers share identical logic except for axis-dependent + alignment constants. This helper consolidates that logic. + """ + if "spacing" in props and props["spacing"]: + sv.setSpacing_(float(props["spacing"])) + + ai = props.get("align_items") or props.get("alignment") + if ai: + if vertical: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "leading": 1, + "center": 3, + "flex_end": 4, + "trailing": 4, + } + else: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "top": 1, + "center": 3, + "flex_end": 4, + "bottom": 4, + } + sv.setAlignment_(alignment_map.get(ai, 0)) + + jc = props.get("justify_content") + if jc: + 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: + left, top, right, bottom = resolve_padding(props["padding"]) + sv.setLayoutMarginsRelativeArrangement_(True) + try: + sv.setDirectionalLayoutMargins_((top, left, bottom, right)) + except Exception: + sv.setLayoutMargins_((top, left, bottom, right)) + + +# ====================================================================== +# ObjC callback targets (retained at module level) +# ====================================================================== + +_pn_btn_handler_map: dict = {} +_pn_retained_views: list = [] + + +class _PNButtonTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[], None]] = None + + @objc_method + def onTap_(self, sender: object) -> None: + if self._callback is not None: + self._callback() + + +_pn_tf_handler_map: dict = {} + + +class _PNTextFieldTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[str], None]] = None + + @objc_method + def onEdit_(self, sender: object) -> None: + if self._callback is not None: + try: + text = str(sender.text) if sender and hasattr(sender, "text") else "" + self._callback(text) + except Exception: + pass + + +_pn_switch_handler_map: dict = {} + + +class _PNSwitchTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[bool], None]] = None + + @objc_method + def onToggle_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(bool(sender.isOn())) + except Exception: + pass + + +_pn_slider_handler_map: dict = {} + + +class _PNSliderTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[float], None]] = None + + @objc_method + def onSlide_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(float(sender.value)) + except Exception: + pass + + +# ====================================================================== +# Handlers +# ====================================================================== + + +class TextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + label = ObjCClass("UILabel").alloc().init() + self._apply(label, props) + _apply_ios_layout(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + if "text" in props: + label.setText_(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + if props.get("bold"): + label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) + else: + label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + elif "bold" in props and props["bold"]: + size = label.font().pointSize() if label.font() else 17.0 + label.setFont_(UIFont.boldSystemFontOfSize_(size)) + if "color" in props and props["color"] is not None: + label.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + label.setBackgroundColor_(_uicolor(props["background_color"])) + if "max_lines" in props and props["max_lines"] is not None: + label.setNumberOfLines_(int(props["max_lines"])) + if "text_align" in props: + mapping = {"left": 0, "center": 1, "right": 2} + label.setTextAlignment_(mapping.get(props["text_align"], 0)) + + +class ButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = ObjCClass("UIButton").alloc().init() + btn.retain() + _pn_retained_views.append(btn) + _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) + btn.setTitleColor_forState_(_ios_blue, 0) + self._apply(btn, props) + _apply_ios_layout(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setTitle_forState_(str(props["title"]), 0) + if "font_size" in props and props["font_size"] is not None: + btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor_(_uicolor(props["background_color"])) + if "color" not in props: + _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) + btn.setTitleColor_forState_(_white, 0) + if "color" in props and props["color"] is not None: + btn.setTitleColor_forState_(_uicolor(props["color"]), 0) + if "enabled" in props: + btn.setEnabled_(bool(props["enabled"])) + if "on_click" in props: + existing = _pn_btn_handler_map.get(id(btn)) + if existing is not None: + existing._callback = props["on_click"] + else: + handler = _PNButtonTarget.new() + handler._callback = props["on_click"] + _pn_btn_handler_map[id(btn)] = handler + btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) + + +class ColumnHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) + sv.setAxis_(1) # vertical + _apply_stack_props(sv, props, vertical=True) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + _apply_stack_props(native_view, changed, vertical=True) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + +class RowHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) + sv.setAxis_(0) # horizontal + _apply_stack_props(sv, props, vertical=False) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + _apply_stack_props(native_view, changed, vertical=False) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + +class ScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIScrollView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + child.setTranslatesAutoresizingMaskIntoConstraints_(False) + parent.addSubview_(child) + content_guide = parent.contentLayoutGuide + frame_guide = parent.frameLayoutGuide + child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) + child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) + child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) + child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) + child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +class TextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tf = ObjCClass("UITextField").alloc().init() + tf.setBorderStyle_(2) # RoundedRect + self._apply(tf, props) + _apply_ios_layout(tf, props) + return tf + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, tf: Any, props: Dict[str, Any]) -> None: + if "value" in props: + tf.setText_(str(props["value"])) + if "placeholder" in props: + tf.setPlaceholder_(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "color" in props and props["color"] is not None: + tf.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tf.setBackgroundColor_(_uicolor(props["background_color"])) + if "secure" in props and props["secure"]: + tf.setSecureTextEntry_(True) + if "on_change" in props: + existing = _pn_tf_handler_map.get(id(tf)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNTextFieldTarget.new() + handler._callback = props["on_change"] + _pn_tf_handler_map[id(tf)] = handler + tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17) + + +class ImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = ObjCClass("UIImageView").alloc().init() + self._apply(iv, props) + _apply_ios_layout(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor_(_uicolor(props["background_color"])) + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4} + iv.setContentMode_(mapping.get(props["scale_type"], 1)) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + NSURL = ObjCClass("NSURL") + NSData = ObjCClass("NSData") + UIImage = ObjCClass("UIImage") + url = NSURL.URLWithString_(source) + data = NSData.dataWithContentsOfURL_(url) + if data: + image = UIImage.imageWithData_(data) + if image: + iv.setImage_(image) + else: + UIImage = ObjCClass("UIImage") + image = UIImage.imageNamed_(source) + if image: + iv.setImage_(image) + except Exception: + pass + + +class SwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = ObjCClass("UISwitch").alloc().init() + self._apply(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setOn_animated_(bool(props["value"]), False) + if "on_change" in props: + existing = _pn_switch_handler_map.get(id(sw)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSwitchTarget.new() + handler._callback = props["on_change"] + _pn_switch_handler_map[id(sw)] = handler + sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12) + + +class ProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pv = ObjCClass("UIProgressView").alloc().init() + if "value" in props: + pv.setProgress_(float(props["value"])) + _apply_ios_layout(pv, props) + return pv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "value" in changed: + native_view.setProgress_(float(changed["value"])) + + +class ActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ai = ObjCClass("UIActivityIndicatorView").alloc().init() + if props.get("animating", True): + ai.startAnimating() + return ai + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "animating" in changed: + if changed["animating"]: + native_view.startAnimating() + else: + native_view.stopAnimating() + + +class WebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = ObjCClass("WKWebView").alloc().init() + if "url" in props and props["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(props["url"])) + wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + _apply_ios_layout(wv, props) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(changed["url"])) + native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + + +class SpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "size" in props and props["size"] is not None: + size = float(props["size"]) + v.setFrame_(((0, 0), (size, size))) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + size = float(changed["size"]) + native_view.setFrame_(((0, 0), (size, size))) + + +class GenericViewHandler(ViewHandler): + """Handler for the ``View`` element (generic UIView container).""" + + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + v.setBackgroundColor_(_uicolor(props["background_color"])) + _apply_ios_layout(v, props) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +class SafeAreaViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + v.setBackgroundColor_(_uicolor(props["background_color"])) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +class ModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setHidden_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + +class SliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sl = ObjCClass("UISlider").alloc().init() + self._apply(sl, props) + _apply_ios_layout(sl, props) + return sl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sl: Any, props: Dict[str, Any]) -> None: + if "min_value" in props: + sl.setMinimumValue_(float(props["min_value"])) + if "max_value" in props: + sl.setMaximumValue_(float(props["max_value"])) + if "value" in props: + sl.setValue_(float(props["value"])) + if "on_change" in props: + existing = _pn_slider_handler_map.get(id(sl)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSliderTarget.new() + handler._callback = props["on_change"] + _pn_slider_handler_map[id(sl)] = handler + sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) + + +class PressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setUserInteractionEnabled_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +# ====================================================================== +# Registration +# ====================================================================== + + +def register_handlers(registry: Any) -> None: + """Register all iOS view handlers with the given registry.""" + registry.register("Text", TextHandler()) + registry.register("Button", ButtonHandler()) + registry.register("Column", ColumnHandler()) + registry.register("Row", RowHandler()) + registry.register("ScrollView", ScrollViewHandler()) + registry.register("TextInput", TextInputHandler()) + registry.register("Image", ImageHandler()) + registry.register("Switch", SwitchHandler()) + registry.register("ProgressBar", ProgressBarHandler()) + registry.register("ActivityIndicator", ActivityIndicatorHandler()) + registry.register("WebView", WebViewHandler()) + registry.register("Spacer", SpacerHandler()) + registry.register("View", GenericViewHandler()) + registry.register("SafeAreaView", SafeAreaViewHandler()) + registry.register("Modal", ModalHandler()) + registry.register("Slider", SliderHandler()) + registry.register("Pressable", PressableHandler()) diff --git a/tests/test_native_views.py b/tests/test_native_views.py new file mode 100644 index 0000000..09bc492 --- /dev/null +++ b/tests/test_native_views.py @@ -0,0 +1,199 @@ +"""Unit tests for the native_views package. + +Tests the registry, base handler protocol, and shared utility functions. +Platform-specific handlers (android/ios) are not tested here since they +require their respective runtime environments; they are exercised by +E2E tests on device. +""" + +from typing import Any, Dict + +import pytest + +from pythonnative.native_views import NativeViewRegistry, set_registry +from pythonnative.native_views.base import ( + LAYOUT_KEYS, + ViewHandler, + parse_color_int, + resolve_padding, +) + +# ====================================================================== +# parse_color_int +# ====================================================================== + + +def test_parse_color_hex6() -> None: + result = parse_color_int("#FF0000") + assert result == parse_color_int("FF0000") + expected = int("FFFF0000", 16) + if expected > 0x7FFFFFFF: + expected -= 0x100000000 + assert result == expected + + +def test_parse_color_hex8() -> None: + result = parse_color_int("#80FF0000") + raw = int("80FF0000", 16) + expected = raw - 0x100000000 # signed conversion + assert result == expected + + +def test_parse_color_int_passthrough() -> None: + assert parse_color_int(0x00FF00) == 0x00FF00 + + +def test_parse_color_signed_conversion() -> None: + result = parse_color_int("#FFFFFFFF") + assert result < 0 + + +def test_parse_color_with_whitespace() -> None: + assert parse_color_int(" #FF0000 ") == parse_color_int("#FF0000") + + +# ====================================================================== +# resolve_padding +# ====================================================================== + + +def test_resolve_padding_none() -> None: + assert resolve_padding(None) == (0, 0, 0, 0) + + +def test_resolve_padding_int() -> None: + assert resolve_padding(16) == (16, 16, 16, 16) + + +def test_resolve_padding_float() -> None: + assert resolve_padding(8.5) == (8, 8, 8, 8) + + +def test_resolve_padding_dict_horizontal_vertical() -> None: + result = resolve_padding({"horizontal": 10, "vertical": 20}) + assert result == (10, 20, 10, 20) + + +def test_resolve_padding_dict_individual() -> None: + result = resolve_padding({"left": 1, "top": 2, "right": 3, "bottom": 4}) + assert result == (1, 2, 3, 4) + + +def test_resolve_padding_dict_all() -> None: + result = resolve_padding({"all": 12}) + assert result == (12, 12, 12, 12) + + +def test_resolve_padding_unsupported_type() -> None: + assert resolve_padding("invalid") == (0, 0, 0, 0) + + +# ====================================================================== +# LAYOUT_KEYS +# ====================================================================== + + +def test_layout_keys_contains_expected() -> None: + expected = {"width", "height", "flex", "margin", "min_width", "max_width", "min_height", "max_height", "align_self"} + assert expected == LAYOUT_KEYS + + +# ====================================================================== +# ViewHandler protocol +# ====================================================================== + + +def test_view_handler_create_raises() -> None: + handler = ViewHandler() + with pytest.raises(NotImplementedError): + handler.create({}) + + +def test_view_handler_update_raises() -> None: + handler = ViewHandler() + with pytest.raises(NotImplementedError): + handler.update(None, {}) + + +def test_view_handler_add_child_noop() -> None: + handler = ViewHandler() + handler.add_child(None, None) + + +def test_view_handler_remove_child_noop() -> None: + handler = ViewHandler() + handler.remove_child(None, None) + + +def test_view_handler_insert_child_delegates() -> None: + calls: list = [] + + class TestHandler(ViewHandler): + def add_child(self, parent: Any, child: Any) -> None: + calls.append(("add", parent, child)) + + handler = TestHandler() + handler.insert_child("parent", "child", 0) + assert calls == [("add", "parent", "child")] + + +# ====================================================================== +# NativeViewRegistry +# ====================================================================== + + +class StubView: + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + self.type_name = type_name + self.props = dict(props) + + +class StubHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> StubView: + return StubView("Stub", props) + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + native_view.props.update(changed_props) + + +def test_registry_create_view() -> None: + reg = NativeViewRegistry() + reg.register("Text", StubHandler()) + view = reg.create_view("Text", {"text": "hello"}) + assert isinstance(view, StubView) + assert view.props["text"] == "hello" + + +def test_registry_unknown_type_raises() -> None: + reg = NativeViewRegistry() + with pytest.raises(ValueError, match="Unknown element type"): + reg.create_view("NonExistent", {}) + + +def test_registry_update_view() -> None: + reg = NativeViewRegistry() + reg.register("Text", StubHandler()) + view = reg.create_view("Text", {"text": "old"}) + reg.update_view(view, "Text", {"text": "new"}) + assert view.props["text"] == "new" + + +def test_registry_update_unknown_type_noop() -> None: + reg = NativeViewRegistry() + reg.update_view(StubView("X", {}), "X", {"a": 1}) + + +def test_registry_child_ops_unknown_type_noop() -> None: + reg = NativeViewRegistry() + reg.add_child(None, None, "Unknown") + reg.remove_child(None, None, "Unknown") + reg.insert_child(None, None, "Unknown", 0) + + +def test_set_registry_injects() -> None: + reg = NativeViewRegistry() + set_registry(reg) + from pythonnative.native_views import _registry + + assert _registry is reg + set_registry(None) From 094d99786f7153a7286eb7db9775db0bb90abf1d Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:16:52 -0700 Subject: [PATCH 30/34] feat(native_views,components)!: add flexbox-inspired layout system --- docs/api/component-properties.md | 49 ++++-- docs/concepts/architecture.md | 30 +++- docs/concepts/components.md | 49 +++++- docs/guides/styling.md | 86 ++++++++-- examples/hello-world/app/main_page.py | 19 ++- examples/hello-world/app/second_page.py | 4 +- examples/hello-world/app/third_page.py | 12 +- src/pythonnative/components.py | 76 ++++++--- src/pythonnative/native_views/android.py | 203 ++++++++++++----------- src/pythonnative/native_views/base.py | 53 ++++++ src/pythonnative/native_views/ios.py | 156 +++++++++-------- tests/test_components.py | 57 ++++++- tests/test_native_views.py | 57 ++++++- 13 files changed, 598 insertions(+), 253 deletions(-) diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index cda58d7..db397f8 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -8,13 +8,39 @@ All components accept these layout properties in their `style` dict: - `width` — fixed width in dp (Android) / pt (iOS) - `height` — fixed height -- `flex` — flex grow factor within Column/Row +- `flex` — flex grow factor (shorthand for `flex_grow`) +- `flex_grow` — how much a child grows to fill available space +- `flex_shrink` — how much a child shrinks when space is limited - `margin` — outer spacing (int, float, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) - `min_width`, `max_width` — width constraints - `min_height`, `max_height` — height constraints -- `align_self` — override parent alignment (`"fill"`, `"center"`, etc.) +- `align_self` — override parent alignment (`"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`) - `key` — stable identity for reconciliation (passed as a kwarg, not inside `style`) +## View + +```python +pn.View(*children, style={ + "flex_direction": "column", + "justify_content": "center", + "align_items": "center", + "overflow": "hidden", + "spacing": 8, + "padding": 16, + "background_color": "#F5F5F5", +}) +``` + +Universal flex container (like React Native's `View`). Defaults to `flex_direction: "column"`. + +Flex container properties (inside `style`): + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing`, `padding`, `background_color` + ## Text ```python @@ -42,23 +68,20 @@ pn.Column(*children, style={"spacing": 12, "padding": 16, "align_items": "center pn.Row(*children, style={"spacing": 8, "justify_content": "space_between"}) ``` +Convenience wrappers for `View` with fixed `flex_direction`: + +- `Column` = `View` with `flex_direction: "column"` (always vertical) +- `Row` = `View` with `flex_direction: "row"` (always horizontal) + - `*children` — child elements (positional) - Style properties: - `spacing` — gap between children (dp / pt) - `padding` — inner padding (int for all sides, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) - - `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"` + - `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`, `"leading"`, `"trailing"` + - `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` + - `overflow` — `"visible"` (default), `"hidden"` - `background_color` — container background -## View - -```python -pn.View(*children, style={"background_color": "#F5F5F5", "padding": 16}) -``` - -Generic container (UIView / FrameLayout). Supports all layout properties in `style`. - ## SafeAreaView ```python diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 677d6d7..1a3f3a5 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -54,16 +54,42 @@ The entry point `create_page()` is called internally by native templates to boot ## Layout -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`. +PythonNative uses a **flexbox-inspired layout model** built on platform-native layout managers. + +`View` is the **universal flex container** (like React Native's `View`). It defaults to `flex_direction: "column"`. `Column` and `Row` are convenience wrappers that fix the direction. + +### Flex container properties (inside `style`) + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing + +### Child layout properties + +- `flex` — flex grow factor (shorthand) +- `flex_grow`, `flex_shrink` — individual flex properties +- `align_self` — override the parent's `align_items` for this child +- `width`, `height` — fixed dimensions +- `min_width`, `min_height` — minimum size constraints +- `margin` — outer spacing + +Under the hood: +- **Android:** `LinearLayout` with gravity, weights, and divider-based spacing +- **iOS:** `UIStackView` with axis, alignment, distribution, and layout margins ## Native view handlers Platform-specific rendering logic lives in the `native_views` package, organised into dedicated submodules: -- `native_views.base` — shared `ViewHandler` protocol and common utilities (colour parsing, padding resolution, layout keys) +- `native_views.base` — shared `ViewHandler` protocol and common utilities (colour parsing, padding resolution, layout keys, flex constants) - `native_views.android` — Android handlers using Chaquopy's Java bridge (`jclass`, `dynamic_proxy`) - `native_views.ios` — iOS handlers using rubicon-objc (`ObjCClass`, `objc_method`) +Column, Row, and View share a single `FlexContainerHandler` class on each platform. The handler reads `flex_direction` from the element's props to configure the native layout container. + Each handler class maps an element type name (e.g. `"Text"`, `"Button"`) to platform-native widget creation, property updates, and child management. The `NativeViewRegistry` lazily imports only the relevant platform module at runtime, so the package can be imported on any platform for testing. ## Comparison diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 6a2db75..934bf67 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -22,10 +22,10 @@ pn.Column( **Layout:** -- `Column(*children, style=...)` — vertical stack -- `Row(*children, style=...)` — horizontal stack +- `View(*children, style=...)` — universal flex container (default `flex_direction: "column"`) +- `Column(*children, style=...)` — vertical flex container (fixed `flex_direction: "column"`) +- `Row(*children, style=...)` — horizontal flex container (fixed `flex_direction: "row"`) - `ScrollView(child, style=...)` — scrollable container -- `View(*children, style=...)` — generic container - `SafeAreaView(*children, style=...)` — safe-area-aware container - `Spacer(size, flex)` — empty space @@ -56,16 +56,51 @@ pn.Column( - `FlatList(data, render_item, key_extractor, separator_height)` — scrollable data list -### Layout properties +### Flex layout model -All components accept layout properties inside the `style` dict: +PythonNative uses a **flexbox-inspired layout model**. `View` is the universal flex container — `Column` and `Row` are convenience wrappers. + +#### Flex container properties (inside `style`) + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing + +#### Child layout properties + +All components accept these in their `style` dict: - `width`, `height` — fixed dimensions (dp / pt) -- `flex` — flex grow factor +- `flex` — flex grow factor (shorthand) +- `flex_grow`, `flex_shrink` — individual flex properties - `margin` — outer margin (int, float, or dict like padding) -- `min_width`, `max_width`, `min_height`, `max_height` — size constraints +- `min_width`, `min_height` — minimum size constraints +- `max_width`, `max_height` — maximum size constraints - `align_self` — override parent alignment for this child +#### Example: centering content + +```python +pn.View( + pn.Text("Centered"), + style={"flex": 1, "justify_content": "center", "align_items": "center"}, +) +``` + +#### Example: horizontal row with spacing + +```python +pn.Row( + pn.Button("Cancel"), + pn.Spacer(flex=1), + pn.Button("OK"), + style={"padding": 16, "align_items": "center"}, +) +``` + ## Function components — the building block All UI in PythonNative is built with `@pn.component` function components. Each screen is a function component that returns an element tree: diff --git a/docs/guides/styling.md b/docs/guides/styling.md index 1dd2026..9556fe6 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -22,7 +22,7 @@ import pythonnative as pn styles = pn.StyleSheet.create( title={"font_size": 28, "bold": True, "color": "#333"}, subtitle={"font_size": 14, "color": "#666"}, - container={"padding": 16, "spacing": 12, "alignment": "fill"}, + container={"padding": 16, "spacing": 12, "align_items": "stretch"}, ) pn.Text("Welcome", style=styles["title"]) @@ -78,25 +78,80 @@ pn.Text("Title", style={"font_size": 24, "bold": True, "text_align": "center"}) pn.Text("Subtitle", style={"font_size": 14, "color": "#666666"}) ``` -## Layout properties +## Flex layout -All components support common layout properties inside `style`: +PythonNative uses a flexbox-inspired layout model. `View` is the universal flex container, and `Column`/`Row` are convenience wrappers. -```python -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}}) -``` +### Flex container properties + +These go in the `style` dict of `View`, `Column`, or `Row`: + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` (only for `View`; `Column`/`Row` have fixed directions) +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing (int for all sides, or dict) + +### Child layout properties + +All components accept these in `style`: - `width`, `height` — fixed dimensions in dp (Android) / pt (iOS) -- `flex` — flex grow factor within Column/Row +- `flex` — flex grow factor (shorthand for `flex_grow`) +- `flex_grow` — how much a child should grow to fill available space +- `flex_shrink` — how much a child should shrink when space is tight - `margin` — outer spacing (int for all sides, or dict) -- `min_width`, `max_width`, `min_height`, `max_height` — size constraints -- `align_self` — override parent alignment +- `min_width`, `min_height` — minimum size constraints +- `max_width`, `max_height` — maximum size constraints +- `align_self` — override parent alignment: `"flex_start"`, `"center"`, `"flex_end"`, `"stretch"` + +### Layout examples + +**Centering content:** + +```python +pn.View( + pn.Text("Centered!"), + style={"flex": 1, "justify_content": "center", "align_items": "center"}, +) +``` + +**Horizontal row with spacer:** + +```python +pn.Row( + pn.Text("Left"), + pn.Spacer(flex=1), + pn.Text("Right"), + style={"padding": 16, "align_items": "center"}, +) +``` + +**Child with flex grow:** + +```python +pn.Column( + pn.Text("Header", style={"font_size": 20, "bold": True}), + pn.View(pn.Text("Content area"), style={"flex": 1}), + pn.Text("Footer"), + style={"flex": 1, "spacing": 8}, +) +``` + +**Horizontal button bar:** + +```python +pn.Row( + pn.Button("Cancel", style={"flex": 1}), + pn.Button("OK", style={"flex": 1, "background_color": "#007AFF", "color": "#FFF"}), + style={"spacing": 8, "padding": 16}, +) +``` ## Layout with Column and Row -`Column` (vertical) and `Row` (horizontal): +`Column` (vertical) and `Row` (horizontal) are convenience wrappers for `View`: ```python pn.Column( @@ -105,7 +160,7 @@ pn.Column( pn.Text("Password"), pn.TextInput(placeholder="Enter password", secure=True), pn.Button("Login", on_click=handle_login), - style={"spacing": 8, "padding": 16, "alignment": "fill"}, + style={"spacing": 8, "padding": 16, "align_items": "stretch"}, ) ``` @@ -113,9 +168,8 @@ pn.Column( Column and Row support `align_items` and `justify_content` inside `style`: -- **`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`) +- **`align_items`** — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`, `"leading"`, `"trailing"` +- **`justify_content`** — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` ```python pn.Row( diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index d524568..5cfd54c 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -9,7 +9,14 @@ title={"font_size": 24, "bold": True}, subtitle={"font_size": 16, "color": "#666666"}, medal={"font_size": 32}, - section={"spacing": 12, "padding": 16, "align_items": "stretch"}, + card={ + "spacing": 12, + "padding": 16, + "background_color": "#F8F9FA", + "align_items": "center", + }, + section={"spacing": 16, "padding": 24, "align_items": "stretch"}, + button_row={"spacing": 8, "align_items": "center"}, ) @@ -19,11 +26,15 @@ def counter_badge(initial: int = 0) -> pn.Element: count, set_count = pn.use_state(initial) medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") - return pn.Column( + return pn.View( 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)), - style={"spacing": 4}, + pn.Row( + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + pn.Button("Reset", on_click=lambda: set_count(0)), + style=styles["button_row"], + ), + style=styles["card"], ) diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 8783d41..bcbd522 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -7,12 +7,12 @@ def SecondPage() -> pn.Element: message = nav.get_args().get("message", "Second Page") return pn.ScrollView( pn.Column( - pn.Text(message, style={"font_size": 20}), + pn.Text(message, style={"font_size": 24, "bold": True}), 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"}, + style={"spacing": 16, "padding": 24, "align_items": "stretch"}, ) ) diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index 3ebc174..a3e3cab 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -4,9 +4,11 @@ @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"}, + return pn.ScrollView( + pn.Column( + pn.Text("Third Page", style={"font_size": 24, "bold": True}), + pn.Text("You navigated two levels deep."), + pn.Button("Back to Second", on_click=nav.pop), + style={"spacing": 16, "padding": 24, "align_items": "stretch"}, + ) ) diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py index 820f001..4dd6455 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -9,12 +9,18 @@ Layout properties supported by all components:: - width, height, flex, margin, min_width, max_width, min_height, - max_height, align_self + width, height, flex, flex_grow, flex_shrink, margin, + min_width, max_width, min_height, max_height, align_self -Container-specific layout properties (Column / Row):: +Flex container properties (View / Column / Row):: - spacing, padding, align_items, justify_content + flex_direction, justify_content, align_items, overflow, + spacing, padding + +``View`` is the universal flex container (like React Native's ``View``). +It defaults to ``flex_direction: "column"``. ``Column`` and ``Row`` +are convenience wrappers that fix the direction to ``"column"`` and +``"row"`` respectively. """ from typing import Any, Callable, Dict, List, Optional @@ -204,15 +210,48 @@ def Slider( # ====================================================================== +def View( + *children: Element, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Universal flex container (like React Native's ``View``). + + Defaults to ``flex_direction: "column"``. Override via ``style``:: + + pn.View(child_a, child_b, style={"flex_direction": "row"}) + + Flex container properties (inside ``style``): + + - ``flex_direction`` — ``"column"`` (default), ``"row"``, + ``"column_reverse"``, ``"row_reverse"`` + - ``justify_content`` — main-axis distribution: + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"`` + - ``align_items`` — cross-axis alignment: + ``"stretch"`` (default), ``"flex_start"``, ``"center"``, + ``"flex_end"`` + - ``overflow`` — ``"visible"`` (default) or ``"hidden"`` + - ``spacing``, ``padding``, ``background_color`` + """ + props: Dict[str, Any] = {"flex_direction": "column"} + props.update(resolve_style(style)) + return Element("View", props, list(children), key=key) + + def Column( *children: Element, style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Arrange children vertically. + """Arrange children vertically (``flex_direction: "column"``). + + Convenience wrapper around :func:`View`. The direction is fixed; + use :func:`View` directly if you need ``flex_direction: "row"``. Style properties: ``spacing``, ``padding``, ``align_items``, - ``justify_content``, ``background_color``, plus common layout props. + ``justify_content``, ``background_color``, ``overflow``, + plus common layout props. ``align_items`` controls cross-axis (horizontal) alignment: ``"stretch"`` (default), ``"flex_start"``/``"leading"``, @@ -222,8 +261,9 @@ def Column( ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, ``"space_between"``, ``"space_around"``, ``"space_evenly"``. """ - props: Dict[str, Any] = {} + props: Dict[str, Any] = {"flex_direction": "column"} props.update(resolve_style(style)) + props["flex_direction"] = "column" return Element("Column", props, list(children), key=key) @@ -232,10 +272,14 @@ def Row( style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Arrange children horizontally. + """Arrange children horizontally (``flex_direction: "row"``). + + Convenience wrapper around :func:`View`. The direction is fixed; + use :func:`View` directly if you need ``flex_direction: "column"``. Style properties: ``spacing``, ``padding``, ``align_items``, - ``justify_content``, ``background_color``, plus common layout props. + ``justify_content``, ``background_color``, ``overflow``, + plus common layout props. ``align_items`` controls cross-axis (vertical) alignment: ``"stretch"`` (default), ``"flex_start"``/``"top"``, @@ -245,8 +289,9 @@ def Row( ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, ``"space_between"``, ``"space_around"``, ``"space_evenly"``. """ - props: Dict[str, Any] = {} + props: Dict[str, Any] = {"flex_direction": "row"} props.update(resolve_style(style)) + props["flex_direction"] = "row" return Element("Row", props, list(children), key=key) @@ -263,17 +308,6 @@ def ScrollView( return Element("ScrollView", props, children, key=key) -def View( - *children: Element, - style: StyleValue = None, - key: Optional[str] = None, -) -> Element: - """Generic container view (``UIView`` / ``android.view.View``).""" - props: Dict[str, Any] = {} - props.update(resolve_style(style)) - return Element("View", props, list(children), key=key) - - def SafeAreaView( *children: Element, style: StyleValue = None, diff --git a/src/pythonnative/native_views/android.py b/src/pythonnative/native_views/android.py index 5a0db11..1e59d82 100644 --- a/src/pythonnative/native_views/android.py +++ b/src/pythonnative/native_views/android.py @@ -12,7 +12,7 @@ from java import dynamic_proxy, jclass from ..utils import get_android_context -from .base import LAYOUT_KEYS, ViewHandler, parse_color_int, resolve_padding +from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding # ====================================================================== # Shared helpers @@ -32,10 +32,11 @@ def _dp(value: float) -> int: def _apply_layout(view: Any, props: Dict[str, Any]) -> None: - """Apply common layout properties to an Android view.""" + """Apply common layout properties (child-level flex props) to an Android view.""" lp = view.getLayoutParams() LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + Gravity = jclass("android.view.Gravity") needs_set = False if lp is None: @@ -48,12 +49,21 @@ def _apply_layout(view: Any, props: Dict[str, Any]) -> None: if "height" in props and props["height"] is not None: lp.height = _dp(float(props["height"])) needs_set = True - if "flex" in props and props["flex"] is not None: + + flex = props.get("flex") + flex_grow = props.get("flex_grow") + weight = None + if flex is not None: + weight = float(flex) + elif flex_grow is not None: + weight = float(flex_grow) + if weight is not None: try: - lp.weight = float(props["flex"]) + lp.weight = weight needs_set = True except Exception: pass + if "margin" in props and props["margin"] is not None: left, top, right, bottom = resolve_padding(props["margin"]) try: @@ -62,30 +72,69 @@ def _apply_layout(view: Any, props: Dict[str, Any]) -> None: except Exception: pass + if "align_self" in props and props["align_self"] is not None: + align_map = { + "flex_start": Gravity.START | Gravity.TOP, + "leading": Gravity.START | Gravity.TOP, + "center": Gravity.CENTER, + "flex_end": Gravity.END | Gravity.BOTTOM, + "trailing": Gravity.END | Gravity.BOTTOM, + "stretch": Gravity.FILL, + } + g = align_map.get(props["align_self"]) + if g is not None: + lp.gravity = g + needs_set = True + if needs_set: view.setLayoutParams(lp) + if "min_width" in props and props["min_width"] is not None: + view.setMinimumWidth(_dp(float(props["min_width"]))) + if "min_height" in props and props["min_height"] is not None: + view.setMinimumHeight(_dp(float(props["min_height"]))) + -def _apply_stack_props(ll: Any, props: Dict[str, Any], *, vertical: bool) -> None: - """Apply spacing, padding, alignment, and background to a Column/Row LinearLayout. +def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None: + """Apply visual properties shared across many handlers.""" + if "background_color" in props and props["background_color"] is not None: + view.setBackgroundColor(parse_color_int(props["background_color"])) + if "overflow" in props: + clip = props["overflow"] == "hidden" + try: + view.setClipChildren(clip) + view.setClipToPadding(clip) + except Exception: + pass - Column and Row handlers share identical logic except for axis-dependent - constants. This helper consolidates that logic. + +def _apply_flex_container(container: Any, props: Dict[str, Any]) -> None: + """Apply flex container properties to a LinearLayout. + + Handles spacing, padding, alignment, justification, background, and overflow. """ + LinearLayout = jclass("android.widget.LinearLayout") Gravity = jclass("android.view.Gravity") + if "flex_direction" in props: + vertical = is_vertical(props["flex_direction"]) + container.setOrientation(LinearLayout.VERTICAL if vertical else LinearLayout.HORIZONTAL) + + direction = props.get("flex_direction", "column") + vertical = is_vertical(direction) + if "spacing" in props and props["spacing"]: px = _dp(float(props["spacing"])) GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") d = GradientDrawable() d.setColor(0x00000000) d.setSize(1 if vertical else px, px if vertical else 1) - ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) - ll.setDividerDrawable(d) + container.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE) + container.setDividerDrawable(d) if "padding" in props: left, top, right, bottom = resolve_padding(props["padding"]) - ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + container.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) gravity = 0 ai = props.get("align_items") or props.get("alignment") @@ -131,13 +180,50 @@ def _apply_stack_props(ll: Any, props: Dict[str, Any], *, vertical: bool) -> Non 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"])) + container.setGravity(gravity) + + _apply_common_visual(container, props) + + +# ====================================================================== +# Flex container handler (shared by Column, Row, View) +# ====================================================================== + + +class FlexContainerHandler(ViewHandler): + """Unified handler for flex layout containers (Column, Row, View). + + All three element types use ``LinearLayout`` with orientation + determined by the ``flex_direction`` prop. + """ + + def create(self, props: Dict[str, Any]) -> Any: + ll = jclass("android.widget.LinearLayout")(_ctx()) + direction = props.get("flex_direction", "column") + LinearLayout = jclass("android.widget.LinearLayout") + ll.setOrientation(LinearLayout.VERTICAL if is_vertical(direction) else LinearLayout.HORIZONTAL) + _apply_flex_container(ll, props) + _apply_layout(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if changed.keys() & CONTAINER_KEYS: + _apply_flex_container(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) # ====================================================================== -# Handlers +# Leaf handlers # ====================================================================== @@ -212,52 +298,6 @@ def onClick(self, view: Any) -> None: btn.setOnClickListener(None) -class ColumnHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ll = jclass("android.widget.LinearLayout")(_ctx()) - ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) - _apply_stack_props(ll, props, vertical=True) - _apply_layout(ll, props) - return ll - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - _apply_stack_props(native_view, changed, vertical=True) - if changed.keys() & LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - -class RowHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ll = jclass("android.widget.LinearLayout")(_ctx()) - ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) - _apply_stack_props(ll, props, vertical=False) - _apply_layout(ll, props) - return ll - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - _apply_stack_props(native_view, changed, vertical=False) - if changed.keys() & LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - class ScrollViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sv = jclass("android.widget.ScrollView")(_ctx()) @@ -500,39 +540,9 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: native_view.setLayoutParams(lp) -class GenericViewHandler(ViewHandler): - """Handler for the ``View`` element (FrameLayout container).""" - - def create(self, props: Dict[str, Any]) -> Any: - fl = jclass("android.widget.FrameLayout")(_ctx()) - if "background_color" in props and props["background_color"] is not None: - fl.setBackgroundColor(parse_color_int(props["background_color"])) - if "padding" in props: - left, top, right, bottom = resolve_padding(props["padding"]) - fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - _apply_layout(fl, props) - return fl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor(parse_color_int(changed["background_color"])) - if "padding" in changed: - left, top, right, bottom = resolve_padding(changed["padding"]) - native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - if changed.keys() & LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - class SafeAreaViewHandler(ViewHandler): + """Safe-area container using FrameLayout with ``fitsSystemWindows``.""" + def create(self, props: Dict[str, Any]) -> Any: fl = jclass("android.widget.FrameLayout")(_ctx()) fl.setFitsSystemWindows(True) @@ -659,10 +669,12 @@ def remove_child(self, parent: Any, child: Any) -> None: def register_handlers(registry: Any) -> None: """Register all Android view handlers with the given registry.""" + flex = FlexContainerHandler() registry.register("Text", TextHandler()) registry.register("Button", ButtonHandler()) - registry.register("Column", ColumnHandler()) - registry.register("Row", RowHandler()) + registry.register("Column", flex) + registry.register("Row", flex) + registry.register("View", flex) registry.register("ScrollView", ScrollViewHandler()) registry.register("TextInput", TextInputHandler()) registry.register("Image", ImageHandler()) @@ -671,7 +683,6 @@ def register_handlers(registry: Any) -> None: registry.register("ActivityIndicator", ActivityIndicatorHandler()) registry.register("WebView", WebViewHandler()) registry.register("Spacer", SpacerHandler()) - registry.register("View", GenericViewHandler()) registry.register("SafeAreaView", SafeAreaViewHandler()) registry.register("Modal", ModalHandler()) registry.register("Slider", SliderHandler()) diff --git a/src/pythonnative/native_views/base.py b/src/pythonnative/native_views/base.py index b36ec8a..e644968 100644 --- a/src/pythonnative/native_views/base.py +++ b/src/pythonnative/native_views/base.py @@ -78,6 +78,40 @@ def resolve_padding(padding: Any) -> tuple: return (0, 0, 0, 0) +# ====================================================================== +# Flex layout constants +# ====================================================================== + +FLEX_DIRECTION_COLUMN = "column" +FLEX_DIRECTION_ROW = "row" +FLEX_DIRECTION_COLUMN_REVERSE = "column_reverse" +FLEX_DIRECTION_ROW_REVERSE = "row_reverse" + +JUSTIFY_FLEX_START = "flex_start" +JUSTIFY_CENTER = "center" +JUSTIFY_FLEX_END = "flex_end" +JUSTIFY_SPACE_BETWEEN = "space_between" +JUSTIFY_SPACE_AROUND = "space_around" +JUSTIFY_SPACE_EVENLY = "space_evenly" + +ALIGN_STRETCH = "stretch" +ALIGN_FLEX_START = "flex_start" +ALIGN_CENTER = "center" +ALIGN_FLEX_END = "flex_end" + +POSITION_RELATIVE = "relative" +POSITION_ABSOLUTE = "absolute" + +OVERFLOW_VISIBLE = "visible" +OVERFLOW_HIDDEN = "hidden" +OVERFLOW_SCROLL = "scroll" + + +def is_vertical(direction: str) -> bool: + """Return ``True`` if *direction* represents a vertical (column) axis.""" + return direction in (FLEX_DIRECTION_COLUMN, FLEX_DIRECTION_COLUMN_REVERSE) + + # ====================================================================== # Layout property keys # ====================================================================== @@ -87,11 +121,30 @@ def resolve_padding(padding: Any) -> tuple: "width", "height", "flex", + "flex_grow", + "flex_shrink", "margin", "min_width", "max_width", "min_height", "max_height", "align_self", + "position", + "top", + "right", + "bottom", + "left", + } +) + +CONTAINER_KEYS = frozenset( + { + "flex_direction", + "justify_content", + "align_items", + "overflow", + "spacing", + "padding", + "background_color", } ) diff --git a/src/pythonnative/native_views/ios.py b/src/pythonnative/native_views/ios.py index a1af536..0631eaf 100644 --- a/src/pythonnative/native_views/ios.py +++ b/src/pythonnative/native_views/ios.py @@ -11,7 +11,7 @@ from rubicon.objc import SEL, ObjCClass, objc_method -from .base import LAYOUT_KEYS, ViewHandler, parse_color_int, resolve_padding +from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding NSObject = ObjCClass("NSObject") UIColor = ObjCClass("UIColor") @@ -53,19 +53,42 @@ def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) except Exception: pass + if "min_width" in props and props["min_width"] is not None: + try: + view.widthAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_width"])).setActive_(True) + except Exception: + pass + if "min_height" in props and props["min_height"] is not None: + try: + view.heightAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_height"])).setActive_(True) + except Exception: + pass -def _apply_stack_props(sv: Any, props: Dict[str, Any], *, vertical: bool) -> None: - """Apply spacing, alignment, distribution, background, and padding to a UIStackView. +def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None: + """Apply visual properties shared across many handlers.""" + if "background_color" in props and props["background_color"] is not None: + view.setBackgroundColor_(_uicolor(props["background_color"])) + if "overflow" in props: + view.setClipsToBounds_(props["overflow"] == "hidden") - Column and Row handlers share identical logic except for axis-dependent - alignment constants. This helper consolidates that logic. + +def _apply_flex_container(sv: Any, props: Dict[str, Any]) -> None: + """Apply flex container properties to a UIStackView. + + Handles axis, spacing, alignment, distribution, background, padding, and overflow. """ + if "flex_direction" in props: + vertical = is_vertical(props["flex_direction"]) + sv.setAxis_(1 if vertical else 0) + if "spacing" in props and props["spacing"]: sv.setSpacing_(float(props["spacing"])) ai = props.get("align_items") or props.get("alignment") if ai: + direction = props.get("flex_direction") + vertical = is_vertical(direction) if direction else bool(sv.axis()) if vertical: alignment_map = { "stretch": 0, @@ -90,6 +113,9 @@ def _apply_stack_props(sv: Any, props: Dict[str, Any], *, vertical: bool) -> Non jc = props.get("justify_content") if jc: + # UIStackViewDistribution: + # 0 = fill, 1 = fillEqually, 2 = fillProportionally, + # 3 = equalSpacing (≈ space_between), 4 = equalCentering (≈ space_evenly) distribution_map = { "flex_start": 0, "center": 0, @@ -100,8 +126,7 @@ def _apply_stack_props(sv: Any, props: Dict[str, Any], *, vertical: bool) -> Non } 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"])) + _apply_common_visual(sv, props) if "padding" in props: left, top, right, bottom = resolve_padding(props["padding"]) @@ -176,7 +201,44 @@ def onSlide_(self, sender: object) -> None: # ====================================================================== -# Handlers +# Flex container handler (shared by Column, Row, View) +# ====================================================================== + + +class FlexContainerHandler(ViewHandler): + """Unified handler for flex layout containers (Column, Row, View). + + All three element types use ``UIStackView`` with axis determined + by the ``flex_direction`` prop. + """ + + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) + direction = props.get("flex_direction", "column") + sv.setAxis_(1 if is_vertical(direction) else 0) + _apply_flex_container(sv, props) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if changed.keys() & CONTAINER_KEYS: + _apply_flex_container(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + +# ====================================================================== +# Leaf handlers # ====================================================================== @@ -255,54 +317,6 @@ def _apply(self, btn: Any, props: Dict[str, Any]) -> None: btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) -class ColumnHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) - sv.setAxis_(1) # vertical - _apply_stack_props(sv, props, vertical=True) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - _apply_stack_props(native_view, changed, vertical=True) - if changed.keys() & LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addArrangedSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeArrangedSubview_(child) - child.removeFromSuperview() - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.insertArrangedSubview_atIndex_(child, index) - - -class RowHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) - sv.setAxis_(0) # horizontal - _apply_stack_props(sv, props, vertical=False) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - _apply_stack_props(native_view, changed, vertical=False) - if changed.keys() & LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addArrangedSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeArrangedSubview_(child) - child.removeFromSuperview() - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.insertArrangedSubview_atIndex_(child, index) - - class ScrollViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: sv = ObjCClass("UIScrollView").alloc().init() @@ -493,29 +507,6 @@ def update(self, native_view: Any, changed: Dict[str, Any]) -> None: native_view.setFrame_(((0, 0), (size, size))) -class GenericViewHandler(ViewHandler): - """Handler for the ``View`` element (generic UIView container).""" - - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - v.setBackgroundColor_(_uicolor(props["background_color"])) - _apply_ios_layout(v, props) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) - if changed.keys() & LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - class SafeAreaViewHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = ObjCClass("UIView").alloc().init() @@ -595,10 +586,12 @@ def remove_child(self, parent: Any, child: Any) -> None: def register_handlers(registry: Any) -> None: """Register all iOS view handlers with the given registry.""" + flex = FlexContainerHandler() registry.register("Text", TextHandler()) registry.register("Button", ButtonHandler()) - registry.register("Column", ColumnHandler()) - registry.register("Row", RowHandler()) + registry.register("Column", flex) + registry.register("Row", flex) + registry.register("View", flex) registry.register("ScrollView", ScrollViewHandler()) registry.register("TextInput", TextInputHandler()) registry.register("Image", ImageHandler()) @@ -607,7 +600,6 @@ def register_handlers(registry: Any) -> None: registry.register("ActivityIndicator", ActivityIndicatorHandler()) registry.register("WebView", WebViewHandler()) registry.register("Spacer", SpacerHandler()) - registry.register("View", GenericViewHandler()) registry.register("SafeAreaView", SafeAreaViewHandler()) registry.register("Modal", ModalHandler()) registry.register("Slider", SliderHandler()) diff --git a/tests/test_components.py b/tests/test_components.py index 469c23c..e4d2945 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -103,6 +103,7 @@ def test_column_with_children() -> None: assert el.props["spacing"] == 10 assert el.props["padding"] == 16 assert el.props["align_items"] == "stretch" + assert el.props["flex_direction"] == "column" def test_row_with_children() -> None: @@ -110,17 +111,19 @@ def test_row_with_children() -> None: assert el.type == "Row" assert len(el.children) == 2 assert el.props["spacing"] == 5 + assert el.props["flex_direction"] == "row" -def test_column_no_style_empty_props() -> None: +def test_column_no_style_has_flex_direction() -> None: el = Column() - assert el.props == {} + assert el.props == {"flex_direction": "column"} def test_column_layout_via_style() -> None: el = Column(style={"flex": 2, "margin": {"horizontal": 8}}) assert el.props["flex"] == 2 assert el.props["margin"] == {"horizontal": 8} + assert el.props["flex_direction"] == "column" def test_column_justify_content() -> None: @@ -134,6 +137,16 @@ def test_row_justify_content() -> None: assert el.props["justify_content"] == "space_between" +def test_column_direction_cannot_be_overridden() -> None: + el = Column(style={"flex_direction": "row"}) + assert el.props["flex_direction"] == "column" + + +def test_row_direction_cannot_be_overridden() -> None: + el = Row(style={"flex_direction": "column"}) + assert el.props["flex_direction"] == "row" + + # --------------------------------------------------------------------------- # ScrollView # --------------------------------------------------------------------------- @@ -236,7 +249,7 @@ def test_column_key() -> None: # --------------------------------------------------------------------------- -# New components +# View (flex container) # --------------------------------------------------------------------------- @@ -248,6 +261,44 @@ def test_view_container() -> None: assert el.props["background_color"] == "#FFF" assert el.props["padding"] == 8 assert el.props["width"] == 200 + assert el.props["flex_direction"] == "column" + + +def test_view_default_direction_column() -> None: + el = View() + assert el.props["flex_direction"] == "column" + + +def test_view_direction_override() -> None: + el = View(style={"flex_direction": "row"}) + assert el.props["flex_direction"] == "row" + + +def test_view_flex_props() -> None: + el = View( + Text("a"), + style={ + "flex_direction": "row", + "justify_content": "space_between", + "align_items": "center", + "overflow": "hidden", + }, + ) + assert el.props["flex_direction"] == "row" + assert el.props["justify_content"] == "space_between" + assert el.props["align_items"] == "center" + assert el.props["overflow"] == "hidden" + + +def test_view_flex_grow_shrink() -> None: + el = Text("flex child", style={"flex_grow": 1, "flex_shrink": 0}) + assert el.props["flex_grow"] == 1 + assert el.props["flex_shrink"] == 0 + + +# --------------------------------------------------------------------------- +# Other containers +# --------------------------------------------------------------------------- def test_safe_area_view() -> None: diff --git a/tests/test_native_views.py b/tests/test_native_views.py index 09bc492..41f6080 100644 --- a/tests/test_native_views.py +++ b/tests/test_native_views.py @@ -12,8 +12,10 @@ from pythonnative.native_views import NativeViewRegistry, set_registry from pythonnative.native_views.base import ( + CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, + is_vertical, parse_color_int, resolve_padding, ) @@ -89,15 +91,66 @@ def test_resolve_padding_unsupported_type() -> None: # ====================================================================== -# LAYOUT_KEYS +# is_vertical +# ====================================================================== + + +def test_is_vertical_column() -> None: + assert is_vertical("column") is True + + +def test_is_vertical_column_reverse() -> None: + assert is_vertical("column_reverse") is True + + +def test_is_vertical_row() -> None: + assert is_vertical("row") is False + + +def test_is_vertical_row_reverse() -> None: + assert is_vertical("row_reverse") is False + + +# ====================================================================== +# LAYOUT_KEYS / CONTAINER_KEYS # ====================================================================== def test_layout_keys_contains_expected() -> None: - expected = {"width", "height", "flex", "margin", "min_width", "max_width", "min_height", "max_height", "align_self"} + expected = { + "width", + "height", + "flex", + "flex_grow", + "flex_shrink", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + "position", + "top", + "right", + "bottom", + "left", + } assert expected == LAYOUT_KEYS +def test_container_keys_contains_expected() -> None: + expected = { + "flex_direction", + "justify_content", + "align_items", + "overflow", + "spacing", + "padding", + "background_color", + } + assert expected == CONTAINER_KEYS + + # ====================================================================== # ViewHandler protocol # ====================================================================== From bf6bb57b6f97c140820a902ea0eff6bf6a7ffdbc Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:50:57 -0700 Subject: [PATCH 31/34] feat(hooks,reconciler)!: defer effects; add batching and use_reducer --- docs/api/pythonnative.md | 13 +- docs/concepts/architecture.md | 25 +++- docs/concepts/components.md | 7 +- docs/concepts/hooks.md | 74 +++++++++- src/pythonnative/__init__.py | 6 + src/pythonnative/components.py | 23 ++++ src/pythonnative/hooks.py | 145 +++++++++++++++++--- src/pythonnative/hot_reload.py | 4 +- src/pythonnative/page.py | 73 ++++++++-- src/pythonnative/reconciler.py | 74 +++++++++- tests/test_components.py | 32 +++++ tests/test_hooks.py | 244 ++++++++++++++++++++++++++++++++- tests/test_reconciler.py | 157 +++++++++++++++++++++ tests/test_smoke.py | 3 + 14 files changed, 827 insertions(+), 53 deletions(-) diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index dfe8033..542d764 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -13,6 +13,10 @@ Each returns an `Element` descriptor. Visual and layout properties are passed via `style={...}`. See the Component Property Reference for full details. +### ErrorBoundary + +`pythonnative.ErrorBoundary(child, fallback=...)` — catches render errors in *child* and displays *fallback* instead. *fallback* may be an `Element` or a callable that receives the exception and returns an `Element`. + ### Element `pythonnative.Element` — the descriptor type returned by element functions. You generally don't create these directly. @@ -23,7 +27,8 @@ 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_reducer(reducer, initial_state)` — reducer-based state management; returns `(state, dispatch)` +- `pythonnative.use_effect(effect, deps)` — side effects, run after native commit - `pythonnative.use_navigation()` — navigation handle (push/pop/get_args) - `pythonnative.use_memo(factory, deps)` — memoised values - `pythonnative.use_callback(fn, deps)` — stable function references @@ -32,6 +37,10 @@ Function component primitives: - `pythonnative.create_context(default)` — create a new context - `pythonnative.Provider(context, value, child)` — provide a context value +### Batching + +- `pythonnative.batch_updates()` — context manager that batches multiple state updates into a single re-render + ### Styling - `pythonnative.StyleSheet` — utility for creating and composing style dicts @@ -53,7 +62,7 @@ Function component primitives: ## 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 `create_page`. +`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, context providers, and error boundaries. Effects are flushed after each mount/reconcile pass. Used internally by `create_page`. ## Hot reload diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 1a3f3a5..a278faa 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -5,24 +5,35 @@ PythonNative combines **direct native bindings** with a **declarative reconciler ## High-level model 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. +2. **Function components and hooks:** All UI is built with `@pn.component` functions using `use_state`, `use_reducer`, `use_effect`, `use_navigation`, etc. — inspired by React hooks but designed for Python. 3. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations. -4. **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: +4. **Post-render effects:** Effects queued via `use_effect` are flushed **after** the reconciler commits native mutations, matching React semantics. This guarantees that effect callbacks interact with the committed native tree. +5. **State batching:** Multiple state updates triggered during a render pass (e.g. from effects) are automatically batched into a single re-render. Explicit batching is available via `pn.batch_updates()`. +6. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content. +7. **Error boundaries:** `pn.ErrorBoundary` catches render errors in child subtrees and displays fallback UI, preventing a single component failure from crashing the entire page. +8. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: - **iOS:** rubicon-objc exposes Objective-C/Swift classes (`UILabel`, `UIButton`, `UIStackView`, etc.). - **Android:** Chaquopy exposes Java classes (`android.widget.TextView`, `android.widget.Button`, etc.) via the JNI bridge. -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. +9. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls `create_page()` internally to bootstrap your Python component, and the reconciler drives the UI from there. ## How it works ``` -@pn.component fn → Element tree → Reconciler → Native views +@pn.component fn → Element tree → Reconciler → Native views → Flush effects ↑ -Hook set_state() → re-render → diff → patch native views +Hook set_state() → schedule render → diff → patch native views → Flush effects + (batched) ``` The reconciler uses **key-based diffing** (matching children by key first, then by position). When a child with the same key/type is found, its props are updated in-place on the native view. When the type changes, the old native view is destroyed and a new one is created. +### Render lifecycle + +1. **Render phase:** Component functions execute. Hooks record state reads, queue effects, and register memos. No native mutations happen yet. +2. **Commit phase:** The reconciler applies the diff to native views — creating, updating, and removing views as needed. +3. **Effect phase:** Pending effects are flushed in depth-first order (children before parents). Cleanup functions from the previous render run before new effect callbacks. +4. **Drain phase:** If effects set state, a new render pass is automatically triggered and the cycle repeats (up to a safety limit to prevent infinite loops). + ## Component model PythonNative uses a single component model: **function components** decorated with `@pn.component`. @@ -40,7 +51,7 @@ def Counter(initial: int = 0): 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 +- Uses hooks for state (`use_state`, `use_reducer`), side effects (`use_effect`), navigation (`use_navigation`), and more - Returns an `Element` tree describing the UI - Each call site creates an independent instance with its own hook state diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 934bf67..1ab7fa0 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -52,6 +52,10 @@ pn.Column( - `Modal(*children, visible, on_dismiss, title)` — modal dialog +**Error handling:** + +- `ErrorBoundary(child, fallback)` — catches render errors in child and displays fallback + **Lists:** - `FlatList(data, render_item, key_extractor, separator_height)` — scrollable data list @@ -164,7 +168,8 @@ Changing one `Counter` doesn't affect the other — each has its own hook state. ### Available hooks - `use_state(initial)` — local component state; returns `(value, setter)` -- `use_effect(effect, deps)` — side effects (timers, API calls, subscriptions) +- `use_reducer(reducer, initial_state)` — reducer-based state; returns `(state, dispatch)` +- `use_effect(effect, deps)` — side effects, run after native commit (timers, API calls, subscriptions) - `use_memo(factory, deps)` — memoised computed values - `use_callback(fn, deps)` — stable function references - `use_ref(initial)` — mutable ref that persists across renders diff --git a/docs/concepts/hooks.md b/docs/concepts/hooks.md index 1f23b7c..7bb304f 100644 --- a/docs/concepts/hooks.md +++ b/docs/concepts/hooks.md @@ -58,9 +58,40 @@ If the initial value is expensive to compute, pass a callable: count, set_count = pn.use_state(lambda: compute_default()) ``` +### use_reducer + +For complex state logic, `use_reducer` lets you manage state transitions through a reducer function — similar to React's `useReducer`: + +```python +def reducer(state, action): + if action == "increment": + return state + 1 + if action == "decrement": + return state - 1 + if action == "reset": + return 0 + return state + +@pn.component +def Counter(): + count, dispatch = pn.use_reducer(reducer, 0) + + return pn.Column( + pn.Text(f"Count: {count}"), + pn.Row( + pn.Button("-", on_click=lambda: dispatch("decrement")), + pn.Button("+", on_click=lambda: dispatch("increment")), + pn.Button("Reset", on_click=lambda: dispatch("reset")), + style={"spacing": 8}, + ), + ) +``` + +The reducer receives the current state and an action, and returns the new state. Actions can be any value (strings, dicts, etc.). The component only re-renders when the reducer returns a different state. + ### use_effect -Run side effects after render. The effect function may return a cleanup callable. +Run side effects **after** the native view tree is committed. The effect function may return a cleanup callable. ```python @pn.component @@ -78,6 +109,8 @@ def Timer(): return pn.Text(f"Elapsed: {seconds}s") ``` +Effects are **deferred** — they are queued during the render phase and executed after the reconciler finishes committing native view mutations. This means effect callbacks can safely measure layout or interact with the committed native tree. + Dependency control: - `pn.use_effect(fn, None)` — run on every render @@ -169,6 +202,45 @@ def UserProfile(): return pn.Text(f"Welcome, {user['name']}") ``` +## Batching state updates + +By default, each state setter call triggers a re-render. When you need to update multiple pieces of state at once, use `pn.batch_updates()` to coalesce them into a single render pass: + +```python +@pn.component +def Form(): + name, set_name = pn.use_state("") + email, set_email = pn.use_state("") + + def on_submit(): + with pn.batch_updates(): + set_name("Alice") + set_email("alice@example.com") + # single re-render here + + return pn.Column( + pn.Text(f"{name} <{email}>"), + pn.Button("Fill", on_click=on_submit), + ) +``` + +State updates triggered by effects during a render pass are automatically batched — the framework drains any pending re-renders after effect flushing completes, so you don't need `batch_updates()` inside effects. + +## Error boundaries + +Wrap risky components in `pn.ErrorBoundary` to catch render errors and display a fallback UI: + +```python +@pn.component +def App(): + return pn.ErrorBoundary( + MyRiskyComponent(), + fallback=lambda err: pn.Text(f"Something went wrong: {err}"), + ) +``` + +Without an error boundary, an exception during rendering crashes the entire page. Error boundaries catch errors during both initial mount and subsequent reconciliation. + ## Custom hooks Extract reusable stateful logic into plain functions: diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index f076dc3..9e65a75 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -20,6 +20,7 @@ def App(): ActivityIndicator, Button, Column, + ErrorBoundary, FlatList, Image, Modal, @@ -39,6 +40,7 @@ def App(): from .element import Element from .hooks import ( Provider, + batch_updates, component, create_context, use_callback, @@ -46,6 +48,7 @@ def App(): use_effect, use_memo, use_navigation, + use_reducer, use_ref, use_state, ) @@ -57,6 +60,7 @@ def App(): "ActivityIndicator", "Button", "Column", + "ErrorBoundary", "FlatList", "Image", "Modal", @@ -76,6 +80,7 @@ def App(): "Element", "create_page", # Hooks + "batch_updates", "component", "create_context", "use_callback", @@ -83,6 +88,7 @@ def App(): "use_effect", "use_memo", "use_navigation", + "use_reducer", "use_ref", "use_state", "Provider", diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py index 4dd6455..eb748a2 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -357,6 +357,29 @@ def Pressable( return Element("Pressable", props, children, key=key) +def ErrorBoundary( + child: Optional[Element] = None, + *, + fallback: Optional[Any] = None, + key: Optional[str] = None, +) -> Element: + """Catch render errors in *child* and display *fallback* instead. + + *fallback* may be an ``Element`` or a callable that receives the + exception and returns an ``Element``:: + + pn.ErrorBoundary( + MyRiskyComponent(), + fallback=lambda err: pn.Text(f"Error: {err}"), + ) + """ + props: Dict[str, Any] = {} + if fallback is not None: + props["__fallback__"] = fallback + children = [child] if child is not None else [] + return Element("__ErrorBoundary__", props, children, key=key) + + def FlatList( *, data: Optional[List[Any]] = None, diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py index 4d7b122..5bae716 100644 --- a/src/pythonnative/hooks.py +++ b/src/pythonnative/hooks.py @@ -19,7 +19,8 @@ def counter(initial=0): import inspect import threading -from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar +from contextlib import contextmanager +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, TypeVar from .element import Element @@ -29,6 +30,7 @@ def counter(initial=0): _hook_context: threading.local = threading.local() +_batch_context: threading.local = threading.local() # ====================================================================== # Hook state container @@ -36,9 +38,22 @@ def counter(initial=0): class HookState: - """Stores all hook data for a single function component instance.""" + """Stores all hook data for a single function component instance. - __slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render") + Effects are **queued** during the render phase and **flushed** after + the reconciler commits native-view mutations. This guarantees that + effect callbacks can safely interact with the committed native tree. + """ + + __slots__ = ( + "states", + "effects", + "memos", + "refs", + "hook_index", + "_trigger_render", + "_pending_effects", + ) def __init__(self) -> None: self.states: List[Any] = [] @@ -47,15 +62,24 @@ def __init__(self) -> None: self.refs: List[dict] = [] self.hook_index: int = 0 self._trigger_render: Optional[Callable[[], None]] = None + self._pending_effects: List[Tuple[int, Callable, Any]] = [] def reset_index(self) -> None: self.hook_index = 0 - def run_pending_effects(self) -> None: - """Execute effects whose deps changed during the last render pass.""" - for i, (deps, cleanup) in enumerate(self.effects): - if deps is _SENTINEL: - continue + def flush_pending_effects(self) -> None: + """Execute effects queued during the render pass (called after commit).""" + pending = self._pending_effects + self._pending_effects = [] + for idx, effect_fn, deps in pending: + _, prev_cleanup = self.effects[idx] + if callable(prev_cleanup): + try: + prev_cleanup() + except Exception: + pass + cleanup = effect_fn() + self.effects[idx] = (list(deps) if deps is not None else None, cleanup) def cleanup_all_effects(self) -> None: """Run all outstanding cleanup functions (called on unmount).""" @@ -66,6 +90,7 @@ def cleanup_all_effects(self) -> None: except Exception: pass self.effects[i] = (_SENTINEL, None) + self._pending_effects = [] # ====================================================================== @@ -91,6 +116,45 @@ def _deps_changed(prev: Any, current: Any) -> bool: return any(p is not c and p != c for p, c in zip(prev, current)) +# ====================================================================== +# Batching helpers +# ====================================================================== + + +def _schedule_trigger(trigger: Callable[[], None]) -> None: + """Call *trigger* now, or defer it if inside :func:`batch_updates`.""" + if getattr(_batch_context, "depth", 0) > 0: + _batch_context.pending_trigger = trigger + else: + trigger() + + +@contextmanager +def batch_updates() -> Generator[None, None, None]: + """Batch multiple state updates so only one re-render occurs. + + Usage:: + + with pn.batch_updates(): + set_count(1) + set_name("hello") + # single re-render happens here + """ + depth = getattr(_batch_context, "depth", 0) + _batch_context.depth = depth + 1 + if depth == 0: + _batch_context.pending_trigger = None + try: + yield + finally: + _batch_context.depth -= 1 + if _batch_context.depth == 0: + trigger = _batch_context.pending_trigger + _batch_context.pending_trigger = None + if trigger is not None: + trigger() + + # ====================================================================== # Public hooks # ====================================================================== @@ -121,13 +185,60 @@ def setter(new_value: Any) -> None: if ctx.states[idx] is not new_value and ctx.states[idx] != new_value: ctx.states[idx] = new_value if ctx._trigger_render: - ctx._trigger_render() + _schedule_trigger(ctx._trigger_render) return current, setter +def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple[Any, Callable]: + """Return ``(state, dispatch)`` for reducer-based state management. + + The *reducer* is called as ``reducer(current_state, action)`` and + must return the new state. If *initial_state* is callable it is + invoked once (lazy initialisation). + + Usage:: + + def reducer(state, action): + if action == "increment": + return state + 1 + if action == "reset": + return 0 + return state + + count, dispatch = pn.use_reducer(reducer, 0) + # dispatch("increment") -> re-render with count = 1 + """ + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_reducer must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.states): + val = initial_state() if callable(initial_state) else initial_state + ctx.states.append(val) + + current = ctx.states[idx] + + def dispatch(action: Any) -> None: + new_state = reducer(ctx.states[idx], action) + if ctx.states[idx] is not new_state and ctx.states[idx] != new_state: + ctx.states[idx] = new_state + if ctx._trigger_render: + _schedule_trigger(ctx._trigger_render) + + return current, dispatch + + def use_effect(effect: Callable, deps: Optional[list] = None) -> None: - """Schedule *effect* to run after render. + """Schedule *effect* to run **after** the native tree is committed. + + Effects are queued during the render pass and flushed once the + reconciler has finished applying all native-view mutations. This + means effects can safely measure layout or interact with committed + native views. *deps* controls when the effect re-runs: @@ -146,18 +257,12 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None: if idx >= len(ctx.effects): ctx.effects.append((_SENTINEL, None)) + ctx._pending_effects.append((idx, effect, deps)) + return - prev_deps, prev_cleanup = ctx.effects[idx] + prev_deps, _prev_cleanup = ctx.effects[idx] if _deps_changed(prev_deps, deps): - if callable(prev_cleanup): - try: - prev_cleanup() - except Exception: - pass - cleanup = effect() - ctx.effects[idx] = (list(deps) if deps is not None else None, cleanup) - else: - ctx.effects[idx] = (prev_deps, prev_cleanup) + ctx._pending_effects.append((idx, effect, deps)) def use_memo(factory: Callable[[], T], deps: list) -> T: diff --git a/src/pythonnative/hot_reload.py b/src/pythonnative/hot_reload.py index 1933118..7a10bd9 100644 --- a/src/pythonnative/hot_reload.py +++ b/src/pythonnative/hot_reload.py @@ -137,7 +137,7 @@ def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]: @staticmethod def reload_page(page_instance: Any) -> None: """Force a page re-render after module reload.""" - from .page import _re_render + from .page import _request_render if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None: - _re_render(page_instance) + _request_render(page_instance) diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index f46d9e6..de5edb7 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -29,6 +29,8 @@ def MainPage(): from .utils import IS_ANDROID, set_android_context +_MAX_RENDER_PASSES = 25 + # ====================================================================== # Component path resolution # ====================================================================== @@ -62,6 +64,9 @@ def _init_host_common(host: Any) -> None: host._args = {} host._reconciler = None host._root_native_view = None + host._nav_handle = None + host._is_rendering = False + host._render_queued = False def _on_create(host: Any) -> None: @@ -70,28 +75,68 @@ def _on_create(host: Any) -> None: from .reconciler import Reconciler host._reconciler = Reconciler(get_registry()) - host._reconciler._page_re_render = lambda: _re_render(host) + host._reconciler._page_re_render = lambda: _request_render(host) + host._nav_handle = NavigationHandle(host) - nav_handle = NavigationHandle(host) app_element = host._component() - provider_element = Provider(_NavigationContext, nav_handle, app_element) + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) + + host._is_rendering = True + try: + host._root_native_view = host._reconciler.mount(provider_element) + host._attach_root(host._root_native_view) + _drain_renders(host) + finally: + host._is_rendering = False + - host._root_native_view = host._reconciler.mount(provider_element) - host._attach_root(host._root_native_view) +def _request_render(host: Any) -> None: + """State-change trigger. Defers if a render is already in progress.""" + if host._is_rendering: + host._render_queued = True + return + _re_render(host) def _re_render(host: Any) -> None: - from .hooks import NavigationHandle, Provider, _NavigationContext + """Perform a full render pass, draining any state set during effects.""" + from .hooks import Provider, _NavigationContext - nav_handle = NavigationHandle(host) - app_element = host._component() - provider_element = Provider(_NavigationContext, nav_handle, app_element) + host._is_rendering = True + try: + host._render_queued = False + + app_element = host._component() + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) + + new_root = host._reconciler.reconcile(provider_element) + if new_root is not host._root_native_view: + host._detach_root(host._root_native_view) + host._root_native_view = new_root + host._attach_root(new_root) + + _drain_renders(host) + finally: + host._is_rendering = False + + +def _drain_renders(host: Any) -> None: + """Flush additional renders queued by effects that set state.""" + from .hooks import Provider, _NavigationContext + + for _ in range(_MAX_RENDER_PASSES): + if not host._render_queued: + break + host._render_queued = False + + app_element = host._component() + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) - new_root = host._reconciler.reconcile(provider_element) - if new_root is not host._root_native_view: - host._detach_root(host._root_native_view) - host._root_native_view = new_root - host._attach_root(new_root) + 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(host: Any, args: Any) -> None: diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index 8f2c5ab..f8b7463 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -11,8 +11,12 @@ ``@component``). Their hook state is preserved across renders. - **Provider elements** (type ``"__Provider__"``), which push/pop context values during tree traversal. +- **Error boundary elements** (type ``"__ErrorBoundary__"``), which + catch exceptions in child subtrees and render a fallback. - **Key-based child reconciliation** for stable identity across re-renders. +- **Post-render effect flushing**: after each mount or reconcile pass, + all queued effects are executed so they see the committed native tree. """ from typing import Any, List, Optional @@ -36,6 +40,10 @@ def __init__(self, element: Element, native_view: Any, children: List["VNode"]) class Reconciler: """Create, diff, and patch native view trees from Element descriptors. + After each ``mount`` or ``reconcile`` call the reconciler walks the + committed tree and flushes all pending effects so that effect + callbacks run **after** native mutations are applied. + Parameters ---------- backend: @@ -56,6 +64,7 @@ def __init__(self, backend: Any) -> None: def mount(self, element: Element) -> Any: """Build native views from *element* and return the root native view.""" self._tree = self._create_tree(element) + self._flush_effects() return self._tree.native_view def reconcile(self, new_element: Element) -> Any: @@ -65,11 +74,28 @@ def reconcile(self, new_element: Element) -> Any: """ if self._tree is None: self._tree = self._create_tree(new_element) + self._flush_effects() return self._tree.native_view self._tree = self._reconcile_node(self._tree, new_element) + self._flush_effects() return self._tree.native_view + # ------------------------------------------------------------------ + # Effect flushing + # ------------------------------------------------------------------ + + def _flush_effects(self) -> None: + """Walk the committed tree and flush pending effects (depth-first).""" + if self._tree is not None: + self._flush_tree_effects(self._tree) + + def _flush_tree_effects(self, node: VNode) -> None: + for child in node.children: + self._flush_tree_effects(child) + if node.hook_state is not None: + node.hook_state.flush_pending_effects() + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -87,6 +113,10 @@ def _create_tree(self, element: Element) -> VNode: children = [child_node] if child_node else [] return VNode(element, native_view, children) + # Error boundary: catch exceptions in the child subtree + if element.type == "__ErrorBoundary__": + return self._create_error_boundary(element) + # Function component: call with hook context if callable(element.type): from .hooks import HookState, _set_hook_state @@ -114,6 +144,20 @@ def _create_tree(self, element: Element) -> VNode: children.append(child_node) return VNode(element, native_view, children) + def _create_error_boundary(self, element: Element) -> VNode: + fallback_fn = element.props.get("__fallback__") + try: + child_node = self._create_tree(element.children[0]) if element.children else None + except Exception as exc: + if fallback_fn is not None: + fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn + child_node = self._create_tree(fallback_el) + else: + raise + native_view = child_node.native_view if child_node else None + children = [child_node] if child_node else [] + return VNode(element, native_view, children) + def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: if not self._same_type(old.element, new_el): new_node = self._create_tree(new_el) @@ -138,6 +182,10 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: old.element = new_el return old + # Error boundary + if new_el.type == "__ErrorBoundary__": + return self._reconcile_error_boundary(old, new_el) + # Function component if callable(new_el.type): from .hooks import _set_hook_state @@ -175,10 +223,34 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: old.element = new_el return old + def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode: + fallback_fn = new_el.props.get("__fallback__") + try: + if old.children and new_el.children: + child = self._reconcile_node(old.children[0], new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + elif new_el.children: + child = self._create_tree(new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + except Exception as exc: + for c in old.children: + self._destroy_tree(c) + if fallback_fn is not None: + fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn + child = self._create_tree(fallback_el) + old.children = [child] + old.native_view = child.native_view + else: + raise + old.element = new_el + return old + def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None: old_children = parent.children parent_type = parent.element.type - is_native = isinstance(parent_type, str) and parent_type != "__Provider__" + is_native = isinstance(parent_type, str) and parent_type not in ("__Provider__", "__ErrorBoundary__") old_by_key: dict = {} old_unkeyed: list = [] diff --git a/tests/test_components.py b/tests/test_components.py index e4d2945..eaac14e 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -4,6 +4,7 @@ ActivityIndicator, Button, Column, + ErrorBoundary, FlatList, Image, Modal, @@ -369,3 +370,34 @@ def test_flat_list_empty() -> None: def test_spacer_flex() -> None: el = Spacer(flex=1) assert el.props["flex"] == 1 + + +# ====================================================================== +# ErrorBoundary +# ====================================================================== + + +def test_error_boundary_creates_element() -> None: + child = Text("risky") + fallback = Text("error") + el = ErrorBoundary(child, fallback=fallback) + assert el.type == "__ErrorBoundary__" + assert el.props["__fallback__"] is fallback + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_error_boundary_callable_fallback() -> None: + fn = lambda exc: Text(str(exc)) # noqa: E731 + el = ErrorBoundary(Text("risky"), fallback=fn) + assert callable(el.props["__fallback__"]) + + +def test_error_boundary_no_child() -> None: + el = ErrorBoundary(fallback=Text("empty")) + assert len(el.children) == 0 + + +def test_error_boundary_with_key() -> None: + el = ErrorBoundary(Text("x"), fallback=Text("err"), key="eb1") + assert el.key == "eb1" diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 8fa017d..477564c 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -9,6 +9,7 @@ Provider, _NavigationContext, _set_hook_state, + batch_updates, component, create_context, use_callback, @@ -16,6 +17,7 @@ use_effect, use_memo, use_navigation, + use_reducer, use_ref, use_state, ) @@ -115,37 +117,156 @@ def test_use_state_setter_functional_update() -> None: # ====================================================================== -# use_effect +# use_reducer # ====================================================================== -def test_use_effect_runs_on_mount() -> None: +def test_use_reducer_returns_initial_state() -> None: + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + _set_hook_state(ctx) + try: + state, dispatch = use_reducer(reducer, 42) + assert state == 42 + finally: + _set_hook_state(None) + + +def test_use_reducer_lazy_initial_state() -> None: + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + _set_hook_state(ctx) + try: + state, _ = use_reducer(reducer, lambda: 99) + assert state == 99 + finally: + _set_hook_state(None) + + +def test_use_reducer_dispatch_triggers_render() -> None: + renders: list = [] + + def reducer(state: int, action: str) -> int: + if action == "increment": + return state + 1 + if action == "reset": + return 0 + return state + + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + state, dispatch = use_reducer(reducer, 0) + dispatch("increment") + assert ctx.states[0] == 1 + assert len(renders) == 1 + dispatch("increment") + assert ctx.states[0] == 2 + assert len(renders) == 2 + dispatch("reset") + assert ctx.states[0] == 0 + assert len(renders) == 3 + finally: + _set_hook_state(None) + + +def test_use_reducer_no_render_on_same_state() -> None: + renders: list = [] + + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, dispatch = use_reducer(reducer, 5) + dispatch("noop") + assert len(renders) == 0 + finally: + _set_hook_state(None) + + +def test_use_reducer_in_reconciler() -> None: + captured_dispatch: list = [None] + + def reducer(state: int, action: str) -> int: + if action == "increment": + return state + 1 + return state + + @component + def counter() -> Element: + count, dispatch = use_reducer(reducer, 0) + captured_dispatch[0] = dispatch + return Element("Text", {"text": str(count)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + re_rendered: list = [] + rec._page_re_render = lambda: re_rendered.append(1) + + root = rec.mount(counter()) + assert root.props["text"] == "0" + + dispatch_fn = captured_dispatch[0] + assert dispatch_fn is not None + dispatch_fn("increment") + assert len(re_rendered) == 1 + + +# ====================================================================== +# use_effect (deferred execution) +# ====================================================================== + + +def test_use_effect_is_deferred() -> None: + """Effects are queued during render, not run immediately.""" calls: list = [] ctx = HookState() _set_hook_state(ctx) try: use_effect(lambda: calls.append("mounted"), []) - assert calls == ["mounted"] + assert calls == [], "Effect should NOT run during render" finally: _set_hook_state(None) + ctx.flush_pending_effects() + assert calls == ["mounted"], "Effect should run after flush" + def test_use_effect_cleanup_on_rerun() -> None: cleanups: list = [] + + def make_effect(label: str): # type: ignore[no-untyped-def] + def effect() -> Any: + return lambda: cleanups.append(label) + + return effect + ctx = HookState() _set_hook_state(ctx) try: - use_effect(lambda: cleanups.append, None) + use_effect(make_effect("first"), None) finally: _set_hook_state(None) + ctx.flush_pending_effects() ctx.reset_index() _set_hook_state(ctx) try: - use_effect(lambda: cleanups.append, None) + use_effect(make_effect("second"), None) finally: _set_hook_state(None) + ctx.flush_pending_effects() + + assert "first" in cleanups def test_use_effect_skips_with_same_deps() -> None: @@ -157,6 +278,7 @@ def test_use_effect_skips_with_same_deps() -> None: use_effect(lambda: calls.append("run"), [1, 2]) finally: _set_hook_state(None) + ctx.flush_pending_effects() ctx.reset_index() _set_hook_state(ctx) @@ -164,10 +286,122 @@ def test_use_effect_skips_with_same_deps() -> None: use_effect(lambda: calls.append("run"), [1, 2]) finally: _set_hook_state(None) + ctx.flush_pending_effects() assert calls == ["run"] +def test_use_effect_runs_after_reconciler_mount() -> None: + """Effects run automatically after Reconciler.mount() completes.""" + calls: list = [] + + @component + def my_comp() -> Element: + use_effect(lambda: calls.append("effect"), []) + return Element("Text", {"text": "hi"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp()) + assert calls == ["effect"] + + +def test_use_effect_runs_after_reconciler_reconcile() -> None: + """Effects run automatically after Reconciler.reconcile() completes.""" + calls: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + use_effect(lambda: calls.append(f"effect-{dep}"), [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=0)) + assert calls == ["effect-0"] + + rec.reconcile(my_comp(dep=1)) + assert calls == ["effect-0", "effect-1"] + + +def test_use_effect_cleanup_on_unmount() -> None: + """Cleanup functions run when component is destroyed.""" + cleanups: list = [] + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(lambda: (lambda: cleanups.append("cleaned")), []) + finally: + _set_hook_state(None) + ctx.flush_pending_effects() + + assert cleanups == [] + ctx.cleanup_all_effects() + assert cleanups == ["cleaned"] + + +# ====================================================================== +# batch_updates +# ====================================================================== + + +def test_batch_updates_defers_render() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(0) + _, set_b = use_state(0) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(1) + set_b(2) + assert len(renders) == 0, "Render should be deferred inside batch" + + assert len(renders) == 1, "Exactly one render after batch exits" + + +def test_batch_updates_nested() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(0) + _, set_b = use_state(0) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(1) + with batch_updates(): + set_b(2) + assert len(renders) == 0 + assert len(renders) == 0, "Nested batch should not trigger render" + + assert len(renders) == 1 + + +def test_batch_updates_no_render_when_unchanged() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(5) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(5) + + assert len(renders) == 0 + + # ====================================================================== # use_memo / use_callback # ====================================================================== diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index a97ecc0..a49e2f5 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -2,7 +2,10 @@ from typing import Any, Dict, List +import pytest + from pythonnative.element import Element +from pythonnative.hooks import component from pythonnative.reconciler import Reconciler # ====================================================================== @@ -369,3 +372,157 @@ def test_keyed_children_insert_new() -> None: assert len(rec._tree.children) == 3 assert rec._tree.children[1].element.key == "b" + + +# ====================================================================== +# Tests: error boundaries +# ====================================================================== + + +def test_error_boundary_catches_mount_error() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad_component(**props: Any) -> Element: + raise ValueError("boom") + + fallback = Element("Text", {"text": "error caught"}, []) + child = Element(bad_component, {}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert root.props["text"] == "error caught" + + +def test_error_boundary_callable_fallback() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad_component(**props: Any) -> Element: + raise RuntimeError("oops") + + def fallback_fn(exc: Exception) -> Element: + return Element("Text", {"text": f"caught: {exc}"}, []) + + child = Element(bad_component, {}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert "caught: oops" in root.props["text"] + + +def test_error_boundary_no_error_renders_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + child = Element("Text", {"text": "ok"}, []) + fallback = Element("Text", {"text": "error"}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert root.props["text"] == "ok" + + +def test_error_boundary_catches_reconcile_error() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + call_count = [0] + + @component + def flaky() -> Element: + call_count[0] += 1 + if call_count[0] > 1: + raise RuntimeError("reconcile boom") + return Element("Text", {"text": "ok"}, []) + + def fallback_fn(exc: Exception) -> Element: + return Element("Text", {"text": f"recovered: {exc}"}, []) + + eb1 = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [flaky()]) + root = rec.mount(eb1) + assert root.props["text"] == "ok" + + eb2 = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [flaky()]) + root = rec.reconcile(eb2) + assert "recovered" in root.props["text"] + + +def test_error_boundary_without_fallback_propagates() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad(**props: Any) -> Element: + raise ValueError("no fallback") + + child = Element(bad, {}, []) + eb = Element("__ErrorBoundary__", {}, [child]) + + with pytest.raises(ValueError, match="no fallback"): + rec.mount(eb) + + +# ====================================================================== +# Tests: post-render effect flushing +# ====================================================================== + + +def test_effects_flushed_after_mount() -> None: + calls: list = [] + + @component + def my_comp() -> Element: + from pythonnative.hooks import use_effect + + use_effect(lambda: calls.append("mounted"), []) + return Element("Text", {"text": "hi"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp()) + assert calls == ["mounted"] + + +def test_effects_flushed_after_reconcile() -> None: + calls: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + from pythonnative.hooks import use_effect + + use_effect(lambda: calls.append(f"e{dep}"), [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=1)) + assert calls == ["e1"] + + rec.reconcile(my_comp(dep=2)) + assert calls == ["e1", "e2"] + + +def test_effect_cleanup_runs_on_rerun() -> None: + log: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + from pythonnative.hooks import use_effect + + def effect() -> Any: + log.append(f"run-{dep}") + return lambda: log.append(f"cleanup-{dep}") + + use_effect(effect, [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=1)) + assert log == ["run-1"] + + rec.reconcile(my_comp(dep=2)) + assert log == ["run-1", "cleanup-1", "run-2"] diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 24bcf62..18b14fc 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -18,6 +18,7 @@ def test_public_api_names() -> None: "Button", "Column", "Element", + "ErrorBoundary", "FlatList", "Image", "Modal", @@ -36,6 +37,7 @@ def test_public_api_names() -> None: # Core "create_page", # Hooks + "batch_updates", "component", "create_context", "use_callback", @@ -43,6 +45,7 @@ def test_public_api_names() -> None: "use_effect", "use_memo", "use_navigation", + "use_reducer", "use_ref", "use_state", "Provider", From 828bbb0c83fba640a7055edf1237500f27493fd3 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:16:35 -0700 Subject: [PATCH 32/34] feat(navigation)!: add declarative navigation system --- docs/api/pythonnative.md | 13 +- docs/concepts/architecture.md | 15 +- docs/concepts/components.md | 4 +- docs/concepts/hooks.md | 32 +- docs/guides/navigation.md | 160 +++++- examples/hello-world/app/main_page.py | 4 +- examples/hello-world/app/second_page.py | 6 +- examples/hello-world/app/third_page.py | 2 +- src/pythonnative/__init__.py | 15 + src/pythonnative/hooks.py | 19 +- src/pythonnative/navigation.py | 544 ++++++++++++++++++ src/pythonnative/page.py | 4 +- src/pythonnative/reconciler.py | 12 + tests/test_hooks.py | 27 +- tests/test_navigation.py | 714 ++++++++++++++++++++++++ tests/test_reconciler.py | 7 +- tests/test_smoke.py | 7 + 17 files changed, 1532 insertions(+), 53 deletions(-) create mode 100644 src/pythonnative/navigation.py create mode 100644 tests/test_navigation.py diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 542d764..f05178c 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -29,7 +29,9 @@ Function component primitives: - `pythonnative.use_state(initial)` — local component state - `pythonnative.use_reducer(reducer, initial_state)` — reducer-based state management; returns `(state, dispatch)` - `pythonnative.use_effect(effect, deps)` — side effects, run after native commit -- `pythonnative.use_navigation()` — navigation handle (push/pop/get_args) +- `pythonnative.use_navigation()` — navigation handle (navigate/go_back/get_params) +- `pythonnative.use_route()` — convenience hook for current route params +- `pythonnative.use_focus_effect(effect, deps)` — like `use_effect` but only runs when the screen is focused - `pythonnative.use_memo(factory, deps)` — memoised values - `pythonnative.use_callback(fn, deps)` — stable function references - `pythonnative.use_ref(initial)` — mutable ref object @@ -37,6 +39,15 @@ Function component primitives: - `pythonnative.create_context(default)` — create a new context - `pythonnative.Provider(context, value, child)` — provide a context value +### Navigation + +Declarative, component-based navigation system: + +- `pythonnative.NavigationContainer(child)` — root container for the navigation tree +- `pythonnative.create_stack_navigator()` — create a stack-based navigator (returns object with `.Navigator` and `.Screen`) +- `pythonnative.create_tab_navigator()` — create a tab-based navigator +- `pythonnative.create_drawer_navigator()` — create a drawer-based navigator + ### Batching - `pythonnative.batch_updates()` — context manager that batches multiple state updates into a single re-render diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index a278faa..5505fdb 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -135,10 +135,17 @@ 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. +PythonNative provides two navigation approaches: + +- **Declarative navigators** (recommended): `NavigationContainer` with `create_stack_navigator()`, `create_tab_navigator()`, and `create_drawer_navigator()`. Navigation state is managed in Python as component state, and navigators are composable — you can nest tabs inside stacks, etc. +- **Page-level navigation**: `use_navigation()` returns a `NavigationHandle` with `.navigate()`, `.go_back()`, and `.get_params()`, delegating to native platform navigation when running on device. + +Both approaches are supported. The declarative system uses the existing reconciler pipeline — navigators are function components that render the active screen via `use_state`, and navigation context is provided via `Provider`. + +See the [Navigation guide](../guides/navigation.md) for full details. + +- iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. +- Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph. ## Related docs diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 1ab7fa0..cbeb421 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -174,7 +174,9 @@ Changing one `Counter` doesn't affect the other — each has its own hook state. - `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 +- `use_navigation()` — navigation handle for navigate/go_back/get_params +- `use_route()` — convenience hook for current route params +- `use_focus_effect(effect, deps)` — like `use_effect` but only runs when the screen is focused ### Custom hooks diff --git a/docs/concepts/hooks.md b/docs/concepts/hooks.md index 7bb304f..49b75aa 100644 --- a/docs/concepts/hooks.md +++ b/docs/concepts/hooks.md @@ -119,7 +119,7 @@ Dependency control: ### use_navigation -Access the navigation stack from any component. Returns a `NavigationHandle` with `.push()`, `.pop()`, and `.get_args()`. +Access navigation from any component. Returns a `NavigationHandle` with `.navigate()`, `.go_back()`, and `.get_params()`. ```python @pn.component @@ -130,7 +130,7 @@ def HomeScreen(): pn.Text("Home", style={"font_size": 24}), pn.Button( "Go to Details", - on_click=lambda: nav.push(DetailScreen, args={"id": 42}), + on_click=lambda: nav.navigate("Detail", params={"id": 42}), ), style={"spacing": 12, "padding": 16}, ) @@ -138,17 +138,41 @@ def HomeScreen(): @pn.component def DetailScreen(): nav = pn.use_navigation() - item_id = nav.get_args().get("id", 0) + item_id = nav.get_params().get("id", 0) return pn.Column( pn.Text(f"Detail #{item_id}", style={"font_size": 20}), - pn.Button("Back", on_click=nav.pop), + pn.Button("Back", on_click=nav.go_back), style={"spacing": 12, "padding": 16}, ) ``` See the [Navigation guide](../guides/navigation.md) for full details. +### use_route + +Convenience hook to read the current route's parameters: + +```python +@pn.component +def DetailScreen(): + params = pn.use_route() + item_id = params.get("id", 0) + return pn.Text(f"Detail #{item_id}") +``` + +### use_focus_effect + +Like `use_effect` but only runs when the screen is focused. Useful for refreshing data when navigating back to a screen: + +```python +@pn.component +def FeedScreen(): + items, set_items = pn.use_state([]) + pn.use_focus_effect(lambda: load_items(set_items), []) + return pn.FlatList(data=items, render_item=lambda item, i: pn.Text(item)) +``` + ### use_memo Memoise an expensive computation: diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 58b22ef..1dbc35a 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -1,15 +1,33 @@ # Navigation -This guide shows how to navigate between screens and pass data using the `use_navigation()` hook. +PythonNative offers two approaches to navigation: -## Push / Pop +1. **Declarative navigators** (recommended) — component-based, inspired by React Navigation +2. **Legacy push/pop** — imperative navigation via `use_navigation()` -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`. +## Declarative Navigation + +Declarative navigators manage screen state as components. Define your screens once, and the navigator handles rendering, transitions, and state. + +### Stack Navigator + +A stack navigator manages a stack of screens — push to go forward, pop to go back. ```python import pythonnative as pn -from app.second_page import SecondPage +from pythonnative.navigation import NavigationContainer, create_stack_navigator + +Stack = create_stack_navigator() +@pn.component +def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Home", + ) + ) @pn.component def HomeScreen(): @@ -17,37 +35,136 @@ def HomeScreen(): 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"}, - ), + "Go to Detail", + on_click=lambda: nav.navigate("Detail", params={"id": 42}), ), style={"spacing": 12, "padding": 16}, ) + +@pn.component +def DetailScreen(): + nav = pn.use_navigation() + params = nav.get_params() + return pn.Column( + pn.Text(f"Detail #{params.get('id')}", style={"font_size": 20}), + pn.Button("Back", on_click=nav.go_back), + style={"spacing": 12, "padding": 16}, + ) +``` + +### Tab Navigator + +A tab navigator renders a tab bar and switches between screens. + +```python +from pythonnative.navigation import create_tab_navigator + +Tab = create_tab_navigator() + +@pn.component +def App(): + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + ) ``` -On the target screen, retrieve args with `nav.get_args()`: +### Drawer Navigator + +A drawer navigator provides a side menu for switching screens. ```python +from pythonnative.navigation import create_drawer_navigator + +Drawer = create_drawer_navigator() + +@pn.component +def App(): + return NavigationContainer( + Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Drawer.Screen("Profile", component=ProfileScreen, options={"title": "Profile"}), + ) + ) + @pn.component -def SecondPage(): +def HomeScreen(): 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}, + pn.Button("Open Menu", on_click=nav.open_drawer), + pn.Text("Home Screen"), + ) +``` + +### Nesting Navigators + +Navigators can be nested — for example, tabs containing stacks: + +```python +Stack = create_stack_navigator() +Tab = create_tab_navigator() + +@pn.component +def HomeStack(): + return Stack.Navigator( + Stack.Screen("Feed", component=FeedScreen), + Stack.Screen("Post", component=PostScreen), + ) + +@pn.component +def App(): + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeStack, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) ) ``` ## NavigationHandle API -`pn.use_navigation()` returns a `NavigationHandle` with: +Inside any screen rendered by a navigator, `pn.use_navigation()` returns a handle with: + +- **`.navigate(route_name, params=...)`** — navigate to a named route with optional params +- **`.go_back()`** — pop the current screen +- **`.get_params()`** — get the current route's params dict +- **`.reset(route_name, params=...)`** — reset the stack to a single route + +### Drawer-specific methods + +When inside a drawer navigator, the handle also provides: + +- **`.open_drawer()`** — open the drawer +- **`.close_drawer()`** — close the drawer +- **`.toggle_drawer()`** — toggle the drawer open/closed + +## Focus-aware Effects -- **`.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. +Use `pn.use_focus_effect()` to run effects only when a screen is focused: + +```python +@pn.component +def DataScreen(): + data, set_data = pn.use_state(None) + + pn.use_focus_effect(lambda: fetch_data(set_data), []) + + return pn.Text(f"Data: {data}") +``` + +## Route Parameters + +Use `pn.use_route()` for convenient access to route params: + +```python +@pn.component +def DetailScreen(): + params = pn.use_route() + item_id = params.get("id", 0) + return pn.Text(f"Item #{item_id}") +``` ## Lifecycle @@ -63,11 +180,6 @@ PythonNative forwards lifecycle events from the host: - `on_save_instance_state` - `on_restore_instance_state` -## Notes - -- On Android, `push` navigates via `NavController` to a `PageFragment` and passes `page_path` and optional JSON `args`. -- On iOS, `push` uses the root `UINavigationController` to push a new `ViewController` and passes page info via KVC. - ## Platform specifics ### iOS (UIViewController per page) diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 5cfd54c..10f592c 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -47,9 +47,9 @@ def MainPage() -> pn.Element: counter_badge(), pn.Button( "Go to Second Page", - on_click=lambda: nav.push( + on_click=lambda: nav.navigate( "app.second_page.SecondPage", - args={"message": "Greetings from MainPage"}, + params={"message": "Greetings from MainPage"}, ), ), style=styles["section"], diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index bcbd522..40725a2 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -4,15 +4,15 @@ @pn.component def SecondPage() -> pn.Element: nav = pn.use_navigation() - message = nav.get_args().get("message", "Second Page") + message = nav.get_params().get("message", "Second Page") return pn.ScrollView( pn.Column( pn.Text(message, style={"font_size": 24, "bold": True}), pn.Button( "Go to Third Page", - on_click=lambda: nav.push("app.third_page.ThirdPage"), + on_click=lambda: nav.navigate("app.third_page.ThirdPage"), ), - pn.Button("Back", on_click=nav.pop), + pn.Button("Back", on_click=nav.go_back), style={"spacing": 16, "padding": 24, "align_items": "stretch"}, ) ) diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index a3e3cab..62a3388 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -8,7 +8,7 @@ def ThirdPage() -> pn.Element: 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), + pn.Button("Back to Second", on_click=nav.go_back), style={"spacing": 16, "padding": 24, "align_items": "stretch"}, ) ) diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 9e65a75..c50407c 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -52,6 +52,14 @@ def App(): use_ref, use_state, ) +from .navigation import ( + NavigationContainer, + create_drawer_navigator, + create_stack_navigator, + create_tab_navigator, + use_focus_effect, + use_route, +) from .page import create_page from .style import StyleSheet, ThemeContext @@ -86,12 +94,19 @@ def App(): "use_callback", "use_context", "use_effect", + "use_focus_effect", "use_memo", "use_navigation", "use_reducer", "use_ref", + "use_route", "use_state", "Provider", + # Navigation + "NavigationContainer", + "create_drawer_navigator", + "create_stack_navigator", + "create_tab_navigator", # Styling "StyleSheet", "ThemeContext", diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py index 5bae716..b6ff196 100644 --- a/src/pythonnative/hooks.py +++ b/src/pythonnative/hooks.py @@ -360,27 +360,28 @@ def Provider(context: Context, value: Any, child: Element) -> Element: class NavigationHandle: - """Object returned by :func:`use_navigation` providing push/pop/get_args. + """Object returned by :func:`use_navigation` providing navigation methods. - Navigates by component reference rather than string path, e.g.:: + :: nav = pn.use_navigation() - nav.push(DetailScreen, args={"id": 42}) + nav.navigate(DetailScreen, params={"id": 42}) + nav.go_back() """ 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 navigate(self, page: Any, params: Optional[Dict[str, Any]] = None) -> None: + """Navigate forward to *page* with optional *params*.""" + self._host._push(page, params) - def pop(self) -> None: + def go_back(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.""" + def get_params(self) -> Dict[str, Any]: + """Return parameters passed from the previous screen.""" return self._host._get_nav_args() diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py new file mode 100644 index 0000000..7c9cef0 --- /dev/null +++ b/src/pythonnative/navigation.py @@ -0,0 +1,544 @@ +"""Declarative navigation for PythonNative. + +Provides a component-based navigation system inspired by React Navigation. +Navigators manage screen state in Python; they render the active screen's +component using the standard reconciler pipeline. + +Usage:: + + from pythonnative.navigation import ( + NavigationContainer, + create_stack_navigator, + create_tab_navigator, + create_drawer_navigator, + ) + + Stack = create_stack_navigator() + + @pn.component + def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + ) +""" + +from typing import Any, Callable, Dict, List, Optional + +from .element import Element +from .hooks import ( + Provider, + _NavigationContext, + component, + create_context, + use_context, + use_effect, + use_memo, + use_ref, + use_state, +) + +# ====================================================================== +# Focus context +# ====================================================================== + +_FocusContext = create_context(False) + +# ====================================================================== +# Data structures +# ====================================================================== + + +class _ScreenDef: + """Configuration for a single screen within a navigator.""" + + __slots__ = ("name", "component", "options") + + def __init__(self, name: str, component_fn: Any, options: Optional[Dict[str, Any]] = None) -> None: + self.name = name + self.component = component_fn + self.options = options or {} + + def __repr__(self) -> str: + return f"Screen({self.name!r})" + + +class _RouteEntry: + """An entry in the navigation stack.""" + + __slots__ = ("name", "params") + + def __init__(self, name: str, params: Optional[Dict[str, Any]] = None) -> None: + self.name = name + self.params = params or {} + + def __repr__(self) -> str: + return f"Route({self.name!r})" + + +# ====================================================================== +# Navigation handle for declarative navigators +# ====================================================================== + + +class _DeclarativeNavHandle: + """Navigation handle provided by declarative navigators. + + Implements the same interface as :class:`~pythonnative.hooks.NavigationHandle` + so that ``use_navigation()`` returns a compatible object regardless of + whether the app uses the legacy page-based navigation or declarative + navigators. + """ + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + ) -> None: + self._screen_map = screen_map + self._get_stack = get_stack + self._set_stack = set_stack + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Navigate to a named route, pushing it onto the stack.""" + if route_name not in self._screen_map: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + entry = _RouteEntry(route_name, params) + self._set_stack(lambda s: list(s) + [entry]) + + def go_back(self) -> None: + """Pop the current screen from the stack.""" + self._set_stack(lambda s: list(s[:-1]) if len(s) > 1 else list(s)) + + def get_params(self) -> Dict[str, Any]: + """Return the parameters for the current route.""" + stack = self._get_stack() + return stack[-1].params if stack else {} + + def reset(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Reset the stack to a single route.""" + if route_name not in self._screen_map: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + self._set_stack([_RouteEntry(route_name, params)]) + + +class _TabNavHandle(_DeclarativeNavHandle): + """Navigation handle for tab navigators with tab switching.""" + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + switch_tab: Callable[[str, Optional[Dict[str, Any]]], None], + ) -> None: + super().__init__(screen_map, get_stack, set_stack) + self._switch_tab = switch_tab + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Switch to a tab by name.""" + if route_name not in self._screen_map: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + self._switch_tab(route_name, params) + + +class _DrawerNavHandle(_DeclarativeNavHandle): + """Navigation handle for drawer navigators with open/close control.""" + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + switch_screen: Callable[[str, Optional[Dict[str, Any]]], None], + set_drawer_open: Callable[[bool], None], + get_drawer_open: Callable[[], bool], + ) -> None: + super().__init__(screen_map, get_stack, set_stack) + self._switch_screen = switch_screen + self._set_drawer_open = set_drawer_open + self._get_drawer_open = get_drawer_open + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Switch to a screen and close the drawer.""" + if route_name not in self._screen_map: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + self._switch_screen(route_name, params) + self._set_drawer_open(False) + + def open_drawer(self) -> None: + """Open the drawer.""" + self._set_drawer_open(True) + + def close_drawer(self) -> None: + """Close the drawer.""" + self._set_drawer_open(False) + + def toggle_drawer(self) -> None: + """Toggle the drawer open/closed.""" + self._set_drawer_open(not self._get_drawer_open()) + + +# ====================================================================== +# Stack navigator +# ====================================================================== + + +def _build_screen_map(screens: Any) -> Dict[str, "_ScreenDef"]: + """Build an ordered dict of name -> _ScreenDef from a list.""" + result: Dict[str, _ScreenDef] = {} + for s in screens or []: + if isinstance(s, _ScreenDef): + result[s.name] = s + return result + + +@component +def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_map = _build_screen_map(screens) + if not screen_map: + return Element("View", {}, []) + + first_route = initial_route or next(iter(screen_map)) + stack, set_stack = use_state(lambda: [_RouteEntry(first_route)]) + + stack_ref = use_ref(None) + stack_ref["current"] = stack + + handle = use_memo(lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref["current"], set_stack), []) + handle._screen_map = screen_map + + current = stack[-1] + screen_def = screen_map.get(current.name) + if screen_def is None: + return Element("Text", {"text": f"Unknown route: {current.name}"}, []) + + screen_el = screen_def.component() + return Provider(_NavigationContext, handle, Provider(_FocusContext, True, screen_el)) + + +def create_stack_navigator() -> Any: + """Create a stack-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Stack = create_stack_navigator() + + Stack.Screen("Home", component=HomeScreen) + + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Home", + ) + """ + + class _StackNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this stack navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the stack navigator with the given screens.""" + return _stack_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _StackNavigator() + + +# ====================================================================== +# Tab navigator +# ====================================================================== + + +@component +def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_list = list(screens or []) + screen_map = _build_screen_map(screen_list) + if not screen_map: + return Element("View", {}, []) + + first_route = initial_route or screen_list[0].name + active_tab, set_active_tab = use_state(first_route) + tab_params, set_tab_params = use_state(lambda: {first_route: {}}) + + params_ref = use_ref(None) + params_ref["current"] = tab_params + + def switch_tab(name: str, params: Optional[Dict[str, Any]] = None) -> None: + set_active_tab(name) + if params: + set_tab_params(lambda p: {**p, name: params}) + + def get_stack() -> List[_RouteEntry]: + p = params_ref["current"] or {} + return [_RouteEntry(active_tab, p.get(active_tab, {}))] + + handle = use_memo(lambda: _TabNavHandle(screen_map, get_stack, lambda _: None, switch_tab), []) + handle._screen_map = screen_map + handle._switch_tab = switch_tab + + screen_def = screen_map.get(active_tab) + if screen_def is None: + screen_def = screen_map[screen_list[0].name] + + tab_buttons: List[Element] = [] + for s in screen_list: + if not isinstance(s, _ScreenDef): + continue + is_active = s.name == active_tab + label = s.options.get("title", s.name) + tab_name = s.name + + def make_on_click(n: str) -> Callable[[], None]: + return lambda: switch_tab(n) + + tab_buttons.append( + Element( + "Button", + { + "title": label, + "on_click": make_on_click(tab_name), + "enabled": not is_active, + }, + [], + key=f"__tab_{tab_name}", + ) + ) + + tab_bar = Element( + "View", + {"flex_direction": "row", "background_color": "#F8F8F8"}, + tab_buttons, + ) + + screen_el = screen_def.component() + content = Provider( + _NavigationContext, + handle, + Provider(_FocusContext, True, screen_el), + ) + + return Element( + "View", + {"flex_direction": "column"}, + [Element("View", {"flex": 1}, [content]), tab_bar], + ) + + +def create_tab_navigator() -> Any: + """Create a tab-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Tab = create_tab_navigator() + + Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen), + ) + """ + + class _TabNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this tab navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the tab navigator with the given screens.""" + return _tab_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _TabNavigator() + + +# ====================================================================== +# Drawer navigator +# ====================================================================== + + +@component +def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_list = list(screens or []) + screen_map = _build_screen_map(screen_list) + if not screen_map: + return Element("View", {}, []) + + first_route = initial_route or screen_list[0].name + active_screen, set_active_screen = use_state(first_route) + drawer_open, set_drawer_open = use_state(False) + screen_params, set_screen_params = use_state(lambda: {first_route: {}}) + + params_ref = use_ref(None) + params_ref["current"] = screen_params + + def switch_screen(name: str, params: Optional[Dict[str, Any]] = None) -> None: + set_active_screen(name) + if params: + set_screen_params(lambda p: {**p, name: params}) + + def get_stack() -> List[_RouteEntry]: + p = params_ref["current"] or {} + return [_RouteEntry(active_screen, p.get(active_screen, {}))] + + handle = use_memo( + lambda: _DrawerNavHandle( + screen_map, + get_stack, + lambda _: None, + switch_screen, + set_drawer_open, + lambda: drawer_open, + ), + [], + ) + handle._screen_map = screen_map + handle._switch_screen = switch_screen + handle._set_drawer_open = set_drawer_open + handle._get_drawer_open = lambda: drawer_open + + screen_def = screen_map.get(active_screen) + if screen_def is None: + screen_def = screen_map[screen_list[0].name] + + screen_el = screen_def.component() + content = Provider( + _NavigationContext, + handle, + Provider(_FocusContext, True, screen_el), + ) + + children: List[Element] = [Element("View", {"flex": 1}, [content])] + + if drawer_open: + menu_items: List[Element] = [] + for s in screen_list: + if not isinstance(s, _ScreenDef): + continue + label = s.options.get("title", s.name) + item_name = s.name + + def make_select(n: str) -> Callable[[], None]: + def _select() -> None: + switch_screen(n) + set_drawer_open(False) + + return _select + + menu_items.append( + Element("Button", {"title": label, "on_click": make_select(item_name)}, [], key=f"__drawer_{item_name}") + ) + + drawer_panel = Element( + "View", + {"background_color": "#FFFFFF", "width": 250}, + menu_items, + ) + children.insert(0, drawer_panel) + + return Element("View", {"flex_direction": "row", "flex": 1}, children) + + +def create_drawer_navigator() -> Any: + """Create a drawer-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Drawer = create_drawer_navigator() + + Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Drawer.Screen("Settings", component=SettingsScreen), + ) + + The navigation handle returned by ``use_navigation()`` inside a drawer + navigator includes ``open_drawer()``, ``close_drawer()``, and + ``toggle_drawer()`` methods. + """ + + class _DrawerNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this drawer navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the drawer navigator with the given screens.""" + return _drawer_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _DrawerNavigator() + + +# ====================================================================== +# NavigationContainer +# ====================================================================== + + +def NavigationContainer(child: Element, *, key: Optional[str] = None) -> Element: + """Root container for the navigation tree. + + Wraps the child navigator in a full-size view. All declarative + navigators (stack, tab, drawer) should be nested inside a + ``NavigationContainer``:: + + @pn.component + def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + ) + ) + """ + return Element("View", {"flex": 1}, [child], key=key) + + +# ====================================================================== +# Hooks +# ====================================================================== + + +def use_route() -> Dict[str, Any]: + """Return the current route's parameters. + + Convenience hook that reads from the navigation context:: + + @pn.component + def DetailScreen(): + params = pn.use_route() + item_id = params.get("id") + ... + """ + nav = use_context(_NavigationContext) + if nav is None: + return {} + get_params = getattr(nav, "get_params", None) + if get_params: + return get_params() + return {} + + +def use_focus_effect(effect: Callable, deps: Optional[list] = None) -> None: + """Run *effect* only when the screen is focused. + + Like ``use_effect`` but skips execution when the screen is not the + active/focused screen in a navigator:: + + @pn.component + def HomeScreen(): + pn.use_focus_effect(lambda: print("screen focused"), []) + """ + is_focused = use_context(_FocusContext) + all_deps = [is_focused] + (list(deps) if deps is not None else []) + + def wrapped_effect() -> Any: + if is_focused: + return effect() + return None + + use_effect(wrapped_effect, all_deps) diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index de5edb7..0d77c75 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -437,10 +437,10 @@ 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)") + raise RuntimeError("navigate() requires a native runtime (iOS or Android)") def _pop(self) -> None: - raise RuntimeError("pop() requires a native runtime (iOS or Android)") + raise RuntimeError("go_back() requires a native runtime (iOS or Android)") def _attach_root(self, native_view: Any) -> None: pass diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index f8b7463..a4f8938 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -301,6 +301,18 @@ def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> Non self.backend.remove_child(parent.native_view, node.native_view, parent_type) self._destroy_tree(node) + # Reorder native children when keyed children changed positions. + # Without this, native sibling order drifts from the logical tree + # when keyed children swap positions across reconcile passes. + if is_native and used_keyed: + old_key_order = [c.element.key for c in old_children if c.element.key in used_keyed] + new_key_order = [n.element.key for n in new_child_nodes if n.element.key in used_keyed] + if old_key_order != new_key_order: + for node in new_child_nodes: + self.backend.remove_child(parent.native_view, node.native_view, parent_type) + for node in new_child_nodes: + self.backend.add_child(parent.native_view, node.native_view, parent_type) + parent.children = new_child_nodes def _destroy_tree(self, node: VNode) -> None: diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 477564c..1eddbeb 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -693,7 +693,32 @@ def _pop(self) -> None: try: nav = use_navigation() assert nav is handle - assert nav.get_args() == {"id": 42} + assert nav.get_params() == {"id": 42} finally: _set_hook_state(None) _NavigationContext._stack.pop() + + +def test_navigation_handle_methods() -> None: + pushed: list = [] + popped: list = [] + + class FakeHost: + def _push(self, page: Any, args: Any = None) -> None: + pushed.append((page, args)) + + def _pop(self) -> None: + popped.append(1) + + def _get_nav_args(self) -> dict: + return {"key": "value"} + + handle = NavigationHandle(FakeHost()) + + handle.navigate("SomePage", params={"x": 1}) + assert pushed == [("SomePage", {"x": 1})] + + handle.go_back() + assert len(popped) == 1 + + assert handle.get_params() == {"key": "value"} diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..a666d53 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,714 @@ +"""Comprehensive tests for the declarative navigation system.""" + +from typing import Any, Dict, List + +import pytest + +from pythonnative.element import Element +from pythonnative.hooks import HookState, _NavigationContext, _set_hook_state, component, use_navigation +from pythonnative.navigation import ( + NavigationContainer, + _build_screen_map, + _DeclarativeNavHandle, + _DrawerNavHandle, + _FocusContext, + _RouteEntry, + _ScreenDef, + _TabNavHandle, + create_drawer_navigator, + create_stack_navigator, + create_tab_navigator, + use_focus_effect, + use_route, +) +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend (same as test_reconciler / test_hooks) +# ====================================================================== + + +class MockView: + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + +class MockBackend: + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, view: MockView, type_name: str, changed: Dict[str, Any]) -> None: + view.props.update(changed) + self.ops.append(("update", type_name, view.id, tuple(sorted(changed.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# Data structures +# ====================================================================== + + +def test_screen_def_creation() -> None: + s = _ScreenDef("Home", lambda: None, {"title": "Home"}) + assert s.name == "Home" + assert s.options == {"title": "Home"} + assert "Home" in repr(s) + + +def test_screen_def_defaults() -> None: + s = _ScreenDef("Detail", lambda: None) + assert s.options == {} + + +def test_route_entry() -> None: + r = _RouteEntry("Home", {"id": 42}) + assert r.name == "Home" + assert r.params == {"id": 42} + assert "Home" in repr(r) + + +def test_route_entry_defaults() -> None: + r = _RouteEntry("Home") + assert r.params == {} + + +def test_build_screen_map() -> None: + screens = [ + _ScreenDef("A", lambda: None), + _ScreenDef("B", lambda: None), + "not a screen", + ] + result = _build_screen_map(screens) + assert set(result.keys()) == {"A", "B"} + + +def test_build_screen_map_empty() -> None: + assert _build_screen_map(None) == {} + assert _build_screen_map([]) == {} + + +# ====================================================================== +# _DeclarativeNavHandle +# ====================================================================== + + +def test_declarative_handle_navigate() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home")] + screens = {"Home": _ScreenDef("Home", lambda: None), "Detail": _ScreenDef("Detail", lambda: None)} + + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle(screens, lambda: stack, set_stack) + handle.navigate("Detail", {"id": 5}) + + assert len(captured) == 1 + assert captured[0][-1].name == "Detail" + assert captured[0][-1].params == {"id": 5} + + +def test_declarative_handle_go_back() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home"), _RouteEntry("Detail")] + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle({}, lambda: stack, set_stack) + handle.go_back() + + assert len(captured[-1]) == 1 + assert captured[-1][0].name == "Home" + + +def test_declarative_handle_go_back_stops_at_root() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home")] + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle({}, lambda: stack, set_stack) + handle.go_back() + + assert len(captured[-1]) == 1 + assert captured[-1][0].name == "Home" + + +def test_declarative_handle_get_params() -> None: + stack = [_RouteEntry("Home"), _RouteEntry("Detail", {"id": 42})] + handle = _DeclarativeNavHandle({}, lambda: stack, lambda _: None) + + assert handle.get_params() == {"id": 42} + + +def test_declarative_handle_get_params_empty_stack() -> None: + handle = _DeclarativeNavHandle({}, lambda: [], lambda _: None) + assert handle.get_params() == {} + + +def test_declarative_handle_reset() -> None: + stack: List[_RouteEntry] = [_RouteEntry("A"), _RouteEntry("B"), _RouteEntry("C")] + screens = {"Home": _ScreenDef("Home", lambda: None)} + captured: list = [] + + def set_stack(val: Any) -> None: + captured.append(val) + + handle = _DeclarativeNavHandle(screens, lambda: stack, set_stack) + handle.reset("Home", {"fresh": True}) + + assert len(captured) == 1 + assert len(captured[0]) == 1 + assert captured[0][0].name == "Home" + + +def test_declarative_handle_navigate_unknown_raises() -> None: + handle = _DeclarativeNavHandle({"Home": _ScreenDef("Home", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.navigate("Missing") + + +def test_declarative_handle_reset_unknown_raises() -> None: + handle = _DeclarativeNavHandle({"Home": _ScreenDef("Home", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.reset("Missing") + + +# ====================================================================== +# Tab nav handle +# ====================================================================== + + +def test_tab_handle_navigate_switches_tab() -> None: + switched: list = [] + screens = {"A": _ScreenDef("A", lambda: None), "B": _ScreenDef("B", lambda: None)} + + def switch_tab(name: str, params: Any = None) -> None: + switched.append((name, params)) + + handle = _TabNavHandle(screens, lambda: [], lambda _: None, switch_tab) + handle.navigate("B", {"x": 1}) + + assert switched == [("B", {"x": 1})] + + +# ====================================================================== +# Drawer nav handle +# ====================================================================== + + +def test_drawer_handle_open_close_toggle() -> None: + drawer_state = [False] + + def set_open(val: bool) -> None: + drawer_state[0] = val + + screens = {"A": _ScreenDef("A", lambda: None)} + + def noop_switch(n: str, p: Any = None) -> None: + pass + + handle = _DrawerNavHandle(screens, lambda: [], lambda _: None, noop_switch, set_open, lambda: drawer_state[0]) + + handle.open_drawer() + assert drawer_state[0] is True + + handle.close_drawer() + assert drawer_state[0] is False + + handle.toggle_drawer() + assert drawer_state[0] is True + + handle.toggle_drawer() + assert drawer_state[0] is False + + +def test_drawer_handle_navigate_closes_drawer() -> None: + drawer_state = [True] + switched: list = [] + + def set_open(val: bool) -> None: + drawer_state[0] = val + + def switch_screen(name: str, params: Any = None) -> None: + switched.append(name) + + screens = {"A": _ScreenDef("A", lambda: None), "B": _ScreenDef("B", lambda: None)} + handle = _DrawerNavHandle(screens, lambda: [], lambda _: None, switch_screen, set_open, lambda: drawer_state[0]) + + handle.navigate("B") + assert switched == ["B"] + assert drawer_state[0] is False + + +# ====================================================================== +# NavigationContainer +# ====================================================================== + + +def test_navigation_container_wraps_child() -> None: + child = Element("Text", {"text": "hi"}, []) + el = NavigationContainer(child) + assert el.type == "View" + assert el.props.get("flex") == 1 + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_navigation_container_with_key() -> None: + child = Element("Text", {"text": "hi"}, []) + el = NavigationContainer(child, key="nav") + assert el.key == "nav" + + +# ====================================================================== +# create_stack_navigator +# ====================================================================== + + +def test_stack_screen_creates_screen_def() -> None: + Stack = create_stack_navigator() + s = Stack.Screen("Home", component=lambda: None, options={"title": "Home"}) + assert isinstance(s, _ScreenDef) + assert s.name == "Home" + + +def test_stack_navigator_element() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) + assert isinstance(el, Element) + assert callable(el.type) + + +def test_stack_navigator_renders_initial_screen() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + root = rec.mount(el) + + assert any(op[0] == "create" and op[1] == "Text" for op in backend.ops) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "home" + + +def test_stack_navigator_respects_initial_route() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Detail", + ) + root = rec.mount(el) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "detail" + + +def test_stack_navigator_provides_navigation_context() -> None: + Stack = create_stack_navigator() + captured_nav: list = [None] + + @component + def HomeScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "home"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) + rec.mount(el) + + assert captured_nav[0] is not None + assert hasattr(captured_nav[0], "navigate") + assert hasattr(captured_nav[0], "go_back") + assert hasattr(captured_nav[0], "get_params") + + +def test_stack_navigator_empty_screens() -> None: + Stack = create_stack_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# create_tab_navigator +# ====================================================================== + + +def test_tab_screen_creates_screen_def() -> None: + Tab = create_tab_navigator() + s = Tab.Screen("Home", component=lambda: None, options={"title": "Home"}) + assert isinstance(s, _ScreenDef) + + +def test_tab_navigator_renders_initial_screen() -> None: + Tab = create_tab_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def SettingsScreen() -> Element: + return Element("Text", {"text": "settings"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + root = rec.mount(el) + + def find_texts(view: MockView) -> list: + result = [] + if view.type_name == "Text": + result.append(view.props.get("text")) + for c in view.children: + result.extend(find_texts(c)) + return result + + texts = find_texts(root) + assert "home" in texts + + +def test_tab_navigator_renders_tab_bar_buttons() -> None: + Tab = create_tab_navigator() + + @component + def ScreenA() -> Element: + return Element("Text", {"text": "a"}, []) + + @component + def ScreenB() -> Element: + return Element("Text", {"text": "b"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("TabA", component=ScreenA, options={"title": "Tab A"}), + Tab.Screen("TabB", component=ScreenB, options={"title": "Tab B"}), + ) + root = rec.mount(el) + + def find_buttons(view: MockView) -> list: + result = [] + if view.type_name == "Button": + result.append(view.props.get("title")) + for c in view.children: + result.extend(find_buttons(c)) + return result + + buttons = find_buttons(root) + assert "Tab A" in buttons + assert "Tab B" in buttons + + +def test_tab_navigator_empty_screens() -> None: + Tab = create_tab_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# create_drawer_navigator +# ====================================================================== + + +def test_drawer_screen_creates_screen_def() -> None: + Drawer = create_drawer_navigator() + s = Drawer.Screen("Home", component=lambda: None) + assert isinstance(s, _ScreenDef) + + +def test_drawer_navigator_renders_initial_screen() -> None: + Drawer = create_drawer_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def SettingsScreen() -> Element: + return Element("Text", {"text": "settings"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen), + Drawer.Screen("Settings", component=SettingsScreen), + ) + root = rec.mount(el) + + def find_texts(view: MockView) -> list: + result = [] + if view.type_name == "Text": + result.append(view.props.get("text")) + for c in view.children: + result.extend(find_texts(c)) + return result + + texts = find_texts(root) + assert "home" in texts + + +def test_drawer_navigator_empty_screens() -> None: + Drawer = create_drawer_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Drawer.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# use_route +# ====================================================================== + + +def test_use_route_returns_params() -> None: + stack = [_RouteEntry("Home"), _RouteEntry("Detail", {"id": 99})] + screens = {"Home": _ScreenDef("Home", lambda: None), "Detail": _ScreenDef("Detail", lambda: None)} + handle = _DeclarativeNavHandle(screens, lambda: stack, lambda _: None) + + _NavigationContext._stack.append(handle) + ctx = HookState() + _set_hook_state(ctx) + try: + params = use_route() + assert params == {"id": 99} + finally: + _set_hook_state(None) + _NavigationContext._stack.pop() + + +def test_use_route_no_context() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + params = use_route() + assert params == {} + finally: + _set_hook_state(None) + + +# ====================================================================== +# use_focus_effect +# ====================================================================== + + +def test_use_focus_effect_runs_when_focused() -> None: + calls: list = [] + + _FocusContext._stack.append(True) + ctx = HookState() + _set_hook_state(ctx) + try: + use_focus_effect(lambda: calls.append("focused"), []) + finally: + _set_hook_state(None) + _FocusContext._stack.pop() + + ctx.flush_pending_effects() + assert calls == ["focused"] + + +def test_use_focus_effect_skips_when_not_focused() -> None: + calls: list = [] + + _FocusContext._stack.append(False) + ctx = HookState() + _set_hook_state(ctx) + try: + use_focus_effect(lambda: calls.append("focused"), []) + finally: + _set_hook_state(None) + _FocusContext._stack.pop() + + ctx.flush_pending_effects() + assert calls == [] + + +# ====================================================================== +# Integration: stack navigator with reconciler +# ====================================================================== + + +def test_stack_navigator_navigate_and_go_back() -> None: + Stack = create_stack_navigator() + captured_nav: list = [None] + + @component + def HomeScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + renders: list = [] + rec._page_re_render = lambda: renders.append(1) + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + rec.mount(el) + + nav = captured_nav[0] + assert nav is not None + + nav.navigate("Detail", {"id": 1}) + assert len(renders) == 1 + + +def test_stack_navigator_with_navigation_container() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = NavigationContainer(Stack.Navigator(Stack.Screen("Home", component=HomeScreen))) + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# Public API surface +# ====================================================================== + + +def test_navigation_exports_from_package() -> None: + import pythonnative as pn + + assert hasattr(pn, "NavigationContainer") + assert hasattr(pn, "create_stack_navigator") + assert hasattr(pn, "create_tab_navigator") + assert hasattr(pn, "create_drawer_navigator") + assert hasattr(pn, "use_route") + assert hasattr(pn, "use_focus_effect") diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index a49e2f5..0751187 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -293,7 +293,7 @@ def test_keyed_children_preserve_identity() -> None: Element("Text", {"text": "C"}, [], key="c"), ], ) - rec.mount(el1) + root = rec.mount(el1) view_a = rec._tree.children[0].native_view view_b = rec._tree.children[1].native_view view_c = rec._tree.children[2].native_view @@ -314,6 +314,11 @@ def test_keyed_children_preserve_identity() -> None: assert rec._tree.children[1].native_view is view_a assert rec._tree.children[2].native_view is view_b + # Native children must also reflect the new order + assert root.children[0] is view_c + assert root.children[1] is view_a + assert root.children[2] is view_b + def test_keyed_children_remove_by_key() -> None: backend = MockBackend() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 18b14fc..23c865a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -43,12 +43,19 @@ def test_public_api_names() -> None: "use_callback", "use_context", "use_effect", + "use_focus_effect", "use_memo", "use_navigation", "use_reducer", "use_ref", + "use_route", "use_state", "Provider", + # Navigation + "NavigationContainer", + "create_drawer_navigator", + "create_stack_navigator", + "create_tab_navigator", # Styling "StyleSheet", "ThemeContext", From 2b8003218267dd39b968c630b89bd5e212ea7254 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:50:10 -0700 Subject: [PATCH 33/34] feat(navigation): add native tab bars and nested navigator forwarding --- docs/api/component-properties.md | 24 ++++ docs/guides/navigation.md | 21 ++- examples/hello-world/app/main_page.py | 31 +++- src/pythonnative/native_views/android.py | 143 +++++++++++++++++++ src/pythonnative/native_views/ios.py | 171 +++++++++++++++++++++++ src/pythonnative/navigation.py | 113 +++++++++------ src/pythonnative/reconciler.py | 4 + tests/test_navigation.py | 156 +++++++++++++++++++-- tests/test_reconciler.py | 31 ++++ 9 files changed, 635 insertions(+), 59 deletions(-) diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index db397f8..9def148 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -176,6 +176,30 @@ pn.Modal(*children, visible=show_modal, on_dismiss=handler, title="Confirm", Overlay dialog shown when `visible=True`. +## TabBar + +```python +pn.Element("TabBar", { + "items": [ + {"name": "Home", "title": "Home"}, + {"name": "Settings", "title": "Settings"}, + ], + "active_tab": "Home", + "on_tab_select": handler, +}) +``` + +Native tab bar — typically created automatically by `Tab.Navigator`. + +| Platform | Native view | +|----------|--------------------------| +| Android | `BottomNavigationView` | +| iOS | `UITabBar` | + +- `items` — list of `{"name": str, "title": str}` dicts defining each tab +- `active_tab` — the `name` of the currently active tab +- `on_tab_select` — callback `(str) -> None` receiving the selected tab name + ## FlatList ```python diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 1dbc35a..5d33ec7 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -3,7 +3,7 @@ PythonNative offers two approaches to navigation: 1. **Declarative navigators** (recommended) — component-based, inspired by React Navigation -2. **Legacy push/pop** — imperative navigation via `use_navigation()` +2. **Page-level push/pop** — imperative navigation via `use_navigation()` (for native page transitions) ## Declarative Navigation @@ -54,7 +54,9 @@ def DetailScreen(): ### Tab Navigator -A tab navigator renders a tab bar and switches between screens. +A tab navigator renders a **native tab bar** and switches between screens. +On Android the tab bar is a `BottomNavigationView` from Material Components; +on iOS it is a `UITabBar`. ```python from pythonnative.navigation import create_tab_navigator @@ -71,6 +73,13 @@ def App(): ) ``` +The tab bar emits a `TabBar` element that maps to platform-native views: + +| Platform | Native view | +|----------|------------------------------| +| Android | `BottomNavigationView` | +| iOS | `UITabBar` | + ### Drawer Navigator A drawer navigator provides a side menu for switching screens. @@ -100,7 +109,10 @@ def HomeScreen(): ### Nesting Navigators -Navigators can be nested — for example, tabs containing stacks: +Navigators can be nested — for example, tabs containing stacks. +When a child navigator receives a `navigate()` call for an unknown route, +it automatically **forwards** the request to its parent navigator. +Similarly, `go_back()` at the root of a child stack forwards to the parent. ```python Stack = create_stack_navigator() @@ -123,6 +135,9 @@ def App(): ) ``` +Inside `FeedScreen`, calling `nav.navigate("Settings")` will forward to the +parent tab navigator and switch to the Settings tab. + ## NavigationHandle API Inside any screen rendered by a navigator, `pn.use_navigation()` returns a handle with: diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 10f592c..23abd46 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,9 +1,11 @@ import emoji import pythonnative as pn +from pythonnative.navigation import NavigationContainer, create_tab_navigator MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] +Tab = create_tab_navigator() styles = pn.StyleSheet.create( title={"font_size": 24, "bold": True}, @@ -39,7 +41,8 @@ def counter_badge(initial: int = 0) -> pn.Element: @pn.component -def MainPage() -> pn.Element: +def HomeTab() -> pn.Element: + """Home tab — counter demo and push-navigation to other pages.""" nav = pn.use_navigation() return pn.ScrollView( pn.Column( @@ -55,3 +58,29 @@ def MainPage() -> pn.Element: style=styles["section"], ) ) + + +@pn.component +def SettingsTab() -> pn.Element: + """Settings tab — simple placeholder content.""" + return pn.ScrollView( + pn.Column( + pn.Text("Settings", style=styles["title"]), + pn.Text("App version: 0.7.0", style=styles["subtitle"]), + pn.Text( + "This tab uses a native UITabBar on iOS " "and BottomNavigationView on Android.", + style=styles["subtitle"], + ), + style=styles["section"], + ) + ) + + +@pn.component +def MainPage() -> pn.Element: + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeTab, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsTab, options={"title": "Settings"}), + ) + ) diff --git a/src/pythonnative/native_views/android.py b/src/pythonnative/native_views/android.py index 1e59d82..d36ae81 100644 --- a/src/pythonnative/native_views/android.py +++ b/src/pythonnative/native_views/android.py @@ -618,6 +618,148 @@ def onStopTrackingTouch(self, seekBar: Any) -> None: sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) +_android_tabbar_state: dict = {"callback": None, "items": []} + + +class TabBarHandler(ViewHandler): + """Native tab bar using ``BottomNavigationView`` from Material Components. + + Falls back to a horizontal ``LinearLayout`` with ``Button`` children + when Material Components is unavailable. + """ + + _is_material: bool = True + + def create(self, props: Dict[str, Any]) -> Any: + try: + bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx()) + bnv.setBackgroundColor(parse_color_int("#FFFFFF")) + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + lp = LayoutParams(ViewGroupLP.MATCH_PARENT, ViewGroupLP.WRAP_CONTENT) + bnv.setLayoutParams(lp) + self._is_material = True + self._apply_full(bnv, props) + return bnv + except Exception: + self._is_material = False + return self._create_fallback(props) + + def _create_fallback(self, props: Dict[str, Any]) -> Any: + """Horizontal LinearLayout with Button children as a tab-bar fallback.""" + LinearLayout = jclass("android.widget.LinearLayout") + ll = LinearLayout(_ctx()) + ll.setOrientation(LinearLayout.HORIZONTAL) + ll.setBackgroundColor(parse_color_int("#F8F8F8")) + self._apply_fallback(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if self._is_material: + self._apply_partial(native_view, changed) + else: + self._apply_fallback(native_view, changed) + + def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None: + """Initial creation — all props are present.""" + items = props.get("items", []) + self._set_menu(bnv, items) + self._set_active(bnv, props.get("active_tab"), items) + cb = props.get("on_tab_select") + if cb is not None: + self._set_listener(bnv, cb, items) + + def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None: + """Reconciler update — only changed props are present.""" + prev_items = _android_tabbar_state["items"] + + if "items" in changed: + items = changed["items"] + self._set_menu(bnv, items) + else: + items = prev_items + + if "active_tab" in changed: + self._set_active(bnv, changed["active_tab"], items) + + if "on_tab_select" in changed: + cb = changed["on_tab_select"] + if cb is not None: + self._set_listener(bnv, cb, items) + + def _set_menu(self, bnv: Any, items: list) -> None: + _android_tabbar_state["items"] = items + try: + menu = bnv.getMenu() + menu.clear() + for i, item in enumerate(items): + title = item.get("title", item.get("name", "")) + menu.add(0, i, i, str(title)) + except Exception: + pass + + def _set_active(self, bnv: Any, active: Any, items: list) -> None: + if active and items: + for i, item in enumerate(items): + if item.get("name") == active: + try: + bnv.setSelectedItemId(i) + except Exception: + pass + break + + def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None: + _android_tabbar_state["callback"] = cb + _android_tabbar_state["items"] = items + try: + listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener") + + class _TabSelectProxy(dynamic_proxy(listener_cls)): + def __init__(self, callback: Callable, tab_items: list) -> None: + super().__init__() + self.callback = callback + self.tab_items = tab_items + + def onNavigationItemSelected(self, menu_item: Any) -> bool: + idx = menu_item.getItemId() + if 0 <= idx < len(self.tab_items): + self.callback(self.tab_items[idx].get("name", "")) + return True + + bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items)) + except Exception: + pass + + def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None: + items = props.get("items", []) + active = props.get("active_tab") + cb = props.get("on_tab_select") + if "items" in props: + ll.removeAllViews() + for item in items: + name = item.get("name", "") + title = item.get("title", name) + btn = jclass("android.widget.Button")(_ctx()) + btn.setText(str(title)) + btn.setEnabled(name != active) + if cb is not None: + tab_name = name + + def _make_click(n: str) -> Callable[[], None]: + return lambda: cb(n) + + class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(_ClickProxy(_make_click(tab_name))) + ll.addView(btn) + + class PressableHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: fl = jclass("android.widget.FrameLayout")(_ctx()) @@ -686,4 +828,5 @@ def register_handlers(registry: Any) -> None: registry.register("SafeAreaView", SafeAreaViewHandler()) registry.register("Modal", ModalHandler()) registry.register("Slider", SliderHandler()) + registry.register("TabBar", TabBarHandler()) registry.register("Pressable", PressableHandler()) diff --git a/src/pythonnative/native_views/ios.py b/src/pythonnative/native_views/ios.py index 0631eaf..95fa0b2 100644 --- a/src/pythonnative/native_views/ios.py +++ b/src/pythonnative/native_views/ios.py @@ -7,6 +7,7 @@ a mock registry via :func:`~.set_registry` and never trigger this import. """ +import ctypes as _ct from typing import Any, Callable, Dict, Optional from rubicon.objc import SEL, ObjCClass, objc_method @@ -563,6 +564,175 @@ def _apply(self, sl: Any, props: Dict[str, Any]) -> None: sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) +_pn_tabbar_state: dict = {"callback": None, "items": []} +_pn_tabbar_delegate_installed: bool = False +_pn_tabbar_delegate_ptr: Any = None + +# --------------------------------------------------------------------------- +# UITabBar delegate via raw ctypes +# +# rubicon-objc's @objc_method crashes (SIGSEGV in PyObject_GetAttr) when +# UIKit invokes the delegate through the FFI closure — the reconstructed +# Python wrappers for ``self`` or ``item`` end up with ob_type == NULL. +# +# We sidestep rubicon-objc entirely: create a minimal ObjC class with +# libobjc, register a CFUNCTYPE IMP for tabBar:didSelectItem:, and use +# objc_msgSend to read ``item.tag`` from the raw pointer. +# --------------------------------------------------------------------------- + +_libobjc = _ct.cdll.LoadLibrary("libobjc.A.dylib") + +_sel_reg = _libobjc.sel_registerName +_sel_reg.restype = _ct.c_void_p +_sel_reg.argtypes = [_ct.c_char_p] + +_get_cls = _libobjc.objc_getClass +_get_cls.restype = _ct.c_void_p +_get_cls.argtypes = [_ct.c_char_p] + +_alloc_cls = _libobjc.objc_allocateClassPair +_alloc_cls.restype = _ct.c_void_p +_alloc_cls.argtypes = [_ct.c_void_p, _ct.c_char_p, _ct.c_size_t] + +_reg_cls = _libobjc.objc_registerClassPair +_reg_cls.argtypes = [_ct.c_void_p] + +_add_method = _libobjc.class_addMethod +_add_method.restype = _ct.c_bool +_add_method.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_char_p] + +_objc_msgSend = _libobjc.objc_msgSend + +# Pre-register selectors used in the raw delegate path +_SEL_ALLOC = _sel_reg(b"alloc") +_SEL_INIT = _sel_reg(b"init") +_SEL_RETAIN = _sel_reg(b"retain") +_SEL_SET_DELEGATE = _sel_reg(b"setDelegate:") +_SEL_TAG = _sel_reg(b"tag") + +# IMP type: void (id self, SEL _cmd, id tabBar, id item) +_DELEGATE_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p) + + +def _tabbar_did_select_imp(self_ptr: int, cmd_ptr: int, tabbar_ptr: int, item_ptr: int) -> None: + """Raw C callback for ``tabBar:didSelectItem:``.""" + try: + _objc_msgSend.restype = _ct.c_long + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p] + tag: int = _objc_msgSend(item_ptr, _SEL_TAG) + + cb = _pn_tabbar_state["callback"] + tab_items = _pn_tabbar_state["items"] + if cb is not None and tab_items and 0 <= tag < len(tab_items): + cb(tab_items[tag].get("name", "")) + except Exception: + pass + + +# prevent GC of the C callback +_tabbar_imp_ref = _DELEGATE_IMP_TYPE(_tabbar_did_select_imp) + +# Create and register a minimal ObjC class for the delegate +_NS_OBJECT_CLS = _get_cls(b"NSObject") +_PN_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTabBarDelegateCTypes", 0) +if _PN_DELEGATE_CLS: + _add_method( + _PN_DELEGATE_CLS, + _sel_reg(b"tabBar:didSelectItem:"), + _ct.cast(_tabbar_imp_ref, _ct.c_void_p), + b"v@:@@", + ) + _reg_cls(_PN_DELEGATE_CLS) + + +def _ensure_tabbar_delegate(tab_bar: Any) -> None: + """Create the singleton delegate (if needed) and assign it to *tab_bar*.""" + global _pn_tabbar_delegate_ptr + if _pn_tabbar_delegate_ptr is None and _PN_DELEGATE_CLS: + _objc_msgSend.restype = _ct.c_void_p + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p] + raw = _objc_msgSend(_PN_DELEGATE_CLS, _SEL_ALLOC) + raw = _objc_msgSend(raw, _SEL_INIT) + raw = _objc_msgSend(raw, _SEL_RETAIN) + _pn_tabbar_delegate_ptr = raw + + if _pn_tabbar_delegate_ptr is not None: + _objc_msgSend.restype = None + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p] + tab_bar_ptr = tab_bar.ptr if hasattr(tab_bar, "ptr") else tab_bar + _objc_msgSend(tab_bar_ptr, _SEL_SET_DELEGATE, _pn_tabbar_delegate_ptr) + + +class TabBarHandler(ViewHandler): + """Native tab bar using ``UITabBar``. + + Each tab is a ``UITabBarItem`` with a ``tag`` matching its index + in the items list. A raw ctypes delegate forwards selection + events back to the Python ``on_tab_select`` callback. + """ + + def create(self, props: Dict[str, Any]) -> Any: + tab_bar = ObjCClass("UITabBar").alloc().initWithFrame_(((0, 0), (0, 49))) + tab_bar.retain() + _pn_retained_views.append(tab_bar) + self._apply_full(tab_bar, props) + _apply_ios_layout(tab_bar, props) + return tab_bar + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply_partial(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply_full(self, tab_bar: Any, props: Dict[str, Any]) -> None: + items = props.get("items", []) + self._set_bar_items(tab_bar, items) + self._set_active(tab_bar, props.get("active_tab"), items) + self._set_callback(tab_bar, props.get("on_tab_select"), items) + + def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None: + prev_items = _pn_tabbar_state["items"] + + if "items" in changed: + items = changed["items"] + self._set_bar_items(tab_bar, items) + else: + items = prev_items + + if "active_tab" in changed: + self._set_active(tab_bar, changed["active_tab"], items) + + if "on_tab_select" in changed: + self._set_callback(tab_bar, changed["on_tab_select"], items) + + def _set_bar_items(self, tab_bar: Any, items: list) -> None: + UITabBarItem = ObjCClass("UITabBarItem") + bar_items = [] + for i, item in enumerate(items): + title = item.get("title", item.get("name", "")) + bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), None, i) + bar_items.append(bar_item) + tab_bar.setItems_animated_(bar_items, False) + + def _set_active(self, tab_bar: Any, active: Any, items: list) -> None: + if not active or not items: + return + for i, item in enumerate(items): + if item.get("name") == active: + try: + all_items = list(tab_bar.items or []) + if i < len(all_items): + tab_bar.setSelectedItem_(all_items[i]) + except Exception: + pass + break + + def _set_callback(self, tab_bar: Any, cb: Any, items: list) -> None: + _pn_tabbar_state["callback"] = cb + _pn_tabbar_state["items"] = items + _ensure_tabbar_delegate(tab_bar) + + class PressableHandler(ViewHandler): def create(self, props: Dict[str, Any]) -> Any: v = ObjCClass("UIView").alloc().init() @@ -603,4 +773,5 @@ def register_handlers(registry: Any) -> None: registry.register("SafeAreaView", SafeAreaViewHandler()) registry.register("Modal", ModalHandler()) registry.register("Slider", SliderHandler()) + registry.register("TabBar", TabBarHandler()) registry.register("Pressable", PressableHandler()) diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py index 7c9cef0..a0cd5a6 100644 --- a/src/pythonnative/navigation.py +++ b/src/pythonnative/navigation.py @@ -90,6 +90,11 @@ class _DeclarativeNavHandle: so that ``use_navigation()`` returns a compatible object regardless of whether the app uses the legacy page-based navigation or declarative navigators. + + When *parent* is provided, unknown routes and root-level ``go_back`` + calls are forwarded to the parent handle. This enables nested + navigators (e.g. a stack inside a tab) to delegate navigation actions + that they cannot handle locally. """ def __init__( @@ -97,21 +102,38 @@ def __init__( screen_map: Dict[str, "_ScreenDef"], get_stack: Callable[[], List["_RouteEntry"]], set_stack: Callable, + parent: Any = None, ) -> None: self._screen_map = screen_map self._get_stack = get_stack self._set_stack = set_stack + self._parent = parent def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: - """Navigate to a named route, pushing it onto the stack.""" - if route_name not in self._screen_map: + """Navigate to a named route, pushing it onto the stack. + + If *route_name* is not known locally and a parent handle exists, + the call is forwarded to the parent navigator. + """ + if route_name in self._screen_map: + entry = _RouteEntry(route_name, params) + self._set_stack(lambda s: list(s) + [entry]) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") - entry = _RouteEntry(route_name, params) - self._set_stack(lambda s: list(s) + [entry]) def go_back(self) -> None: - """Pop the current screen from the stack.""" - self._set_stack(lambda s: list(s[:-1]) if len(s) > 1 else list(s)) + """Pop the current screen from the stack. + + If the stack is at its root and a parent handle exists, the call + is forwarded to the parent navigator. + """ + stack = self._get_stack() + if len(stack) > 1: + self._set_stack(lambda s: list(s[:-1])) + elif self._parent is not None: + self._parent.go_back() def get_params(self) -> Dict[str, Any]: """Return the parameters for the current route.""" @@ -134,15 +156,19 @@ def __init__( get_stack: Callable[[], List["_RouteEntry"]], set_stack: Callable, switch_tab: Callable[[str, Optional[Dict[str, Any]]], None], + parent: Any = None, ) -> None: - super().__init__(screen_map, get_stack, set_stack) + super().__init__(screen_map, get_stack, set_stack, parent=parent) self._switch_tab = switch_tab def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: - """Switch to a tab by name.""" - if route_name not in self._screen_map: + """Switch to a tab by name, or forward to parent for unknown routes.""" + if route_name in self._screen_map: + self._switch_tab(route_name, params) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") - self._switch_tab(route_name, params) class _DrawerNavHandle(_DeclarativeNavHandle): @@ -156,18 +182,22 @@ def __init__( switch_screen: Callable[[str, Optional[Dict[str, Any]]], None], set_drawer_open: Callable[[bool], None], get_drawer_open: Callable[[], bool], + parent: Any = None, ) -> None: - super().__init__(screen_map, get_stack, set_stack) + super().__init__(screen_map, get_stack, set_stack, parent=parent) self._switch_screen = switch_screen self._set_drawer_open = set_drawer_open self._get_drawer_open = get_drawer_open def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: - """Switch to a screen and close the drawer.""" - if route_name not in self._screen_map: + """Switch to a screen and close the drawer, or forward to parent.""" + if route_name in self._screen_map: + self._switch_screen(route_name, params) + self._set_drawer_open(False) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") - self._switch_screen(route_name, params) - self._set_drawer_open(False) def open_drawer(self) -> None: """Open the drawer.""" @@ -202,14 +232,19 @@ def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = No if not screen_map: return Element("View", {}, []) + parent_nav = use_context(_NavigationContext) + first_route = initial_route or next(iter(screen_map)) stack, set_stack = use_state(lambda: [_RouteEntry(first_route)]) stack_ref = use_ref(None) stack_ref["current"] = stack - handle = use_memo(lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref["current"], set_stack), []) + handle = use_memo( + lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref["current"], set_stack, parent=parent_nav), [] + ) handle._screen_map = screen_map + handle._parent = parent_nav current = stack[-1] screen_def = screen_map.get(current.name) @@ -262,6 +297,8 @@ def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None if not screen_map: return Element("View", {}, []) + parent_nav = use_context(_NavigationContext) + first_route = initial_route or screen_list[0].name active_tab, set_active_tab = use_state(first_route) tab_params, set_tab_params = use_state(lambda: {first_route: {}}) @@ -278,42 +315,28 @@ def get_stack() -> List[_RouteEntry]: p = params_ref["current"] or {} return [_RouteEntry(active_tab, p.get(active_tab, {}))] - handle = use_memo(lambda: _TabNavHandle(screen_map, get_stack, lambda _: None, switch_tab), []) + handle = use_memo(lambda: _TabNavHandle(screen_map, get_stack, lambda _: None, switch_tab, parent=parent_nav), []) handle._screen_map = screen_map handle._switch_tab = switch_tab + handle._parent = parent_nav screen_def = screen_map.get(active_tab) if screen_def is None: screen_def = screen_map[screen_list[0].name] - tab_buttons: List[Element] = [] + tab_items: List[Dict[str, str]] = [] for s in screen_list: - if not isinstance(s, _ScreenDef): - continue - is_active = s.name == active_tab - label = s.options.get("title", s.name) - tab_name = s.name - - def make_on_click(n: str) -> Callable[[], None]: - return lambda: switch_tab(n) - - tab_buttons.append( - Element( - "Button", - { - "title": label, - "on_click": make_on_click(tab_name), - "enabled": not is_active, - }, - [], - key=f"__tab_{tab_name}", - ) - ) + if isinstance(s, _ScreenDef): + tab_items.append({"name": s.name, "title": s.options.get("title", s.name)}) + + def on_tab_select(name: str) -> None: + switch_tab(name) tab_bar = Element( - "View", - {"flex_direction": "row", "background_color": "#F8F8F8"}, - tab_buttons, + "TabBar", + {"items": tab_items, "active_tab": active_tab, "on_tab_select": on_tab_select}, + [], + key="__tab_bar__", ) screen_el = screen_def.component() @@ -325,7 +348,7 @@ def make_on_click(n: str) -> Callable[[], None]: return Element( "View", - {"flex_direction": "column"}, + {"flex_direction": "column", "flex": 1}, [Element("View", {"flex": 1}, [content]), tab_bar], ) @@ -369,6 +392,8 @@ def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = N if not screen_map: return Element("View", {}, []) + parent_nav = use_context(_NavigationContext) + first_route = initial_route or screen_list[0].name active_screen, set_active_screen = use_state(first_route) drawer_open, set_drawer_open = use_state(False) @@ -394,6 +419,7 @@ def get_stack() -> List[_RouteEntry]: switch_screen, set_drawer_open, lambda: drawer_open, + parent=parent_nav, ), [], ) @@ -401,6 +427,7 @@ def get_stack() -> List[_RouteEntry]: handle._switch_screen = switch_screen handle._set_drawer_open = set_drawer_open handle._get_drawer_open = lambda: drawer_open + handle._parent = parent_nav screen_def = screen_map.get(active_screen) if screen_def is None: diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index a4f8938..f24ab5d 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -287,7 +287,11 @@ def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> Non self.backend.insert_child(parent.native_view, node.native_view, parent_type, i) new_child_nodes.append(node) else: + old_native = matched.native_view updated = self._reconcile_node(matched, new_el) + if is_native and updated.native_view is not old_native: + self.backend.remove_child(parent.native_view, old_native, parent_type) + self.backend.insert_child(parent.native_view, updated.native_view, parent_type, i) new_child_nodes.append(updated) # Destroy unused old nodes diff --git a/tests/test_navigation.py b/tests/test_navigation.py index a666d53..f45ca55 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -170,8 +170,9 @@ def set_stack(val: Any) -> None: handle = _DeclarativeNavHandle({}, lambda: stack, set_stack) handle.go_back() - assert len(captured[-1]) == 1 - assert captured[-1][0].name == "Home" + assert captured == [] + assert len(stack) == 1 + assert stack[0].name == "Home" def test_declarative_handle_get_params() -> None: @@ -473,7 +474,7 @@ def find_texts(view: MockView) -> list: assert "home" in texts -def test_tab_navigator_renders_tab_bar_buttons() -> None: +def test_tab_navigator_renders_native_tab_bar() -> None: Tab = create_tab_navigator() @component @@ -494,17 +495,23 @@ def ScreenB() -> Element: ) root = rec.mount(el) - def find_buttons(view: MockView) -> list: - result = [] - if view.type_name == "Button": - result.append(view.props.get("title")) + def find_tab_bar(view: MockView) -> Any: + if view.type_name == "TabBar": + return view for c in view.children: - result.extend(find_buttons(c)) - return result + r = find_tab_bar(c) + if r is not None: + return r + return None - buttons = find_buttons(root) - assert "Tab A" in buttons - assert "Tab B" in buttons + tab_bar = find_tab_bar(root) + assert tab_bar is not None + assert tab_bar.props["items"] == [ + {"name": "TabA", "title": "Tab A"}, + {"name": "TabB", "title": "Tab B"}, + ] + assert tab_bar.props["active_tab"] == "TabA" + assert callable(tab_bar.props["on_tab_select"]) def test_tab_navigator_empty_screens() -> None: @@ -698,6 +705,131 @@ def HomeScreen() -> Element: assert root.type_name == "View" +# ====================================================================== +# Parent forwarding (nested navigators) +# ====================================================================== + + +def test_declarative_handle_forwards_navigate_to_parent() -> None: + """Unknown routes in a child navigator forward to the parent.""" + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + child_screens = {"A": _ScreenDef("A", lambda: None)} + handle = _DeclarativeNavHandle(child_screens, lambda: [_RouteEntry("A")], lambda _: None, parent=_MockParent()) + + handle.navigate("UnknownRoute", {"key": "value"}) + assert parent_calls == [("navigate", "UnknownRoute", {"key": "value"})] + + +def test_declarative_handle_forwards_go_back_at_root() -> None: + """go_back at the root of a child navigator forwards to the parent.""" + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + child_screens = {"A": _ScreenDef("A", lambda: None)} + stack: List[_RouteEntry] = [_RouteEntry("A")] + handle = _DeclarativeNavHandle(child_screens, lambda: stack, lambda _: None, parent=_MockParent()) + + handle.go_back() + assert parent_calls == [("go_back",)] + + +def test_declarative_handle_no_parent_raises_on_unknown() -> None: + """Without a parent, unknown routes still raise ValueError.""" + handle = _DeclarativeNavHandle({"A": _ScreenDef("A", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.navigate("Missing") + + +def test_tab_handle_forwards_unknown_to_parent() -> None: + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + screens = {"TabA": _ScreenDef("TabA", lambda: None)} + + def noop_switch(name: str, params: Any = None) -> None: + pass + + handle = _TabNavHandle(screens, lambda: [], lambda _: None, noop_switch, parent=_MockParent()) + handle.navigate("ExternalRoute", {"x": 1}) + assert parent_calls == [("navigate", "ExternalRoute", {"x": 1})] + + +def test_drawer_handle_forwards_unknown_to_parent() -> None: + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + screens = {"DrawerA": _ScreenDef("DrawerA", lambda: None)} + + def noop_switch(n: str, p: Any = None) -> None: + pass + + handle = _DrawerNavHandle( + screens, lambda: [], lambda _: None, noop_switch, lambda _: None, lambda: False, parent=_MockParent() + ) + handle.navigate("ExternalRoute") + assert parent_calls == [("navigate", "ExternalRoute", None)] + + +def test_stack_inside_tab_forwards_to_parent() -> None: + """A Stack.Navigator nested inside a Tab.Navigator can forward.""" + Stack = create_stack_navigator() + Tab = create_tab_navigator() + + captured_nav: list = [None] + + @component + def InnerScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "inner"}, []) + + @component + def InnerStack() -> Element: + return Stack.Navigator(Stack.Screen("Inner", component=InnerScreen)) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("TabA", component=InnerStack), + Tab.Screen("TabB", component=lambda: Element("Text", {"text": "b"}, [])), + ) + rec.mount(el) + + nav = captured_nav[0] + assert nav is not None + + nav.navigate("TabB") + assert True # no error means forwarding worked + + # ====================================================================== # Public API surface # ====================================================================== diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 0751187..08ac250 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -531,3 +531,34 @@ def effect() -> Any: rec.reconcile(my_comp(dep=2)) assert log == ["run-1", "cleanup-1", "run-2"] + + +def test_provider_child_native_view_swap() -> None: + """When a Provider wraps different component types across renders, + the parent native container must swap the old native subview for the new one.""" + from pythonnative.hooks import Provider, create_context + + ctx = create_context(None) + + @component + def CompA() -> Element: + return Element("Text", {"text": "A"}, []) + + @component + def CompB() -> Element: + return Element("Text", {"text": "B"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + + tree1 = Element("View", {}, [Provider(ctx, "v1", CompA())]) + root = rec.mount(tree1) + assert len(root.children) == 1 + assert root.children[0].props["text"] == "A" + old_child_id = root.children[0].id + + tree2 = Element("View", {}, [Provider(ctx, "v2", CompB())]) + rec.reconcile(tree2) + assert len(root.children) == 1 + assert root.children[0].props["text"] == "B" + assert root.children[0].id != old_child_id From 1a3bf472b9cc5b7620eb87773cd1bc559402865f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 8 Apr 2026 03:36:45 +0000 Subject: [PATCH 34/34] chore(release): v0.8.0 --- CHANGELOG.md | 22 ++++++++++++++++++++++ pyproject.toml | 2 +- src/pythonnative/__init__.py | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8d2c7..1f6260b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,28 @@ # CHANGELOG +## v0.8.0 (2026-04-08) + +### Features + +- **hooks,reconciler**: Defer effects; add batching and use_reducer + ([`bf6bb57`](https://github.com/pythonnative/pythonnative/commit/bf6bb57b6f97c140820a902ea0eff6bf6a7ffdbc)) + +- **native_views,components**: Add flexbox-inspired layout system + ([`094d997`](https://github.com/pythonnative/pythonnative/commit/094d99786f7153a7286eb7db9775db0bb90abf1d)) + +- **navigation**: Add declarative navigation system + ([`828bbb0`](https://github.com/pythonnative/pythonnative/commit/828bbb0c83fba640a7055edf1237500f27493fd3)) + +- **navigation**: Add native tab bars and nested navigator forwarding + ([`2b80032`](https://github.com/pythonnative/pythonnative/commit/2b8003218267dd39b968c630b89bd5e212ea7254)) + +### Refactoring + +- **native_views**: Split monolithic module into platform-specific package + ([`d0068fd`](https://github.com/pythonnative/pythonnative/commit/d0068fdbcceb4745b02d8043b03eade2b54dde66)) + + ## v0.7.0 (2026-04-03) ### Features diff --git a/pyproject.toml b/pyproject.toml index 3e7765e..b5ae010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.7.0" +version = "0.8.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 c50407c..11c4e16 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -14,7 +14,7 @@ def App(): ) """ -__version__ = "0.7.0" +__version__ = "0.8.0" from .components import ( ActivityIndicator,