diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71428bf..8336d6c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,6 +112,7 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc - `platform_metrics` – platform-reported metrics like safe-area insets and bar heights (`platform_metrics.py`) - `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`) - `screen` – screen host, native lifecycle bridge, and render scheduling (`screen.py`) + - `sdk` – public extension SDK for custom native components (`sdk/`) - `style` – StyleSheet and theming (`style.py`) - `utils` – shared utilities (`utils.py`) diff --git a/README.md b/README.md index 8b8d4a5..a6a5c03 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app - **Declarative UI:** Describe *what* your UI should look like with element functions (`Text`, `Button`, `Column`, `Row`, etc.). PythonNative creates and updates native views automatically. - **Hooks and function components:** Manage state with `use_state`, side effects with `use_effect`, and navigation with `use_navigation`, all through one consistent pattern. -- **`style` prop:** Pass all visual and layout properties through a single `style` dict, composable via `StyleSheet`. +- **Typed `style` prop:** Pass all visual and layout properties through a single `style` dict, fully described by the `pn.Style` `TypedDict` and the ergonomic `pn.style(...)` helper for IDE autocomplete and static checking. Compose reusable styles with `StyleSheet`. - **Cross-platform flexbox engine:** A pure-Python, Yoga-style layout engine computes frames once and applies them to native views, so `flex`, `padding`, `aspect_ratio`, and `position: "absolute"` produce the same geometry on Android and iOS. - **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. +- **Custom-component SDK:** Wrap any platform widget as a first-class element with type-checked props via `pythonnative.sdk` (`Props`, `@native_component`, `element_factory`). Plugins distributed on PyPI auto-register through the `pythonnative.handlers` entry-point group. - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app. - **Native-backed navigation:** Declarative `Stack`, `Tab`, and `Drawer` navigators inspired by React Navigation. The root stack drives the platform's native navigation controller (`UINavigationController` on iOS, AndroidX Navigation Component on Android), so transitions, back gestures, and the hardware back button match what users expect. - **Fast Refresh hot reload:** `pn run --hot-reload` watches `app/` and patches edits into the running app on save, preserving component state across most changes. @@ -59,12 +60,12 @@ import pythonnative as pn def App(): count, set_count = pn.use_state(0) return pn.Column( - pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Text(f"Count: {count}", style=pn.style(font_size=24, bold=True)), pn.Button( "Tap me", on_click=lambda: set_count(count + 1), ), - style={"spacing": 12, "padding": 16}, + style=pn.style(spacing=12, padding=16), ) ``` diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index c768921..44de382 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -42,13 +42,14 @@ The reference is split per module so each page stays scannable: | System dialogs | [Alerts](alerts.md) | [`Alert`][pythonnative.Alert] | | Platform | [Platform](platform.md) | [`Platform`][pythonnative.Platform] | | Navigation | [Navigation](navigation.md) | [`NavigationContainer`][pythonnative.NavigationContainer], [`create_stack_navigator`][pythonnative.create_stack_navigator], [`create_tab_navigator`][pythonnative.create_tab_navigator], [`create_drawer_navigator`][pythonnative.create_drawer_navigator], [`use_navigation`][pythonnative.use_navigation] | -| Styling | [Style](style.md) | [`StyleSheet`][pythonnative.StyleSheet], [`ThemeContext`][pythonnative.style.ThemeContext] | +| Styling | [Style](style.md) | [`StyleSheet`][pythonnative.StyleSheet], [`Style`][pythonnative.style.Style], [`StyleProp`][pythonnative.style.StyleProp], [`style`][pythonnative.style.style], [`ThemeContext`][pythonnative.style.ThemeContext] | | Element descriptor | [Element](element.md) | [`Element`][pythonnative.Element] | | Screen host | [Screen](screen.md) | [`create_screen`][pythonnative.create_screen] | | Reconciler | [Reconciler](reconciler.md) | [`Reconciler`][pythonnative.reconciler.Reconciler] | | Native modules | [Native modules](native_modules.md) | `Camera`, `Location`, `FileSystem`, `Notifications` | | Native views | [Native views](native_views.md) | [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry], [`ViewHandler`][pythonnative.native_views.base.ViewHandler] | | Hot reload | [Hot reload](hot_reload.md) | [`FileWatcher`][pythonnative.hot_reload.FileWatcher], [`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] | +| Custom components SDK | [SDK](sdk.md) | [`Props`][pythonnative.sdk._components.Props], [`ViewHandler`][pythonnative.native_views.base.ViewHandler], [`native_component`][pythonnative.sdk._components.native_component], [`register_component`][pythonnative.sdk._components.register_component], [`element_factory`][pythonnative.sdk._components.element_factory] | | Utilities | [Utilities](utils.md) | `IS_ANDROID`, `IS_IOS`, [`get_android_context`][pythonnative.utils.get_android_context] | | CLI | [CLI (`pn`)](cli.md) | `pn init`, `pn run`, `pn clean` | diff --git a/docs/api/sdk.md b/docs/api/sdk.md new file mode 100644 index 0000000..a82b02d --- /dev/null +++ b/docs/api/sdk.md @@ -0,0 +1,74 @@ +# SDK + +`pythonnative.sdk` is the public extension API for adding new native +widgets to PythonNative. It re-exports the +[`Element`][pythonnative.element.Element] descriptor, the +[`ViewHandler`][pythonnative.native_views.base.ViewHandler] protocol, +and the typed style primitives so plugin authors only need a single +import path. The reference here documents the symbols that are +*unique* to the SDK module — the re-exports are documented on their +canonical pages and linked below. + +The full walkthrough lives in +[Custom native components](../guides/custom-native-components.md); +this page is the symbol-level reference. + +## Re-exports + +The following names are re-exported from `pythonnative.sdk` for +convenience and are documented on their canonical pages: + +| Symbol | Defined in | +|---|---| +| [`Element`][pythonnative.element.Element] | [Element](element.md) | +| [`ViewHandler`][pythonnative.native_views.base.ViewHandler] | [Native views](native_views.md) | +| [`Style`][pythonnative.style.Style], [`StyleProp`][pythonnative.style.StyleProp], [`Color`][pythonnative.style.Color], [`Dimension`][pythonnative.style.Dimension], [`EdgeInsets`][pythonnative.style.EdgeInsets], [`EdgeValue`][pythonnative.style.EdgeValue], `FlexDirection`, `JustifyContent`, `Overflow`, `Position`, [`TransformSpec`][pythonnative.style.TransformSpec], [`style`][pythonnative.style.style] | [Style](style.md) | +| `parse_color_int`, `resolve_padding` | `pythonnative.native_views.base` | + +## Custom-component primitives + +::: pythonnative.sdk + options: + show_root_heading: false + show_root_toc_entry: false + members_order: source + members: + - ENTRY_POINT_GROUP + - Props + - native_component + - register_component + - unregister_component + - element_factory + - install_into_registry + - list_components + - get_props_type + +## Entry-point discovery + +Third-party packages can register handlers automatically by exposing +an entry point in the `pythonnative.handlers` group (the value of +[`ENTRY_POINT_GROUP`][pythonnative.sdk.ENTRY_POINT_GROUP]). The first +call to [`get_registry()`][pythonnative.native_views.get_registry] +loads every registered entry point exactly once. A misbehaving plugin +raises an exception that is caught and logged; it never breaks +PythonNative startup. + +```toml +# In your plugin's pyproject.toml +[project.entry-points."pythonnative.handlers"] +my_widget = "my_pkg:register" +``` + +The function pointed at by the entry point should perform whatever +imports are needed to call +[`@native_component`][pythonnative.sdk.native_component] or +[`register_component`][pythonnative.sdk.register_component]. + +## Next steps + +- [Custom native components guide](../guides/custom-native-components.md) + walks through a complete `Badge` widget across iOS and Android. +- [Native views (concept)](../concepts/native-views.md) describes the + reconciler boundary the SDK plugs into. +- [Native views API](native_views.md) documents the runtime registry + the SDK installs handlers onto. diff --git a/docs/concepts/native-views.md b/docs/concepts/native-views.md index b8a9b9c..1a2877d 100644 --- a/docs/concepts/native-views.md +++ b/docs/concepts/native-views.md @@ -168,32 +168,54 @@ introspect. ## Custom widgets -Adding a widget is a three-step process: - -1. Implement a handler subclass for each platform you support. -2. Register it under a unique type string. -3. Add a small Python factory that returns - `Element(, props, children)`. +Adding a widget is a three-step process, and the +[`pythonnative.sdk`](../api/sdk.md) module gives you a ready-made, +type-checked entry point for each step: + +1. Define a frozen [`Props`][pythonnative.sdk._components.Props] + dataclass listing the widget's API surface. +2. Implement a [`ViewHandler`][pythonnative.native_views.base.ViewHandler] + subclass per platform and decorate it with + [`@native_component`][pythonnative.sdk._components.native_component]. +3. Hand callers an + [`element_factory`][pythonnative.sdk._components.element_factory] + that validates kwargs against the dataclass and returns regular + `Element` instances. ```python +from dataclasses import dataclass +from typing import Optional import pythonnative as pn -from pythonnative.element import Element -from pythonnative.native_views import get_registry +from pythonnative.sdk import Props, ViewHandler, element_factory, native_component -class _RatingHandler: - def create_view(self, props): - # platform-specific stars widget - ... - def update_view(self, view, prev, next): + +@dataclass(frozen=True) +class RatingProps(Props): + value: float = 0.0 + on_change: Optional[callable] = None + style: Optional[pn.StyleProp] = None + + +@native_component("Rating", props=RatingProps, platforms=("ios",)) +class IOSRatingHandler(ViewHandler): + def create(self, props): + ... # build a UIView wrapping star UIImageViews + def update(self, view, changed): ... -get_registry().register("Rating", _RatingHandler()) -def Rating(value: float, *, on_change=None, **kwargs): - return Element("Rating", {"value": value, "on_change": on_change, **kwargs}, []) +Rating = element_factory("Rating") ``` -The reconciler treats `Rating` like any other element after that. +After registration the reconciler treats `Rating` like any other +element. PyPI plugins can register their handlers automatically via +the `pythonnative.handlers` entry-point group (see +[`ENTRY_POINT_GROUP`][pythonnative.sdk._components.ENTRY_POINT_GROUP]), +so users only have to `pip install` your package. + +For the full walkthrough — typed props, iOS handler, Android handler, +distribution as a plugin, unit-testing — see the +[Custom native components guide](../guides/custom-native-components.md). ## Next steps diff --git a/docs/guides/custom-native-components.md b/docs/guides/custom-native-components.md new file mode 100644 index 0000000..c6ea73b --- /dev/null +++ b/docs/guides/custom-native-components.md @@ -0,0 +1,326 @@ +# Custom native components + +PythonNative ships a public extension SDK, +[`pythonnative.sdk`](../api/sdk.md), that lets you wrap a real +platform widget — a `UIView` subclass on iOS, a `View` subclass on +Android — and expose it to user code as a first-class element with +type-checked props. Custom components participate in reconciliation, +flex layout, and Fast Refresh exactly like the built-ins. + +This guide walks through the four-file shape of a typical component +(typed props, iOS handler, Android handler, registration) and shows +how to ship one as an installable PyPI plugin. + +## Why an SDK? + +Before the SDK, custom widgets were a monkey-patching exercise: +construct a `ViewHandler` subclass, reach into the global +[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry], +and define your own ad-hoc element factory by hand. There was no +contract for prop validation, no entry point for third-party +plugins, and `mypy` couldn't help you. + +The SDK fixes that: + +- A frozen [`Props`][pythonnative.sdk._components.Props] dataclass + declares every prop your component accepts, with types, defaults, + and IDE autocomplete. +- A [`@native_component`][pythonnative.sdk._components.native_component] + decorator registers your handlers under a unique element name. +- An [`element_factory`][pythonnative.sdk._components.element_factory] + turns that registration into a callable users invoke like any + other built-in. +- Discovery via the `pythonnative.handlers` entry-point group (see + [`ENTRY_POINT_GROUP`][pythonnative.sdk._components.ENTRY_POINT_GROUP]) + lets your component appear automatically when users `pip install` + your package. + +## A worked example: `Badge` + +We'll build a small widget that draws a coloured pill with a centred +label — useful for unread counts, status chips, etc. The same +project layout works for anything from a chart view to a camera +preview. + +### 1. Define typed props + +Create `my_pkg/badge_props.py`: + +```python +from dataclasses import dataclass +from typing import Optional + +import pythonnative as pn +from pythonnative.sdk import Props + + +@dataclass(frozen=True) +class BadgeProps(Props): + """Visible state of a Badge. + + All fields default so callers can pass only the props they care + about. ``style`` is the standard ``StyleProp`` accepted by every + built-in factory. + """ + + text: str = "" + color: str = "#FF3B30" + text_color: str = "#FFFFFF" + style: Optional[pn.StyleProp] = None +``` + +`Props` is a frozen dataclass; instances are immutable so equality +diffing in the reconciler stays cheap. + +### 2. Implement the iOS handler + +`my_pkg/badge_ios.py` runs only when `IS_IOS` is true. It uses +[rubicon-objc](https://rubicon-objc.readthedocs.io/) to wrap a +`UIView` containing a `UILabel`: + +```python +from typing import Any, Dict + +from rubicon.objc import ObjCClass + +from pythonnative.sdk import ViewHandler, native_component +from .badge_props import BadgeProps + +UIView = ObjCClass("UIView") +UILabel = ObjCClass("UILabel") +UIColor = ObjCClass("UIColor") + + +def _hex_to_uicolor(hex_str: str) -> Any: + s = hex_str.lstrip("#") + if len(s) == 6: + a = 1.0 + r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) + else: # 8-char AARRGGBB + a = int(s[0:2], 16) / 255.0 + r, g, b = int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16) + return UIColor.colorWithRed_green_blue_alpha_(r / 255, g / 255, b / 255, a) + + +@native_component("Badge", props=BadgeProps, platforms=("ios",)) +class IOSBadgeHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + view = UIView.alloc().init() + view.layer.cornerRadius = 12 + label = UILabel.alloc().init() + label.textAlignment = 1 # NSTextAlignmentCenter + view.addSubview_(label) + view._pn_label = label + self.update(view, props) + return view + + def update(self, view: Any, changed: Dict[str, Any]) -> None: + if "color" in changed: + view.backgroundColor = _hex_to_uicolor(changed["color"]) + if "text" in changed: + view._pn_label.text = changed["text"] + if "text_color" in changed: + view._pn_label.textColor = _hex_to_uicolor(changed["text_color"]) + + def set_frame(self, view: Any, x: float, y: float, w: float, h: float) -> None: + view.frame = ((x, y), (w, h)) + view._pn_label.frame = ((0, 0), (w, h)) + + def measure_intrinsic(self, view: Any, max_w: float, max_h: float) -> tuple[float, float]: + size = view._pn_label.sizeThatFits_((max_w - 24, max_h)) + return (float(size.width) + 24.0, float(size.height) + 8.0) +``` + +### 3. Implement the Android handler + +`my_pkg/badge_android.py` runs only when `IS_ANDROID` is true. It +uses [Chaquopy](https://chaquo.com/chaquopy/) to wrap a `TextView` +inside a `FrameLayout`: + +```python +from typing import Any, Dict + +from java import jclass + +from pythonnative.sdk import ViewHandler, native_component +from pythonnative.utils import get_android_context +from .badge_props import BadgeProps + +FrameLayout = jclass("android.widget.FrameLayout") +TextView = jclass("android.widget.TextView") +GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") +Color = jclass("android.graphics.Color") +Gravity = jclass("android.view.Gravity") + + +@native_component("Badge", props=BadgeProps, platforms=("android",)) +class AndroidBadgeHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ctx = get_android_context() + container = FrameLayout(ctx) + bg = GradientDrawable() + bg.setShape(GradientDrawable.RECTANGLE) + bg.setCornerRadius(24.0) + container.setBackground(bg) + label = TextView(ctx) + label.setGravity(Gravity.CENTER) + container.addView(label) + container._pn_label = label + container._pn_bg = bg + self.update(container, props) + return container + + def update(self, view: Any, changed: Dict[str, Any]) -> None: + if "color" in changed: + view._pn_bg.setColor(Color.parseColor(changed["color"])) + if "text" in changed: + view._pn_label.setText(changed["text"]) + if "text_color" in changed: + view._pn_label.setTextColor(Color.parseColor(changed["text_color"])) +``` + +The `set_frame` and `measure_intrinsic` shapes are identical to the +built-in handlers; see +[Native views](../concepts/native-views.md) for the full protocol. + +### 4. Wire it into your project + +`my_pkg/__init__.py` imports the right module based on the active +runtime and exposes a typed factory: + +```python +from pythonnative.sdk import element_factory +from pythonnative.utils import IS_ANDROID, IS_IOS + +if IS_ANDROID: + from . import badge_android # noqa: F401 # registers AndroidBadgeHandler +elif IS_IOS: + from . import badge_ios # noqa: F401 # registers IOSBadgeHandler + +Badge = element_factory("Badge") +``` + +Users now write: + +```python +import pythonnative as pn +from my_pkg import Badge + +@pn.component +def NotificationsButton(): + count, _ = pn.use_state(3) + return pn.Row( + pn.Text("Inbox"), + Badge(text=str(count), color="#0A84FF"), + style={"spacing": 8, "align_items": "center"}, + ) +``` + +`Badge(...)` validates kwargs against `BadgeProps`, resolves the +`style` argument through [`resolve_style`][pythonnative.style.resolve_style], +and returns a regular [`Element`][pythonnative.Element]. + +## Validation rules + +The factory enforces a strict contract on its arguments: + +| Call site | Result | +|---|---| +| `Badge(text="3")` | Validated against `BadgeProps`. Unknown fields raise `TypeError`. | +| `Badge(props=BadgeProps(text="3"))` | Used directly. `style` is still resolved if present. | +| `Badge(props=..., text="3")` | `TypeError`: pass either `props` *or* keyword arguments. | +| `Badge(some_unknown_key=...)` | `TypeError("Invalid props for 'Badge': …")`. | + +For `register_component` callers without a `Props` class, kwargs +flow straight to the `Element` and are not validated. We strongly +recommend defining a `Props` dataclass for every public component. + +## Distributing as a plugin + +To ship `Badge` as a PyPI package and have it auto-register on +install, declare an entry point in your project's `pyproject.toml`: + +```toml +[project.entry-points."pythonnative.handlers"] +badge = "my_pkg:register" +``` + +The function pointed at by the entry point runs once on first call +to [`get_registry()`][pythonnative.native_views.get_registry]. A +common pattern is to import the platform-specific module from inside +that function so the heavy `rubicon-objc`/Chaquopy code path only +runs on the target device: + +```python +def register() -> None: + from pythonnative.utils import IS_ANDROID, IS_IOS + if IS_ANDROID: + from . import badge_android # noqa: F401 + elif IS_IOS: + from . import badge_ios # noqa: F401 +``` + +Plugins are loaded once per process, even if `get_registry()` is +called many times. Errors raised from a misbehaving plugin are +caught and logged but do not break PythonNative's startup. + +## Imperative registration + +If you don't want to use the decorator (e.g., for handlers that are +constructed lazily), call +[`register_component`][pythonnative.sdk._components.register_component]: + +```python +from pythonnative.sdk import register_component + +register_component( + name="Badge", + props=BadgeProps, + handlers={"ios": IOSBadgeHandler(), "android": AndroidBadgeHandler()}, +) +``` + +You can call this at any time before the first `Badge(...)` call. +Multiple calls merge by platform, so different files can register +the iOS and Android handlers separately. + +## Testing custom components + +The SDK is platform-agnostic: it does not import Chaquopy or +rubicon-objc, so you can unit-test your factory and registration +logic from pytest on a developer laptop. A typical pattern is to +swap in a stub handler that records calls: + +```python +import pytest +from pythonnative.sdk import register_component, element_factory, ViewHandler +from pythonnative.native_views import NativeViewRegistry, set_registry +from my_pkg.badge_props import BadgeProps + + +class _StubHandler(ViewHandler): + def create(self, props): return {"props": props} + def update(self, view, changed): view["props"].update(changed) + + +def test_badge_validates_props() -> None: + register_component(name="Badge", props=BadgeProps, handlers={"ios": _StubHandler()}) + Badge = element_factory("Badge") + + with pytest.raises(TypeError): + Badge(unknown_field=42) + + el = Badge(text="3", color="#000000") + assert el.props["text"] == "3" +``` + +See `tests/test_sdk.py` in the PythonNative repo for a fuller +end-to-end example that runs the reconciler against a recording +backend. + +## Next steps + +- API reference: [`pythonnative.sdk`](../api/sdk.md). +- Native view protocol: [Native views](../concepts/native-views.md). +- Forward styles cleanly: [Styling](styling.md). +- Wrap a device API instead of a widget: [Native modules](native-modules.md). diff --git a/docs/guides/styling.md b/docs/guides/styling.md index d405dc2..7b30f0c 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -1,9 +1,12 @@ # Styling -Style properties are passed via the `style` prop as a dict (or list of -dicts) to any element function. PythonNative also provides a -[`StyleSheet`][pythonnative.StyleSheet] utility for creating reusable -styles and a theming system via context. +Style properties are passed via the `style` prop on every element +factory. The value can be a plain dict, a [typed +`Style`](#typed-styles-with-pnstyle) `TypedDict` built with +[`pn.style(...)`][pythonnative.style.style], a list mixing those +(later entries win on key collision), or `None`. PythonNative also +provides a [`StyleSheet`][pythonnative.StyleSheet] utility for +declaring named styles and a theming system via context. ## Inline styles @@ -15,6 +18,73 @@ pn.Button("Tap", style={"background_color": "#FF1E88E5", "color": "#FFFFFF"}) pn.Column(pn.Text("Content"), style={"background_color": "#FFF5F5F5"}) ``` +## Typed styles with `pn.style()` + +[`pn.style(**props)`][pythonnative.style.style] is a tiny helper that +returns a [`pn.Style`][pythonnative.style.Style] `TypedDict`. Values are +plain Python `dict` instances at runtime, but the type is fully +recognised by static checkers (mypy, pyright, Pylance) and editors +will autocomplete known keys and `Literal` values: + +```python +import pythonnative as pn + +heading: pn.Style = pn.style( + font_size=28, + font_weight="700", # Literal: "100".."900" | "bold" | "normal" | … + text_align="center", # Literal: "left" | "center" | "right" | "justify" + color="#0F172A", +) + +pn.Text("Welcome", style=heading) +``` + +Why use `pn.style()` over a raw dict? + +- **IDE autocomplete** for every supported key (`flex_direction`, + `align_items`, `transform`, `shadow_offset`, …). +- **Type-checked literals** — typos like `align_items="centre"` are + flagged before you ever run the app. +- **Self-documenting code** — the `pn.Style` annotation tells readers + this dict is meant to flow into the `style` prop. + +Because `Style` is `total=False`, every key is optional; you only +include the props you care about. Plain dicts continue to work +everywhere (they're widened to the same `StyleProp` type) and +existing code does not need to change. + +### `StyleProp` for component authors + +The argument type accepted by every built-in factory is +[`pn.StyleProp`][pythonnative.style.StyleProp]: + +```python +StyleProp = Style | dict[str, Any] | list[Style | dict | None] | None +``` + +Use it in your own components when you want to forward styles +through: + +```python +from typing import Optional +import pythonnative as pn + +@pn.component +def Card( + *children: pn.Element, + style: Optional[pn.StyleProp] = None, +) -> pn.Element: + base: pn.Style = pn.style( + padding=16, + border_radius=12, + background_color="#FFFFFF", + ) + return pn.View(*children, style=[base, style]) +``` + +The list form lets callers layer overrides on top of `base` without +losing any keys you didn't override. + ## StyleSheet Create reusable named styles with @@ -65,6 +135,16 @@ pn.StyleSheet.flatten([base, highlight]) pn.StyleSheet.flatten(None) # returns {} ``` +### `StyleSheet.absolute_fill` + +Convenience factory for the common "fill the parent" overlay style: + +```python +overlay = pn.StyleSheet.absolute_fill() +# {"position": "absolute", "top": 0, "right": 0, "bottom": 0, "left": 0} +pn.View(pn.Text("Loading…"), style=[overlay, {"background_color": "#0008"}]) +``` + ## Colors Pass hex strings (`#RRGGBB` or `#AARRGGBB`) to color properties inside `style`: @@ -373,5 +453,7 @@ pn.ScrollView( [Lists](../examples/lists.md). - Browse the API: [Style](../api/style.md), [Components](../api/components.md). +- Forward typed styles through your own widgets: + [Custom native components](custom-native-components.md). - Learn about reconciliation and how style props are diffed: [Reconciliation](../concepts/reconciliation.md). diff --git a/docs/index.md b/docs/index.md index fe4d9c9..f108bf4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,9 +18,9 @@ import pythonnative as pn def Counter(initial: int = 0): count, set_count = pn.use_state(initial) return pn.Column( - pn.Text(f"Count: {count}", style={"font_size": 24, "bold": True}), + pn.Text(f"Count: {count}", style=pn.style(font_size=24, bold=True)), pn.Button("+", on_click=lambda: set_count(count + 1)), - style={"spacing": 12, "padding": 16}, + style=pn.style(spacing=12, padding=16), ) ``` @@ -39,6 +39,12 @@ produce identical frames on both platforms. - **No JS bridge, no transpiler.** The reconciler runs synchronously in Python on the platform's main thread; native API calls are direct method calls. +- **Typed styling.** [`pn.Style`][pythonnative.style.Style] is a + `TypedDict` with `Literal` enums for every fixed-value field, so + mypy and your editor catch typos in `align_items` or + `font_weight` before the app ever runs. The + [`pn.style(...)`][pythonnative.style.style] helper makes the + call sites tidy. - **Native-backed navigation.** The root `Stack.Navigator` drives the platform's real navigation controller — Android Navigation Component fragments on Android, `UINavigationController` on iOS — @@ -47,6 +53,10 @@ produce identical frames on both platforms. - **Fast Refresh hot reload.** `pn run --hot-reload` watches `app/` and patches the running app in place, preserving component state across most edits. +- **An extension SDK.** [`pythonnative.sdk`](api/sdk.md) lets you + wrap any platform widget as a first-class element with + type-checked props, and PyPI plugins auto-register through the + `pythonnative.handlers` entry-point group. - **A small surface.** A handful of element factories, a handful of hooks, and one navigation primitive. @@ -55,6 +65,8 @@ produce identical frames on both platforms. - New here? Start with [Getting started](getting-started.md). - Want the bigger picture? Read [Mental model](concepts/mental-model.md). - Looking up an API? [Package overview](api/pythonnative.md). +- Wrapping a custom widget? Read + [Custom native components](guides/custom-native-components.md). - Stuck on an error? Try [Troubleshooting](meta/troubleshooting.md). ## Project status diff --git a/examples/hello-world/app/screens/home.py b/examples/hello-world/app/screens/home.py index 5304e66..a5477e4 100644 --- a/examples/hello-world/app/screens/home.py +++ b/examples/hello-world/app/screens/home.py @@ -15,15 +15,19 @@ MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] +# ``pn.style(...)`` returns a fully-typed ``pn.Style`` TypedDict so each +# entry below benefits from IDE autocomplete and mypy/pyright checking +# against the supported style keys and ``Literal`` value sets (e.g. +# ``align_items``). local_styles = pn.StyleSheet.create( - medal={"font_size": 32}, - card={ - "spacing": 12, - "padding": 16, - "background_color": "#F8F9FA", - "align_items": "center", - }, - button_row={"spacing": 8, "align_items": "center"}, + medal=pn.style(font_size=32), + card=pn.style( + spacing=12, + padding=16, + background_color="#F8F9FA", + align_items="center", + ), + button_row=pn.style(spacing=8, align_items="center"), ) diff --git a/examples/hello-world/app/theme.py b/examples/hello-world/app/theme.py index ff3f95b..92d0f78 100644 --- a/examples/hello-world/app/theme.py +++ b/examples/hello-world/app/theme.py @@ -4,14 +4,18 @@ file focused on its own behaviour. Screen-specific styles (``flex_box``, ``abs_canvas``, ``chip``, ``field``, etc.) stay inline in the screen that owns them so each file remains self-contained. + +Each entry is built with :func:`pythonnative.style` so the values are +fully type-checked: pass ``align_items="centre"`` (typo) and +mypy/pyright will flag it against the ``AlignItems`` ``Literal``. """ import pythonnative as pn styles = pn.StyleSheet.create( - title={"font_size": 24, "bold": True}, - subtitle={"font_size": 16, "color": "#666666"}, - section_title={"font_size": 18, "font_weight": "600", "color": "#0F172A"}, - hint={"font_size": 13, "color": "#6B7280"}, - section={"spacing": 16, "padding": 20, "align_items": "stretch"}, + title=pn.style(font_size=24, bold=True), + subtitle=pn.style(font_size=16, color="#666666"), + section_title=pn.style(font_size=18, font_weight="600", color="#0F172A"), + hint=pn.style(font_size=13, color="#6B7280"), + section=pn.style(spacing=16, padding=20, align_items="stretch"), ) diff --git a/mkdocs.yml b/mkdocs.yml index 0acda5b..d70a058 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,6 +83,7 @@ nav: - Lists: guides/lists.md - Platform & Accessibility: guides/platform-accessibility.md - Native Modules: guides/native-modules.md + - Custom Native Components: guides/custom-native-components.md - Hot Reload: guides/hot-reload.md - Error Boundaries: guides/error-boundaries.md - Testing: guides/testing.md @@ -108,6 +109,7 @@ nav: - Navigation: api/navigation.md - Native modules: api/native_modules.md - Native views: api/native_views.md + - SDK: api/sdk.md - Hot reload: api/hot_reload.md - Utilities: api/utils.md - CLI (pn): api/cli.md diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index cf9db2f..bedbcf6 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -23,8 +23,18 @@ factories. - **Styling** uses a single ``style`` dict per element (or a list of dicts), composable via [`StyleSheet`][pythonnative.StyleSheet]. + PythonNative ships a fully-typed [`Style`][pythonnative.style.Style] + TypedDict so editors and ``mypy`` validate every key as you type. - **Animations** use the ``Animated`` namespace, modeled on React Native's animation API. +- **Custom native components** can be authored with the + ``pythonnative.sdk`` package: define a typed + [`Props`][pythonnative.sdk.Props] dataclass, implement a + [`ViewHandler`][pythonnative.native_views.base.ViewHandler] for each + platform, and register it via + [`@native_component`][pythonnative.sdk.native_component] (or expose + it from a PyPI package via the ``pythonnative.handlers`` entry-point + group). Example: ```python @@ -34,15 +44,16 @@ def App(): count, set_count = pn.use_state(0) return pn.Column( - pn.Text(f"Count: {count}", style={"font_size": 24}), + pn.Text(f"Count: {count}", style=pn.style(font_size=24)), pn.Button("+", on_click=lambda: set_count(count + 1)), - style={"spacing": 12}, + style=pn.style(spacing=12), ) ``` """ __version__ = "0.14.0" +from . import sdk from .alerts import Alert from .animated import Animated, AnimatedValue from .components import ( @@ -100,7 +111,39 @@ def App(): ) from .platform import Platform from .screen import create_screen -from .style import StyleSheet, ThemeContext +from .sdk import ( + Props, + ViewHandler, + element_factory, + native_component, + register_component, +) +from .style import ( + AlignItems, + AlignSelf, + AutoCapitalize, + Color, + Dimension, + EdgeInsets, + FlexDirection, + FontWeight, + JustifyContent, + KeyboardType, + Overflow, + Position, + ReturnKeyType, + ScaleType, + ShadowOffset, + Style, + StyleProp, + StyleSheet, + TextAlign, + TextDecoration, + ThemeContext, + TransformSpec, + resolve_style, + style, +) __all__ = [ # Components @@ -154,9 +197,31 @@ def App(): "create_drawer_navigator", "create_stack_navigator", "create_tab_navigator", - # Styling + # Styling - typed primitives + "AlignItems", + "AlignSelf", + "AutoCapitalize", + "Color", + "Dimension", + "EdgeInsets", + "FlexDirection", + "FontWeight", + "JustifyContent", + "KeyboardType", + "Overflow", + "Position", + "ReturnKeyType", + "ScaleType", + "ShadowOffset", + "Style", + "StyleProp", "StyleSheet", + "TextAlign", + "TextDecoration", "ThemeContext", + "TransformSpec", + "resolve_style", + "style", # Animation "Animated", "AnimatedValue", @@ -169,4 +234,11 @@ def App(): "Notifications", # Platform "Platform", + # Custom-component SDK + "Props", + "ViewHandler", + "element_factory", + "native_component", + "register_component", + "sdk", ] diff --git a/src/pythonnative/animated.py b/src/pythonnative/animated.py index 4880373..c3f4b9d 100644 --- a/src/pythonnative/animated.py +++ b/src/pythonnative/animated.py @@ -58,7 +58,7 @@ def fade_in(): from .element import Element from .hooks import use_effect, use_ref -from .style import StyleValue, resolve_style +from .style import StyleProp, resolve_style # Maximum frame rate at which the Python ticker drives animations. # We aim for 60 Hz but back off when no animation is active. @@ -433,7 +433,7 @@ def stop(self) -> None: # ====================================================================== -def _resolve_style_with_values(style: StyleValue) -> Tuple[Dict[str, Any], Dict[str, AnimatedValue]]: +def _resolve_style_with_values(style: StyleProp) -> Tuple[Dict[str, Any], Dict[str, AnimatedValue]]: """Return ``(plain_style, animated_bindings)``. AnimatedValue entries in the style are replaced with their diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py index 6ae5c25..da66d33 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -35,10 +35,18 @@ ``` """ -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Literal, Optional from .element import Element -from .style import StyleValue, resolve_style +from .style import ( + AutoCapitalize, + Color, + KeyboardType, + ReturnKeyType, + ScaleType, + StyleProp, + resolve_style, +) # ====================================================================== # Leaf components @@ -73,7 +81,7 @@ def _accessibility_props( def Text( text: str = "", *, - style: StyleValue = None, + style: StyleProp = None, accessibility_label: Optional[str] = None, accessibility_hint: Optional[str] = None, accessibility_role: Optional[str] = None, @@ -118,7 +126,7 @@ def Button( *, on_click: Optional[Callable[[], None]] = None, enabled: bool = True, - style: StyleValue = None, + style: StyleProp = None, accessibility_label: Optional[str] = None, accessibility_hint: Optional[str] = None, accessible: Optional[bool] = None, @@ -174,14 +182,14 @@ def TextInput( on_submit: Optional[Callable[[str], None]] = None, secure: bool = False, multiline: bool = False, - keyboard_type: Optional[str] = None, - auto_capitalize: Optional[str] = None, + keyboard_type: Optional[KeyboardType] = None, + auto_capitalize: Optional[AutoCapitalize] = None, auto_correct: Optional[bool] = None, auto_focus: bool = False, - return_key_type: Optional[str] = None, + return_key_type: Optional[ReturnKeyType] = None, max_length: Optional[int] = None, - placeholder_color: Optional[str] = None, - style: StyleValue = None, + placeholder_color: Optional[Color] = None, + style: StyleProp = None, accessibility_label: Optional[str] = None, accessibility_hint: Optional[str] = None, accessible: Optional[bool] = None, @@ -257,9 +265,9 @@ def TextInput( def Image( source: str = "", *, - scale_type: Optional[str] = None, - tint_color: Optional[str] = None, - style: StyleValue = None, + scale_type: Optional[ScaleType] = None, + tint_color: Optional[Color] = None, + style: StyleProp = None, accessibility_label: Optional[str] = None, accessible: Optional[bool] = None, ref: Optional[Dict[str, Any]] = None, @@ -307,7 +315,7 @@ def Switch( *, value: bool = False, on_change: Optional[Callable[[bool], None]] = None, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Display a toggle switch. @@ -331,7 +339,7 @@ def Switch( def ProgressBar( *, value: float = 0.0, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Show determinate progress as a value between 0.0 and 1.0. @@ -356,7 +364,7 @@ def ProgressBar( def ActivityIndicator( *, animating: bool = True, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Show an indeterminate loading spinner. @@ -378,7 +386,7 @@ def ActivityIndicator( def WebView( *, url: str = "", - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Embed web content from a URL. @@ -440,7 +448,7 @@ def Slider( min_value: float = 0.0, max_value: float = 1.0, on_change: Optional[Callable[[float], None]] = None, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Continuous-value slider between `min_value` and `max_value`. @@ -475,7 +483,7 @@ def Slider( def View( *children: Element, - style: StyleValue = None, + style: StyleProp = None, accessibility_label: Optional[str] = None, accessibility_hint: Optional[str] = None, accessibility_role: Optional[str] = None, @@ -530,7 +538,7 @@ def View( def Column( *children: Element, - style: StyleValue = None, + style: StyleProp = None, ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: @@ -571,7 +579,7 @@ def Column( def Row( *children: Element, - style: StyleValue = None, + style: StyleProp = None, ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: @@ -614,7 +622,7 @@ def ScrollView( child: Optional[Element] = None, *, refresh_control: Optional[Dict[str, Any]] = None, - style: StyleValue = None, + style: StyleProp = None, ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: @@ -647,7 +655,7 @@ def ScrollView( def SafeAreaView( *children: Element, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Container that respects safe-area insets (notch, status bar, home indicator). @@ -670,9 +678,9 @@ def Modal( visible: bool = False, on_dismiss: Optional[Callable[[], None]] = None, title: Optional[str] = None, - animation_type: str = "slide", + animation_type: Literal["slide", "fade", "none"] = "slide", transparent: bool = False, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Overlay modal dialog backed by a real native presentation. @@ -720,7 +728,7 @@ def Pressable( on_press: Optional[Callable[[], None]] = None, on_long_press: Optional[Callable[[], None]] = None, pressed_opacity: float = 0.6, - style: StyleValue = None, + style: StyleProp = None, accessibility_label: Optional[str] = None, accessibility_hint: Optional[str] = None, accessible: Optional[bool] = None, @@ -811,7 +819,7 @@ def FlatList( separator_height: float = 0, refresh_control: Optional[Dict[str, Any]] = None, on_item_press: Optional[Callable[[int], None]] = None, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Virtualized scrollable list that renders items from `data` lazily. @@ -941,7 +949,7 @@ def SectionList( item_height: Optional[float] = None, section_header_height: float = 32.0, separator_height: float = 0, - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Virtualized list that supports section headers. @@ -1040,8 +1048,8 @@ def _mount_row(index: int, content_view: Any) -> None: def StatusBar( *, - style: Optional[str] = None, - background_color: Optional[str] = None, + style: Optional[Literal["light", "dark", "default"]] = None, + background_color: Optional[Color] = None, hidden: Optional[bool] = None, key: Optional[str] = None, ) -> Element: @@ -1075,8 +1083,8 @@ def StatusBar( def KeyboardAvoidingView( *children: Element, - behavior: str = "padding", - style: StyleValue = None, + behavior: Literal["padding", "position"] = "padding", + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """Wrap content that should shift up when the keyboard is shown. @@ -1106,7 +1114,7 @@ def RefreshControl( *, refreshing: bool = False, on_refresh: Optional[Callable[[], None]] = None, - tint_color: Optional[str] = None, + tint_color: Optional[Color] = None, ) -> Dict[str, Any]: """Pull-to-refresh spec for [`ScrollView`][pythonnative.ScrollView] / [`FlatList`][pythonnative.FlatList]. @@ -1162,7 +1170,7 @@ def Picker( items: Optional[List[Dict[str, Any]]] = None, on_change: Optional[Callable[[Any], None]] = None, placeholder: str = "Select…", - style: StyleValue = None, + style: StyleProp = None, key: Optional[str] = None, ) -> Element: """A select / dropdown widget. diff --git a/src/pythonnative/native_views/__init__.py b/src/pythonnative/native_views/__init__.py index 16b51ae..93c7cd8 100644 --- a/src/pythonnative/native_views/__init__.py +++ b/src/pythonnative/native_views/__init__.py @@ -237,12 +237,50 @@ def measure_intrinsic( _registry: Optional[NativeViewRegistry] = None +def _active_platform_name() -> str: + """Return ``"android"`` or ``"ios"`` for the active runtime.""" + from ..utils import IS_ANDROID + + return "android" if IS_ANDROID else "ios" + + +def _register_builtin_handlers(registry: NativeViewRegistry) -> None: + """Register every built-in handler for the active platform.""" + from ..utils import IS_ANDROID + + if IS_ANDROID: + from .android import register_handlers + else: + from .ios import register_handlers + register_handlers(registry) + + +def _install_sdk_handlers(registry: NativeViewRegistry) -> None: + """Copy decorator-registered SDK handlers + entry-point plugins. + + Imported lazily so unit tests that never touch the SDK don't pay the + entry-point discovery cost. + """ + try: + from ..sdk._components import install_into_registry as _sdk_install + except Exception: + return + try: + _sdk_install(registry, _active_platform_name()) + except Exception: + # A misbehaving plugin must not break PythonNative's startup. + pass + + def get_registry() -> NativeViewRegistry: """Return the process-wide registry, lazily registering handlers. - The first call instantiates the registry and registers either the - Android or iOS handlers based on `IS_ANDROID`. Subsequent calls - return the same instance. + The first call instantiates the registry, registers either the + Android or iOS handlers based on `IS_ANDROID`, then layers on every + decorator-registered SDK handler (and any handlers exposed by + third-party packages via the + [`pythonnative.handlers`][pythonnative.sdk.ENTRY_POINT_GROUP] entry + point group). Subsequent calls return the same instance. Returns: The active `NativeViewRegistry`. @@ -251,30 +289,40 @@ def get_registry() -> NativeViewRegistry: if _registry is not None: return _registry _registry = NativeViewRegistry() + _register_builtin_handlers(_registry) + _install_sdk_handlers(_registry) + return _registry - from ..utils import IS_ANDROID - if IS_ANDROID: - from .android import register_handlers +def refresh_registry() -> NativeViewRegistry: + """Re-run SDK handler installation against the existing registry. - register_handlers(_registry) - else: - from .ios import register_handlers + Call this after registering a new component at runtime if the + registry has already been instantiated. This is mostly useful in + REPL sessions and tests; the normal flow is "register, then call + [`get_registry`][pythonnative.native_views.get_registry]" and the + handlers come along automatically. - register_handlers(_registry) - return _registry + Returns: + The active `NativeViewRegistry`. + """ + registry = get_registry() + _install_sdk_handlers(registry) + return registry -def set_registry(registry: NativeViewRegistry) -> None: +def set_registry(registry: Optional[NativeViewRegistry]) -> None: """Install a custom registry (primarily for testing). Replaces the lazy singleton so subsequent [`get_registry`][pythonnative.native_views.get_registry] calls return `registry`. Pass a mock to drive the reconciler from - unit tests without touching real native APIs. + unit tests without touching real native APIs. Pass ``None`` to + reset the singleton; the next ``get_registry`` call will then + rebuild it from scratch. Args: - registry: The replacement registry. + registry: The replacement registry, or ``None`` to clear. """ global _registry _registry = registry diff --git a/src/pythonnative/sdk/__init__.py b/src/pythonnative/sdk/__init__.py new file mode 100644 index 0000000..1b4b30d --- /dev/null +++ b/src/pythonnative/sdk/__init__.py @@ -0,0 +1,132 @@ +"""Public extension surface for PythonNative. + +The ``pythonnative.sdk`` package collects the *stable* extension +contract that third-party packages rely on: the +[`ViewHandler`][pythonnative.sdk.ViewHandler] protocol, the +[`Style`][pythonnative.sdk.Style] type, the +[`@native_component`][pythonnative.sdk.native_component] registration +decorator, and an +[`element_factory`][pythonnative.sdk.element_factory] helper for +producing strongly-typed element constructors. + +A custom native component is three things: + +1. A typed, frozen [`Props`][pythonnative.sdk.Props] dataclass listing + the public properties the component accepts. +2. One or more + [`ViewHandler`][pythonnative.sdk.ViewHandler] subclasses (one per + target platform) implementing creation, update, and child management + for the underlying native widget. +3. A registration call (the + [`@native_component`][pythonnative.sdk.native_component] decorator, + or + [`register_component`][pythonnative.sdk.register_component] for + imperative use) that binds the props type and handler into the + process-wide registry. + +Once registered, the component appears alongside the built-ins: the +reconciler, layout engine, and Fast Refresh treat it identically. + +PyPI packages can ship handlers without users importing them +explicitly by declaring an entry point in the +``pythonnative.handlers`` group; PythonNative discovers and imports +those modules the first time the registry is asked for a handler. + +Example: + ```python + from dataclasses import dataclass + import pythonnative as pn + from pythonnative.sdk import ( + Props, + ViewHandler, + element_factory, + native_component, + ) + + + @dataclass(frozen=True) + class BadgeProps(Props): + text: str = "" + color: str = "#FF3B30" + style: pn.StyleProp = None + + + @native_component("Badge", props=BadgeProps, platforms=("ios",)) + class IOSBadgeHandler(ViewHandler): + def create(self, props): + ... + + def update(self, view, changed): + ... + + + Badge = element_factory("Badge") + + @pn.component + def App(): + return pn.Column( + Badge(text="3", color="#0A84FF"), + pn.Text("Inbox"), + ) + ``` +""" + +from ..element import Element +from ..native_views.base import ViewHandler, parse_color_int, resolve_padding +from ..style import ( + Color, + Dimension, + EdgeInsets, + EdgeValue, + FlexDirection, + JustifyContent, + Overflow, + Position, + Style, + StyleProp, + TransformSpec, + style, +) +from ._components import ( + ENTRY_POINT_GROUP, + Props, + element_factory, + get_props_type, + install_into_registry, + list_components, + native_component, + register_component, + unregister_component, +) + +__all__ = [ + # Core types + "Element", + "ViewHandler", + # Style types + "Color", + "Dimension", + "EdgeInsets", + "EdgeValue", + "FlexDirection", + "JustifyContent", + "Overflow", + "Position", + "Style", + "StyleProp", + "TransformSpec", + "style", + # SDK helpers (re-exported so users only import from one place) + "parse_color_int", + "resolve_padding", + # Native-component SDK + "ENTRY_POINT_GROUP", + "Props", + "element_factory", + "get_props_type", + "install_into_registry", + "list_components", + "native_component", + "register_component", + "unregister_component", +] diff --git a/src/pythonnative/sdk/_components.py b/src/pythonnative/sdk/_components.py new file mode 100644 index 0000000..5c8db63 --- /dev/null +++ b/src/pythonnative/sdk/_components.py @@ -0,0 +1,429 @@ +"""Custom native-component registration. + +Implements the [`@native_component`][pythonnative.sdk.native_component] +decorator and supporting helpers that let third-party packages contribute +new element types to the reconciler. + +The registration model is intentionally small. A custom component is a +three-part agreement: + +1. A typed, immutable + [`Props`][pythonnative.sdk.Props] dataclass declaring the + component's public surface. +2. One or more + [`ViewHandler`][pythonnative.sdk.ViewHandler] subclasses + (one per platform) implementing the platform-side rendering. +3. A name (string) used by the reconciler to look up the handler. + +The decorator stores the (name, props_type, handler_instance) tuple +in a process-wide registry. The +[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] +calls +[`install_into_registry`][pythonnative.sdk.install_into_registry] on +first use; that helper performs entry-point discovery (importing any +modules registered under +[`ENTRY_POINT_GROUP`][pythonnative.sdk.ENTRY_POINT_GROUP]) and copies +every handler matching the active platform into the registry. + +Example: + ```python + from dataclasses import dataclass + import pythonnative as pn + from pythonnative.sdk import Props, ViewHandler, element_factory, native_component + + + @dataclass(frozen=True) + class BadgeProps(Props): + text: str = "" + color: str = "#FF3B30" + style: pn.StyleProp = None + + + @native_component("Badge", props=BadgeProps, platforms=("ios",)) + class IOSBadgeHandler(ViewHandler): + def create(self, props): + ... + + def update(self, view, changed): + ... + + + Badge = element_factory("Badge") + ``` +""" + +from dataclasses import dataclass, fields, is_dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar + +from ..element import Element +from ..native_views.base import ViewHandler + +ENTRY_POINT_GROUP = "pythonnative.handlers" +"""Entry-point group used by PyPI packages to register native handlers. + +Packages declare entries like: + +```toml +[project.entry-points."pythonnative.handlers"] +my_blur = "my_pkg.blur:register" +``` + +PythonNative imports the referenced module the first time the +[`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] is +materialized; the decorators inside that module populate the registry +during import. +""" + + +@dataclass(frozen=True) +class Props: + """Optional base class for typed prop dataclasses. + + Subclassing is not strictly required (any + ``@dataclass(frozen=True)`` works), but inheriting from + ``Props`` gives third-party components a clear, searchable marker + in their public API and a stable place to add framework-wide + behavior in the future. + + Example: + ```python + from dataclasses import dataclass + from pythonnative.sdk import Props + + @dataclass(frozen=True) + class BadgeProps(Props): + text: str = "" + color: str = "#FF3B30" + ``` + """ + + +# ---------------------------------------------------------------------- # +# Internal registry +# ---------------------------------------------------------------------- # + +# name -> (props_type or None, {platform_name: handler_instance}) +_REGISTRY: Dict[str, Tuple[Optional[type], Dict[str, ViewHandler]]] = {} + +# Caches `_install_into_registry` runs to avoid repeated entry-point +# discovery once the registry has been populated for a given platform. +_DISCOVERED: bool = False + + +H = TypeVar("H", bound=ViewHandler) + + +def native_component( + name: str, + *, + props: Optional[type] = None, + platforms: Optional[Tuple[str, ...]] = None, +) -> Callable[[Type[H]], Type[H]]: + """Decorator that registers a [`ViewHandler`][pythonnative.sdk.ViewHandler] under ``name``. + + The handler class is instantiated immediately and stored in the + process-wide registry. Decorate the same ``name`` once per platform + when shipping platform-specific implementations; the decorator + accumulates entries in a ``{platform: handler}`` mapping per name. + + Args: + name: Element type name (e.g., ``"Badge"``). Must be a valid + identifier-like string. Used by the reconciler at lookup time. + props: Optional dataclass type describing the component's + typed props. When supplied, the + [`element_factory`][pythonnative.sdk.element_factory] helper + uses this type to validate kwargs and produce frozen prop + instances. + platforms: Tuple of platform identifiers + (``"ios"`` / ``"android"``) the handler implements. Defaults + to ``("android", "ios")`` so a single cross-platform handler + registers everywhere. + + Returns: + A decorator that, when applied to a + [`ViewHandler`][pythonnative.sdk.ViewHandler] subclass, registers + it and returns the class unchanged. + + Raises: + TypeError: If the decorated object is not a class subclassing + ``ViewHandler``. + + Example: + ```python + from dataclasses import dataclass + from pythonnative.sdk import Props, ViewHandler, native_component + + + @dataclass(frozen=True) + class BadgeProps(Props): + text: str = "" + color: str = "#FF3B30" + + + @native_component("Badge", props=BadgeProps, platforms=("ios",)) + class IOSBadgeHandler(ViewHandler): + def create(self, props): + ... + ``` + """ + plats: Tuple[str, ...] = platforms if platforms is not None else ("android", "ios") + + def decorator(handler_cls: Type[H]) -> Type[H]: + if not isinstance(handler_cls, type) or not issubclass(handler_cls, ViewHandler): + raise TypeError( + f"@native_component({name!r}) must decorate a ViewHandler subclass; " f"got {handler_cls!r}" + ) + register_component(name=name, props=props, handlers={plat: handler_cls() for plat in plats}) + return handler_cls + + return decorator + + +def register_component( + *, + name: str, + props: Optional[type] = None, + handlers: Dict[str, ViewHandler], +) -> None: + """Register a custom native component imperatively. + + Equivalent to applying [`@native_component`][pythonnative.sdk.native_component] + one or more times, but useful when constructing handlers + programmatically (e.g., parameterized handler instances). Subsequent + calls for the same ``name`` merge their ``handlers`` into the + existing entry, replacing any previously-registered handler for the + same platform. + + Args: + name: Element type name. + props: Optional dataclass type describing the typed props. + handlers: ``{platform_name: handler_instance}`` mapping. Common + keys are ``"ios"`` and ``"android"``. + + Raises: + TypeError: If any handler is not a + [`ViewHandler`][pythonnative.sdk.ViewHandler] instance, or + if ``props`` is not a dataclass type. + """ + if props is not None and not (isinstance(props, type) and is_dataclass(props)): + raise TypeError(f"register_component({name!r}): props must be a @dataclass type, got {props!r}") + for plat, handler in handlers.items(): + if not isinstance(handler, ViewHandler): + raise TypeError(f"register_component({name!r}): handler for {plat!r} must be a ViewHandler instance") + + existing = _REGISTRY.get(name) + if existing is None: + _REGISTRY[name] = (props, dict(handlers)) + return + existing_props, plat_map = existing + new_props = props if props is not None else existing_props + plat_map.update(handlers) + _REGISTRY[name] = (new_props, plat_map) + + +def unregister_component(name: str) -> None: + """Remove a previously-registered component (primarily for tests). + + Args: + name: The element type name to unregister. + """ + _REGISTRY.pop(name, None) + + +def list_components() -> List[str]: + """Return the names of every registered custom component. + + Useful for diagnostics and tests. + + Returns: + Sorted list of names registered via + [`@native_component`][pythonnative.sdk.native_component] or + [`register_component`][pythonnative.sdk.register_component]. + """ + return sorted(_REGISTRY) + + +def get_props_type(name: str) -> Optional[type]: + """Return the registered props dataclass for ``name`` (or ``None``).""" + entry = _REGISTRY.get(name) + return entry[0] if entry is not None else None + + +def install_into_registry(registry: Any, platform_name: str) -> None: + """Copy registered handlers into a [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry]. + + Called once by the registry on first use. Triggers entry-point + discovery on the first call so PyPI-installed handlers register + themselves before the registry snapshot is taken. + + Args: + registry: A + [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] + (or duck-compatible object) with a ``register(name, handler)`` + method. + platform_name: The active platform identifier + (``"ios"`` or ``"android"``). + """ + _discover_entry_points() + for name, (_props_type, plat_map) in _REGISTRY.items(): + handler = plat_map.get(platform_name) + if handler is not None: + registry.register(name, handler) + + +def _discover_entry_points() -> None: + """Import every module registered under ``ENTRY_POINT_GROUP``. + + Idempotent and safe to call repeatedly; the actual discovery only + runs once per process. Exceptions raised by individual entry points + are swallowed (with the offending name printed to stderr) so a + single broken plugin never prevents the rest of the process from + rendering. + """ + global _DISCOVERED + if _DISCOVERED: + return + _DISCOVERED = True + + try: + from importlib.metadata import entry_points + except ImportError: + return + + try: + eps = entry_points() + except Exception: + return + + # importlib.metadata's API changed across Python versions; both + # ``select`` and direct ``.get`` are normalized here. + selected: List[Any] = [] + if hasattr(eps, "select"): + try: + selected = list(eps.select(group=ENTRY_POINT_GROUP)) + except Exception: + selected = [] + if not selected: + try: + getter = getattr(eps, "get", None) + if getter is not None: + selected = list(getter(ENTRY_POINT_GROUP, [])) + except Exception: + selected = [] + + for ep in selected: + name = getattr(ep, "name", "?") + try: + ep.load() + except Exception as exc: # pragma: no cover - defensive + import sys + + print( + f"[pythonnative.sdk] Failed to load handler entry point {name!r}: {exc!r}", + file=sys.stderr, + flush=True, + ) + + +def _reset_discovery_state_for_tests() -> None: + """Reset the entry-point discovery flag (for tests only).""" + global _DISCOVERED + _DISCOVERED = False + + +# ---------------------------------------------------------------------- # +# Element factories +# ---------------------------------------------------------------------- # + + +def _props_to_dict(value: Any) -> Dict[str, Any]: + """Convert a typed props dataclass to a flat dict of non-None fields.""" + if isinstance(value, dict): + return {k: v for k, v in value.items() if v is not None} + if is_dataclass(value): + out: Dict[str, Any] = {} + for f in fields(value): + field_value = getattr(value, f.name) + if field_value is not None: + out[f.name] = field_value + return out + raise TypeError(f"Expected a dataclass instance or dict, got {type(value).__name__!r}") + + +def element_factory(name: str) -> Callable[..., Element]: + """Return a callable that builds [`Element`][pythonnative.Element] instances of type ``name``. + + The returned factory accepts: + + - Children as positional arguments (any number). + - ``key=`` (optional, keyword-only) for keyed reconciliation. + - Either ``props=`` (a dataclass instance) or per-field keyword + arguments matching the registered props dataclass. + + If no ``props`` dataclass was registered for ``name``, kwargs flow + through unmodified — useful when iterating before locking down a + prop schema. + + Args: + name: An element type name previously registered via + [`@native_component`][pythonnative.sdk.native_component] or + [`register_component`][pythonnative.sdk.register_component]. + + Returns: + A callable producing fresh + [`Element`][pythonnative.Element] instances of type ``name``. + + Raises: + KeyError: If ``name`` is not registered. + + Example: + ```python + Badge = element_factory("Badge") + Badge(text="3", color="#0A84FF") + Badge(props=BadgeProps(text="3")) + ``` + """ + if name not in _REGISTRY: + raise KeyError( + f"No component registered under name {name!r}. " "Use @native_component or register_component first." + ) + + def factory(*children: Element, key: Optional[str] = None, props: Any = None, **kwargs: Any) -> Element: + props_type = get_props_type(name) + if props is not None: + if kwargs: + raise TypeError("Pass either props=... or keyword props, not both") + props_dict = _props_to_dict(props) + elif props_type is not None: + try: + instance = props_type(**kwargs) + except TypeError as exc: + raise TypeError(f"Invalid props for {name!r}: {exc}") from exc + props_dict = _props_to_dict(instance) + else: + props_dict = dict(kwargs) + # Style props pass through resolve_style at the boundary so list + # forms / None get flattened identically to built-in factories. + from ..style import resolve_style as _resolve + + style_value = props_dict.pop("style", None) + style_dict = _resolve(style_value) + merged: Dict[str, Any] = {**style_dict, **props_dict} + return Element(name, merged, list(children), key=key) + + factory.__name__ = name + factory.__doc__ = f"Construct an Element of type {name!r}." + return factory + + +__all__ = [ + "ENTRY_POINT_GROUP", + "Props", + "element_factory", + "get_props_type", + "install_into_registry", + "list_components", + "native_component", + "register_component", + "unregister_component", +] diff --git a/src/pythonnative/style.py b/src/pythonnative/style.py index 4a8e9e4..5670654 100644 --- a/src/pythonnative/style.py +++ b/src/pythonnative/style.py @@ -1,26 +1,30 @@ -"""StyleSheet, style resolution, and theming support. - -Provides: - -- A [`StyleSheet`][pythonnative.StyleSheet] helper for creating and - composing reusable style dictionaries. -- A [`resolve_style`][pythonnative.style.resolve_style] utility for - flattening the `style` prop accepted by every component factory. -- A pair of light and dark default themes plus a - [`ThemeContext`][pythonnative.ThemeContext] for distributing a theme - dict across a subtree. - -Style values are plain Python dicts so they are trivial to compose, -diff, and store. Properties unrecognized by the platform handler are -ignored. +"""StyleSheet, typed `Style`, style resolution, and theming. + +PythonNative ships a single, fully-typed [`Style`][pythonnative.Style] +TypedDict that enumerates every supported style property and constrains +enum-shaped values via [`typing.Literal`][typing.Literal]. The TypedDict +gives editors and type-checkers (mypy, pyright) full autocomplete and +validation: a typo such as ``flex_direction="collumn"`` is now a static +error, not a silent runtime no-op. + +Style values remain plain dicts at runtime so they are trivial to +compose, diff, and store. Properties unrecognized by a platform handler +are still ignored, so third-party handlers may extend the palette +without modifying core types. + +The runtime helpers ([`resolve_style`][pythonnative.style.resolve_style], +[`StyleSheet`][pythonnative.StyleSheet]) accept either a ``Style`` +TypedDict, a regular ``Dict[str, Any]`` (for forward-compat or +unrestricted use), or a list of either kind of dict, and always +return a fresh, flat dict. Example: ```python import pythonnative as pn styles = pn.StyleSheet.create( - title={"font_size": 24, "bold": True, "color": "#333"}, - container={"padding": 16, "spacing": 12}, + title=pn.style(font_size=24, bold=True, color="#333"), + container=pn.style(padding=16, spacing=12), ) pn.Column( @@ -30,35 +34,337 @@ ``` """ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union from .hooks import Context, create_context -_StyleDict = Dict[str, Any] -StyleValue = Union[None, _StyleDict, List[Optional[_StyleDict]]] +# ====================================================================== +# Atomic value types +# ====================================================================== + +Color = str +"""Color value: ``"#RRGGBB"``, ``"#AARRGGBB"``, or any string a platform +handler recognizes (e.g., ``"red"``). Stored verbatim and parsed by the +handler at apply-time, so palettes from third-party libraries pass through +unchanged.""" + +Dimension = Union[int, float, str] +"""A length value. Numbers are points/dp; strings ending in ``"%"`` are +parent-relative percentages.""" + + +class EdgeInsets(TypedDict, total=False): + """Per-edge spacing values used for ``padding`` and ``margin``. + + Mirrors React Native's ``EdgeInsets`` shape with PythonNative's + convenience aliases. Any subset of keys may be supplied. + """ + + top: Dimension + right: Dimension + bottom: Dimension + left: Dimension + horizontal: Dimension + vertical: Dimension + all: Dimension + + +EdgeValue = Union[Dimension, EdgeInsets] +"""Padding/margin value: a uniform number, a ``"%"`` string, or an +[`EdgeInsets`][pythonnative.EdgeInsets] dict.""" + + +class ShadowOffset(TypedDict): + """Shadow displacement in points.""" + + width: float + height: float + + +# ---------------------------------------------------------------------- +# Transforms +# ---------------------------------------------------------------------- + + +class TransformRotate(TypedDict): + """Rotation transform in degrees (numeric) or with explicit unit suffix.""" + + rotate: Union[float, str] + + +class TransformScale(TypedDict): + """Uniform scale transform.""" + + scale: float + + +class TransformScaleX(TypedDict): + """Horizontal-only scale transform.""" + + scale_x: float + + +class TransformScaleY(TypedDict): + """Vertical-only scale transform.""" + + scale_y: float + + +class TransformTranslate(TypedDict, total=False): + """Translation transform along one or both axes.""" + + translate_x: float + translate_y: float + + +TransformEntry = Union[ + TransformRotate, + TransformScale, + TransformScaleX, + TransformScaleY, + TransformTranslate, + Dict[str, Any], +] +"""A single transform operation.""" + +TransformSpec = Union[TransformEntry, List[TransformEntry]] +"""``transform`` style value: a single operation or an ordered list.""" + + +# ---------------------------------------------------------------------- +# Enum-shaped style values +# ---------------------------------------------------------------------- + +FlexDirection = Literal["row", "column", "row_reverse", "column_reverse"] +JustifyContent = Literal[ + "flex_start", + "center", + "flex_end", + "space_between", + "space_around", + "space_evenly", + "start", + "leading", + "top", + "end", + "trailing", + "bottom", +] +AlignItems = Literal[ + "stretch", + "flex_start", + "center", + "flex_end", + "auto", + "start", + "leading", + "top", + "end", + "trailing", + "bottom", + "fill", +] +AlignSelf = AlignItems +Position = Literal["relative", "absolute"] +Overflow = Literal["visible", "hidden", "scroll"] +TextAlign = Literal["left", "center", "right", "justify", "start", "end"] +TextDecoration = Literal["none", "underline", "line_through"] +FontWeight = Literal[ + "normal", + "bold", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +] +ScaleType = Literal["cover", "contain", "stretch", "center"] +KeyboardType = Literal[ + "default", + "email_address", + "number_pad", + "decimal_pad", + "phone_pad", + "url", +] +AutoCapitalize = Literal["none", "sentences", "words", "characters"] +ReturnKeyType = Literal["default", "done", "go", "next", "send", "search"] + + +# ====================================================================== +# Style TypedDict +# ====================================================================== + + +class Style(TypedDict, total=False): + """Statically-typed style dictionary. + + Lists every style property recognized by the built-in components + plus their layout engine, with enum-shaped values constrained via + [`Literal`][typing.Literal]. ``Style`` is a `total=False` TypedDict + so any subset of keys is valid at construction time. + + Custom native components may accept additional, unlisted keys — + they are ignored by the built-in handlers but flow through the + reconciler unmodified, so third-party handlers can read them. + + Example: + ```python + import pythonnative as pn + title: pn.Style = { + "font_size": 24, + "color": "#0A84FF", + "text_align": "center", + } -def resolve_style(style: StyleValue) -> _StyleDict: + pn.Text("Hello", style=title) + ``` + """ + + # --- Layout: sizing --- + width: Dimension + height: Dimension + min_width: Dimension + max_width: Dimension + min_height: Dimension + max_height: Dimension + aspect_ratio: float + + # --- Layout: flex --- + flex: float + flex_grow: float + flex_shrink: float + flex_basis: Dimension + flex_direction: FlexDirection + justify_content: JustifyContent + align_items: AlignItems + align_self: AlignSelf + + # --- Layout: position --- + position: Position + top: Dimension + right: Dimension + bottom: Dimension + left: Dimension + + # --- Layout: spacing --- + padding: EdgeValue + padding_top: Dimension + padding_bottom: Dimension + padding_left: Dimension + padding_right: Dimension + padding_horizontal: Dimension + padding_vertical: Dimension + margin: EdgeValue + margin_top: Dimension + margin_bottom: Dimension + margin_left: Dimension + margin_right: Dimension + margin_horizontal: Dimension + margin_vertical: Dimension + spacing: float + gap: float + + # --- Visual: clipping & overflow --- + overflow: Overflow + + # --- Visual: colors --- + background_color: Color + color: Color + border_color: Color + placeholder_color: Color + tint_color: Color + + # --- Visual: borders --- + border_width: float + border_radius: float + + # --- Visual: typography --- + font_size: float + font_family: str + font_weight: FontWeight + bold: bool + italic: bool + text_align: TextAlign + text_decoration: TextDecoration + line_height: float + letter_spacing: float + max_lines: int + + # --- Visual: shadows / effects --- + shadow_color: Color + shadow_offset: Union[ShadowOffset, Tuple[float, float], List[float]] + shadow_opacity: float + shadow_radius: float + elevation: float + opacity: float + transform: TransformSpec + + +StyleProp = Union[Style, Dict[str, Any], List[Optional[Union[Style, Dict[str, Any]]]], None] +"""Public type for the ``style`` parameter on every component factory. + +Accepts a [`Style`][pythonnative.Style] TypedDict (recommended), a +plain ``Dict[str, Any]`` (forward-compat / unrestricted), a list of +either with ``None`` entries skipped, or ``None``.""" + + +# ====================================================================== +# Runtime helpers +# ====================================================================== + + +def style(**properties: Any) -> Style: + """Construct a [`Style`][pythonnative.Style] from keyword arguments. + + Equivalent to ``Style(...)`` but works with any Python version + (``TypedDict.__init__`` is fragile prior to 3.11) and reads more + naturally inside expressions: + + ```python + pn.View(child, style=pn.style(padding=16, background_color="#fff")) + ``` + + Unknown keys are accepted at runtime to keep the door open for + third-party styling extensions; static type checkers will still + reject them when this helper's return type is annotated as + ``Style``. + + Args: + **properties: Style key/value pairs. + + Returns: + A fresh ``Style`` dict containing the supplied entries. + """ + return properties # type: ignore[return-value] + + +def resolve_style(value: StyleProp) -> Dict[str, Any]: """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). Used by every built-in element factory in + Accepts ``None``, a single dict (``Style`` or untyped), or a list of + dicts (later entries override earlier ones, mirroring React Native's + array-style pattern). Used by every built-in element factory in `pythonnative.components`. Args: - style: The raw value of the component's `style` argument. + value: The raw value of the component's `style` argument. Returns: A flat dict suitable for the native handler. Always a fresh dict, never the input. """ - if style is None: + if value is None: return {} - if isinstance(style, dict): - return dict(style) - result: _StyleDict = {} - for entry in style: + if isinstance(value, dict): + return dict(value) + result: Dict[str, Any] = {} + for entry in value: if entry: result.update(entry) return result @@ -77,78 +383,87 @@ class StyleSheet: """ @staticmethod - def create(**named_styles: _StyleDict) -> Dict[str, _StyleDict]: + def create(**named_styles: Style) -> Dict[str, Style]: """Create a set of named styles from keyword arguments. Args: **named_styles: Each keyword argument is a style name - mapping to a dict of property values. + mapping to a [`Style`][pythonnative.Style] dict. Returns: - A dict mapping each name to a copy of the supplied dict, so - the caller can mutate the result without affecting the + A dict mapping each name to a copy of the supplied style, + so the caller can mutate the result without affecting the originals. Example: ```python - from pythonnative import StyleSheet + from pythonnative import StyleSheet, style styles = StyleSheet.create( - heading={"font_size": 28, "bold": True}, - body={"font_size": 16}, + heading=style(font_size=28, bold=True), + body=style(font_size=16), ) ``` """ - return {name: dict(props) for name, props in named_styles.items()} + return {name: dict(props) for name, props in named_styles.items()} # type: ignore[misc] @staticmethod - def compose(*styles: _StyleDict) -> _StyleDict: + def compose(*styles: StyleProp) -> Style: """Merge multiple style dicts. Args: *styles: Style dicts to merge. Later dicts override keys - from earlier ones. + from earlier ones. Falsy entries (``None``) are skipped. + List entries are flattened in turn. Returns: - A new dict containing the merged result. Falsy entries - (e.g., `None`) are skipped. + A new ``Style`` dict containing the merged result. """ - merged: _StyleDict = {} - for style in styles: - if style: - merged.update(style) - return merged + merged: Dict[str, Any] = {} + for entry in styles: + if entry is None: + continue + if isinstance(entry, dict): + merged.update(entry) + continue + for nested in entry: + if nested: + merged.update(nested) + return merged # type: ignore[return-value] @staticmethod - def flatten(styles: Any) -> _StyleDict: + def flatten(styles: StyleProp) -> Style: """Flatten a style value or list of styles into a single dict. Equivalent to [`resolve_style`][pythonnative.style.resolve_style] but exposed - on `StyleSheet` for parity with React Native's API. + on `StyleSheet` for parity with React Native's API and typed + as returning a ``Style``. Args: styles: A single dict, a list of dicts, or `None`. Returns: - A flat dict combining the inputs. + A flat ``Style`` dict combining the inputs. """ - 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 + return resolve_style(styles) # type: ignore[return-value] + + @staticmethod + def absolute_fill() -> Style: + """Return a style that absolutely fills the parent. + + Convenience preset matching React Native's + ``StyleSheet.absoluteFill``: ``position: "absolute"`` with + every inset pinned to ``0``. + """ + return {"position": "absolute", "top": 0, "right": 0, "bottom": 0, "left": 0} # ====================================================================== # Theming # ====================================================================== -DEFAULT_LIGHT_THEME: _StyleDict = { +DEFAULT_LIGHT_THEME: Dict[str, Any] = { "primary_color": "#007AFF", "secondary_color": "#5856D6", "background_color": "#FFFFFF", @@ -167,7 +482,7 @@ def flatten(styles: Any) -> _StyleDict: "border_radius": 8, } -DEFAULT_DARK_THEME: _StyleDict = { +DEFAULT_DARK_THEME: Dict[str, Any] = { "primary_color": "#0A84FF", "secondary_color": "#5E5CE6", "background_color": "#000000", @@ -194,3 +509,40 @@ def flatten(styles: Any) -> _StyleDict: override the theme for that subtree, then read it inside descendants via [`use_context(ThemeContext)`][pythonnative.use_context]. """ + + +__all__ = [ + "AlignItems", + "AlignSelf", + "AutoCapitalize", + "Color", + "DEFAULT_DARK_THEME", + "DEFAULT_LIGHT_THEME", + "Dimension", + "EdgeInsets", + "EdgeValue", + "FlexDirection", + "FontWeight", + "JustifyContent", + "KeyboardType", + "Overflow", + "Position", + "ReturnKeyType", + "ScaleType", + "ShadowOffset", + "Style", + "StyleProp", + "StyleSheet", + "TextAlign", + "TextDecoration", + "ThemeContext", + "TransformEntry", + "TransformRotate", + "TransformScale", + "TransformScaleX", + "TransformScaleY", + "TransformSpec", + "TransformTranslate", + "resolve_style", + "style", +] diff --git a/tests/test_sdk.py b/tests/test_sdk.py new file mode 100644 index 0000000..bad84b9 --- /dev/null +++ b/tests/test_sdk.py @@ -0,0 +1,446 @@ +"""Tests for the public extension SDK (``pythonnative.sdk``). + +Exercises the typed prop system (``Props``), the +``@native_component`` decorator, the imperative ``register_component`` +helper, ``element_factory`` constructors, and the entry-point discovery +hook used to import third-party PyPI plugins. +""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import pytest + +import pythonnative as pn +import pythonnative.sdk._components as nc_internals +from pythonnative.element import Element +from pythonnative.native_views import NativeViewRegistry, set_registry +from pythonnative.sdk import ( + ENTRY_POINT_GROUP, + Props, + ViewHandler, + element_factory, + get_props_type, + install_into_registry, + list_components, + native_component, + register_component, + unregister_component, +) + +# --------------------------------------------------------------------------- +# Test fixtures +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class BadgeProps(Props): + text: str = "" + color: str = "#FF3B30" + style: pn.StyleProp = None + + +class StubBadgeHandler(ViewHandler): + """ViewHandler whose native view is just a dict for assertions.""" + + def __init__(self) -> None: + self.created: list = [] + + def create(self, props: Dict[str, Any]) -> Dict[str, Any]: + view = {"props": dict(props), "type": "badge"} + self.created.append(view) + return view + + def update(self, view: Dict[str, Any], changed: Dict[str, Any]) -> None: + view["props"].update(changed) + + +@pytest.fixture(autouse=True) +def _clean_registry() -> Any: + """Snapshot the SDK registry before each test, restore on teardown.""" + snapshot = dict(nc_internals._REGISTRY) + nc_internals._REGISTRY.clear() + nc_internals._reset_discovery_state_for_tests() + yield + nc_internals._REGISTRY.clear() + nc_internals._REGISTRY.update(snapshot) + nc_internals._reset_discovery_state_for_tests() + + +# --------------------------------------------------------------------------- +# native_component decorator +# --------------------------------------------------------------------------- + + +def test_native_component_registers_handler() -> None: + @native_component("Badge", props=BadgeProps, platforms=("ios",)) + class IOSBadge(StubBadgeHandler): + pass + + assert "Badge" in list_components() + assert get_props_type("Badge") is BadgeProps + + +def test_native_component_default_platforms() -> None: + @native_component("Spinner") + class Spinner(StubBadgeHandler): + pass + + plat_map = nc_internals._REGISTRY["Spinner"][1] + assert set(plat_map.keys()) == {"android", "ios"} + + +def test_native_component_two_platform_specific_classes() -> None: + @native_component("Badge", props=BadgeProps, platforms=("ios",)) + class IOSBadge(StubBadgeHandler): + pass + + @native_component("Badge", props=BadgeProps, platforms=("android",)) + class AndroidBadge(StubBadgeHandler): + pass + + plat_map = nc_internals._REGISTRY["Badge"][1] + assert set(plat_map.keys()) == {"ios", "android"} + assert isinstance(plat_map["ios"], IOSBadge) + assert isinstance(plat_map["android"], AndroidBadge) + + +def test_native_component_replaces_handler_for_same_platform() -> None: + @native_component("Badge", props=BadgeProps, platforms=("ios",)) + class FirstBadge(StubBadgeHandler): + pass + + @native_component("Badge", props=BadgeProps, platforms=("ios",)) + class SecondBadge(StubBadgeHandler): + pass + + plat_map = nc_internals._REGISTRY["Badge"][1] + assert isinstance(plat_map["ios"], SecondBadge) + + +def test_native_component_rejects_non_view_handler() -> None: + with pytest.raises(TypeError, match="ViewHandler subclass"): + + @native_component("BadCoffee") + class NotAHandler: # type: ignore[type-var] + pass + + +# --------------------------------------------------------------------------- +# register_component (imperative) +# --------------------------------------------------------------------------- + + +def test_register_component_basic() -> None: + handler = StubBadgeHandler() + register_component(name="Badge", props=BadgeProps, handlers={"ios": handler}) + assert get_props_type("Badge") is BadgeProps + assert nc_internals._REGISTRY["Badge"][1]["ios"] is handler + + +def test_register_component_merges_platforms() -> None: + a = StubBadgeHandler() + b = StubBadgeHandler() + register_component(name="Badge", handlers={"ios": a}) + register_component(name="Badge", props=BadgeProps, handlers={"android": b}) + plat_map = nc_internals._REGISTRY["Badge"][1] + assert plat_map == {"ios": a, "android": b} + # Late-arrived props get installed + assert get_props_type("Badge") is BadgeProps + + +def test_register_component_invalid_props_raises() -> None: + class NotADataclass: + pass + + with pytest.raises(TypeError, match="must be a @dataclass type"): + register_component( + name="Badge", + props=NotADataclass, + handlers={"ios": StubBadgeHandler()}, + ) + + +def test_register_component_invalid_handler_raises() -> None: + with pytest.raises(TypeError, match="ViewHandler instance"): + register_component(name="Badge", handlers={"ios": "not a handler"}) # type: ignore[dict-item] + + +def test_unregister_component() -> None: + register_component(name="Badge", handlers={"ios": StubBadgeHandler()}) + assert "Badge" in list_components() + unregister_component("Badge") + assert "Badge" not in list_components() + + +# --------------------------------------------------------------------------- +# element_factory +# --------------------------------------------------------------------------- + + +def test_element_factory_validates_kwargs_via_props() -> None: + register_component(name="Badge", props=BadgeProps, handlers={"ios": StubBadgeHandler()}) + Badge = element_factory("Badge") + el = Badge(text="3", color="#0A84FF") + assert isinstance(el, Element) + assert el.type == "Badge" + assert el.props["text"] == "3" + assert el.props["color"] == "#0A84FF" + assert el.children == [] + + +def test_element_factory_accepts_props_instance() -> None: + register_component(name="Badge", props=BadgeProps, handlers={"ios": StubBadgeHandler()}) + Badge = element_factory("Badge") + el = Badge(props=BadgeProps(text="Hi", color="#FFFFFF")) + assert el.props["text"] == "Hi" + assert el.props["color"] == "#FFFFFF" + + +def test_element_factory_skips_none_default_fields() -> None: + register_component(name="Badge", props=BadgeProps, handlers={"ios": StubBadgeHandler()}) + Badge = element_factory("Badge") + el = Badge(props=BadgeProps()) + # ``style`` defaults to None and is dropped. + assert "style" not in el.props + # Non-None defaults survive. + assert el.props["text"] == "" + assert el.props["color"] == "#FF3B30" + + +def test_element_factory_resolves_style_arg() -> None: + register_component(name="Badge", props=BadgeProps, handlers={"ios": StubBadgeHandler()}) + Badge = element_factory("Badge") + el = Badge(text="5", style=pn.style(padding=4, background_color="#000")) + assert el.props["padding"] == 4 + assert el.props["background_color"] == "#000" + assert el.props["text"] == "5" + + +def test_element_factory_passes_children() -> None: + register_component(name="Container", handlers={"ios": StubBadgeHandler()}) + Container = element_factory("Container") + inner = pn.Text("inner") + el = Container(inner, key="root") + assert el.children == [inner] + assert el.key == "root" + + +def test_element_factory_unknown_name_raises() -> None: + with pytest.raises(KeyError, match="No component registered"): + element_factory("DoesNotExist") + + +def test_element_factory_kwargs_against_unknown_field_raises() -> None: + register_component(name="Badge", props=BadgeProps, handlers={"ios": StubBadgeHandler()}) + Badge = element_factory("Badge") + with pytest.raises(TypeError, match="Invalid props"): + Badge(unknown_field=42) + + +def test_element_factory_rejects_both_props_and_kwargs() -> None: + register_component(name="Badge", props=BadgeProps, handlers={"ios": StubBadgeHandler()}) + Badge = element_factory("Badge") + with pytest.raises(TypeError, match="Pass either props"): + Badge(props=BadgeProps(text="a"), text="b") + + +def test_element_factory_without_props_passes_kwargs_through() -> None: + register_component(name="Anything", handlers={"ios": StubBadgeHandler()}) + Anything = element_factory("Anything") + el = Anything(arbitrary="value", number=42) + assert el.props == {"arbitrary": "value", "number": 42} + + +# --------------------------------------------------------------------------- +# install_into_registry +# --------------------------------------------------------------------------- + + +def test_install_copies_handlers_for_active_platform() -> None: + handler = StubBadgeHandler() + register_component(name="Badge", props=BadgeProps, handlers={"ios": handler, "android": handler}) + + reg = NativeViewRegistry() + install_into_registry(reg, "ios") + + view = reg.create_view("Badge", {"text": "hi"}) + assert view["type"] == "badge" + assert view["props"]["text"] == "hi" + + +def test_install_skips_handlers_not_targeting_platform() -> None: + register_component(name="IOSOnly", props=BadgeProps, handlers={"ios": StubBadgeHandler()}) + + reg = NativeViewRegistry() + install_into_registry(reg, "android") + + with pytest.raises(ValueError, match="Unknown element type"): + reg.create_view("IOSOnly", {}) + + +def test_install_supports_multiple_components() -> None: + register_component(name="A", handlers={"ios": StubBadgeHandler()}) + register_component(name="B", handlers={"ios": StubBadgeHandler()}) + + reg = NativeViewRegistry() + install_into_registry(reg, "ios") + + reg.create_view("A", {}) + reg.create_view("B", {}) + + +# --------------------------------------------------------------------------- +# Entry-point discovery +# --------------------------------------------------------------------------- + + +def test_entry_point_group_constant() -> None: + """Public entry-point group name is part of the SDK contract.""" + assert ENTRY_POINT_GROUP == "pythonnative.handlers" + + +def test_entry_point_discovery_runs_once(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list = [] + + class FakeEP: + def __init__(self, name: str) -> None: + self.name = name + + def load(self) -> None: + calls.append(self.name) + register_component(name=self.name, handlers={"ios": StubBadgeHandler()}) + + class FakeEntryPoints: + def select(self, group: str) -> Any: + return [FakeEP("FromPlugin")] + + import importlib + + em = importlib.import_module("importlib.metadata") + monkeypatch.setattr(em, "entry_points", lambda: FakeEntryPoints()) + + nc_internals._reset_discovery_state_for_tests() + nc_internals._discover_entry_points() + assert calls == ["FromPlugin"] + nc_internals._discover_entry_points() + assert calls == ["FromPlugin"] # second call is a no-op + + +def test_entry_point_failure_does_not_break_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + """A broken plugin is logged but never propagates.""" + + class BrokenEP: + name = "Broken" + + def load(self) -> None: + raise RuntimeError("boom") + + class GoodEP: + name = "Good" + + def __init__(self) -> None: + self.loaded = False + + def load(self) -> None: + self.loaded = True + register_component(name="Good", handlers={"ios": StubBadgeHandler()}) + + good = GoodEP() + + class FakeEntryPoints: + def select(self, group: str) -> Any: + return [BrokenEP(), good] + + import importlib + + em = importlib.import_module("importlib.metadata") + monkeypatch.setattr(em, "entry_points", lambda: FakeEntryPoints()) + + nc_internals._reset_discovery_state_for_tests() + nc_internals._discover_entry_points() + assert good.loaded + assert "Good" in list_components() + + +def test_get_registry_runs_sdk_install(monkeypatch: pytest.MonkeyPatch) -> None: + """get_registry pulls SDK handlers in for the active platform.""" + handler = StubBadgeHandler() + register_component(name="Badge", props=BadgeProps, handlers={"ios": handler, "android": handler}) + + # Force the lazy registry to rebuild against a stub backend + set_registry(None) + + # Patch `_register_builtin_handlers` so we don't need the real + # platform-specific handler module. + import pythonnative.native_views as nv + + original_register_builtin = nv._register_builtin_handlers + monkeypatch.setattr(nv, "_register_builtin_handlers", lambda registry: None) + + try: + reg = nv.get_registry() + view = reg.create_view("Badge", {"text": "hi"}) + assert view["type"] == "badge" + finally: + set_registry(None) + monkeypatch.setattr(nv, "_register_builtin_handlers", original_register_builtin) + + +# --------------------------------------------------------------------------- +# Round-trip: real factory + real reconciler +# --------------------------------------------------------------------------- + + +class RecordingHandler(ViewHandler): + def __init__(self) -> None: + self.creates: list = [] + self.updates: list = [] + + def create(self, props: Dict[str, Any]) -> Dict[str, Any]: + self.creates.append(dict(props)) + return {"props": dict(props), "kids": []} + + def update(self, view: Dict[str, Any], changed: Dict[str, Any]) -> None: + view["props"].update(changed) + self.updates.append(dict(changed)) + + +def test_sdk_component_drives_reconciler_end_to_end() -> None: + @dataclass(frozen=True) + class Props2(Props): + text: str = "" + intensity: float = 0.5 + style: Optional[pn.StyleProp] = None + + handler = RecordingHandler() + register_component(name="Glass", props=Props2, handlers={"ios": handler, "android": handler}) + + Glass = element_factory("Glass") + + reg = NativeViewRegistry() + install_into_registry(reg, "ios") + + el = Glass(text="hi", intensity=0.8, style=pn.style(padding=8)) + + from pythonnative.reconciler import Reconciler + + rec = Reconciler(reg) + view = rec.mount(el) + assert view["props"]["text"] == "hi" + assert view["props"]["intensity"] == 0.8 + assert view["props"]["padding"] == 8 + assert handler.creates == [view["props"]] + + +# --------------------------------------------------------------------------- +# Top-level re-exports stay in sync with the SDK package +# --------------------------------------------------------------------------- + + +def test_top_level_reexports() -> None: + assert pn.Props is Props + assert pn.ViewHandler is ViewHandler + assert pn.native_component is native_component + assert pn.register_component is register_component + assert pn.element_factory is element_factory diff --git a/tests/test_style.py b/tests/test_style.py index a0687d2..ce8b7a6 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -1,11 +1,13 @@ -"""Unit tests for StyleSheet, resolve_style, and theming.""" +"""Unit tests for StyleSheet, resolve_style, theming, and typed Style.""" from pythonnative.style import ( DEFAULT_DARK_THEME, DEFAULT_LIGHT_THEME, + Style, StyleSheet, ThemeContext, resolve_style, + style, ) @@ -78,3 +80,91 @@ def test_theme_context_has_default() -> None: 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"] + + +# --------------------------------------------------------------------------- +# Typed Style + style() helper +# --------------------------------------------------------------------------- + + +def test_style_helper_returns_dict() -> None: + s = style(font_size=18, color="#FF0000") + assert s == {"font_size": 18, "color": "#FF0000"} + assert isinstance(s, dict) + + +def test_style_helper_empty() -> None: + assert style() == {} + + +def test_style_typeddict_is_runtime_dict() -> None: + """Style is a TypedDict: at runtime values are plain dicts.""" + title: Style = {"font_size": 24, "bold": True, "color": "#000"} + assert isinstance(title, dict) + # ``Style`` is total=False so any subset is valid; here we just want + # to confirm the values flow through resolve_style unchanged. + assert resolve_style(title) == title + + +def test_style_helper_used_with_text_factory() -> None: + from pythonnative.components import Text + + el = Text("Hello", style=style(font_size=18, bold=True)) + assert el.props["text"] == "Hello" + assert el.props["font_size"] == 18 + assert el.props["bold"] is True + + +def test_style_helper_used_with_view_factory() -> None: + from pythonnative.components import View + + el = View(style=style(padding=16, background_color="#fff")) + assert el.props["padding"] == 16 + assert el.props["background_color"] == "#fff" + assert el.props["flex_direction"] == "column" + + +def test_stylesheet_compose_flattens_lists() -> None: + base = style(font_size=16, color="#000") + override = style(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_mixed_dict_and_list() -> None: + merged = StyleSheet.compose( + style(font_size=14), + [None, style(color="#0A84FF")], + style(bold=True), + ) + assert merged == {"font_size": 14, "color": "#0A84FF", "bold": True} + + +def test_stylesheet_absolute_fill() -> None: + fill = StyleSheet.absolute_fill() + assert fill == {"position": "absolute", "top": 0, "right": 0, "bottom": 0, "left": 0} + + +def test_stylesheet_absolute_fill_returns_fresh_dict() -> None: + fill_a = StyleSheet.absolute_fill() + fill_b = StyleSheet.absolute_fill() + fill_a["top"] = 99 + assert fill_b["top"] == 0 + + +def test_resolve_style_list_with_typed_styles() -> None: + """resolve_style accepts a list mixing Style TypedDict entries and ``None``.""" + base: Style = {"font_size": 16} + override: Style = {"color": "#FFF"} + result = resolve_style([base, None, override, None]) + assert result == {"font_size": 16, "color": "#FFF"} + + +def test_resolve_style_returns_fresh_dict() -> None: + """resolve_style never mutates the caller's dict.""" + src = {"font_size": 16} + out = resolve_style(src) + out["font_size"] = 99 + assert src["font_size"] == 16