diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8d2c7..1f6260b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,28 @@ # CHANGELOG +## v0.8.0 (2026-04-08) + +### Features + +- **hooks,reconciler**: Defer effects; add batching and use_reducer + ([`bf6bb57`](https://github.com/pythonnative/pythonnative/commit/bf6bb57b6f97c140820a902ea0eff6bf6a7ffdbc)) + +- **native_views,components**: Add flexbox-inspired layout system + ([`094d997`](https://github.com/pythonnative/pythonnative/commit/094d99786f7153a7286eb7db9775db0bb90abf1d)) + +- **navigation**: Add declarative navigation system + ([`828bbb0`](https://github.com/pythonnative/pythonnative/commit/828bbb0c83fba640a7055edf1237500f27493fd3)) + +- **navigation**: Add native tab bars and nested navigator forwarding + ([`2b80032`](https://github.com/pythonnative/pythonnative/commit/2b8003218267dd39b968c630b89bd5e212ea7254)) + +### Refactoring + +- **native_views**: Split monolithic module into platform-specific package + ([`d0068fd`](https://github.com/pythonnative/pythonnative/commit/d0068fdbcceb4745b02d8043b03eade2b54dde66)) + + ## v0.7.0 (2026-04-03) ### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a24afc..37daba4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,7 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc - `hooks` – function components and hooks (`hooks.py`) - `hot_reload` – file watcher and module reloader (`hot_reload.py`) - `native_modules` – native API modules for device capabilities (`native_modules/`) - - `native_views` – platform-specific native view creation and updates (`native_views.py`) + - `native_views` – platform-specific native view creation and updates (`native_views/`) - `package` – `src/pythonnative/__init__.py` exports and package boundary - `page` – Page component, lifecycle, and reactive state (`page.py`) - `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`) diff --git a/docs/api/component-properties.md b/docs/api/component-properties.md index cda58d7..9def148 100644 --- a/docs/api/component-properties.md +++ b/docs/api/component-properties.md @@ -8,13 +8,39 @@ All components accept these layout properties in their `style` dict: - `width` — fixed width in dp (Android) / pt (iOS) - `height` — fixed height -- `flex` — flex grow factor within Column/Row +- `flex` — flex grow factor (shorthand for `flex_grow`) +- `flex_grow` — how much a child grows to fill available space +- `flex_shrink` — how much a child shrinks when space is limited - `margin` — outer spacing (int, float, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) - `min_width`, `max_width` — width constraints - `min_height`, `max_height` — height constraints -- `align_self` — override parent alignment (`"fill"`, `"center"`, etc.) +- `align_self` — override parent alignment (`"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`) - `key` — stable identity for reconciliation (passed as a kwarg, not inside `style`) +## View + +```python +pn.View(*children, style={ + "flex_direction": "column", + "justify_content": "center", + "align_items": "center", + "overflow": "hidden", + "spacing": 8, + "padding": 16, + "background_color": "#F5F5F5", +}) +``` + +Universal flex container (like React Native's `View`). Defaults to `flex_direction: "column"`. + +Flex container properties (inside `style`): + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing`, `padding`, `background_color` + ## Text ```python @@ -42,23 +68,20 @@ pn.Column(*children, style={"spacing": 12, "padding": 16, "align_items": "center pn.Row(*children, style={"spacing": 8, "justify_content": "space_between"}) ``` +Convenience wrappers for `View` with fixed `flex_direction`: + +- `Column` = `View` with `flex_direction: "column"` (always vertical) +- `Row` = `View` with `flex_direction: "row"` (always horizontal) + - `*children` — child elements (positional) - Style properties: - `spacing` — gap between children (dp / pt) - `padding` — inner padding (int for all sides, or dict with `horizontal`, `vertical`, `left`, `top`, `right`, `bottom`) - - `alignment` — cross-axis alignment shorthand - - `align_items` — cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"`, `"stretch"` - - `justify_content` — main-axis distribution: `"start"`, `"center"`, `"end"`, `"space_between"`, `"space_around"` + - `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`, `"leading"`, `"trailing"` + - `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` + - `overflow` — `"visible"` (default), `"hidden"` - `background_color` — container background -## View - -```python -pn.View(*children, style={"background_color": "#F5F5F5", "padding": 16}) -``` - -Generic container (UIView / FrameLayout). Supports all layout properties in `style`. - ## SafeAreaView ```python @@ -153,6 +176,30 @@ pn.Modal(*children, visible=show_modal, on_dismiss=handler, title="Confirm", Overlay dialog shown when `visible=True`. +## TabBar + +```python +pn.Element("TabBar", { + "items": [ + {"name": "Home", "title": "Home"}, + {"name": "Settings", "title": "Settings"}, + ], + "active_tab": "Home", + "on_tab_select": handler, +}) +``` + +Native tab bar — typically created automatically by `Tab.Navigator`. + +| Platform | Native view | +|----------|--------------------------| +| Android | `BottomNavigationView` | +| iOS | `UITabBar` | + +- `items` — list of `{"name": str, "title": str}` dicts defining each tab +- `active_tab` — the `name` of the currently active tab +- `on_tab_select` — callback `(str) -> None` receiving the selected tab name + ## FlatList ```python diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 849cf95..f05178c 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -13,6 +13,10 @@ Each returns an `Element` descriptor. Visual and layout properties are passed via `style={...}`. See the Component Property Reference for full details. +### ErrorBoundary + +`pythonnative.ErrorBoundary(child, fallback=...)` — catches render errors in *child* and displays *fallback* instead. *fallback* may be an `Element` or a callable that receives the exception and returns an `Element`. + ### Element `pythonnative.Element` — the descriptor type returned by element functions. You generally don't create these directly. @@ -23,8 +27,11 @@ Function component primitives: - `pythonnative.component` — decorator to create a function component - `pythonnative.use_state(initial)` — local component state -- `pythonnative.use_effect(effect, deps)` — side effects -- `pythonnative.use_navigation()` — navigation handle (push/pop/get_args) +- `pythonnative.use_reducer(reducer, initial_state)` — reducer-based state management; returns `(state, dispatch)` +- `pythonnative.use_effect(effect, deps)` — side effects, run after native commit +- `pythonnative.use_navigation()` — navigation handle (navigate/go_back/get_params) +- `pythonnative.use_route()` — convenience hook for current route params +- `pythonnative.use_focus_effect(effect, deps)` — like `use_effect` but only runs when the screen is focused - `pythonnative.use_memo(factory, deps)` — memoised values - `pythonnative.use_callback(fn, deps)` — stable function references - `pythonnative.use_ref(initial)` — mutable ref object @@ -32,6 +39,19 @@ Function component primitives: - `pythonnative.create_context(default)` — create a new context - `pythonnative.Provider(context, value, child)` — provide a context value +### Navigation + +Declarative, component-based navigation system: + +- `pythonnative.NavigationContainer(child)` — root container for the navigation tree +- `pythonnative.create_stack_navigator()` — create a stack-based navigator (returns object with `.Navigator` and `.Screen`) +- `pythonnative.create_tab_navigator()` — create a tab-based navigator +- `pythonnative.create_drawer_navigator()` — create a drawer-based navigator + +### Batching + +- `pythonnative.batch_updates()` — context manager that batches multiple state updates into a single re-render + ### Styling - `pythonnative.StyleSheet` — utility for creating and composing style dicts @@ -53,7 +73,7 @@ Function component primitives: ## Reconciler -`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, and context providers. Used internally by `create_page`. +`pythonnative.reconciler.Reconciler` — diffs element trees and applies minimal native mutations. Supports key-based child reconciliation, function components, context providers, and error boundaries. Effects are flushed after each mount/reconcile pass. Used internally by `create_page`. ## Hot reload @@ -64,3 +84,9 @@ Function component primitives: ## Native view registry `pythonnative.native_views.NativeViewRegistry` — maps element type names to platform-specific handlers. Use `set_registry()` to inject a mock for testing. + +The `native_views` package is organised into submodules: + +- `pythonnative.native_views.base` — shared `ViewHandler` protocol and utilities (`parse_color_int`, `resolve_padding`, `LAYOUT_KEYS`) +- `pythonnative.native_views.android` — Android handlers (only imported at runtime on Android via Chaquopy) +- `pythonnative.native_views.ios` — iOS handlers (only imported at runtime on iOS via rubicon-objc) diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 6ededb9..5505fdb 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -5,24 +5,35 @@ PythonNative combines **direct native bindings** with a **declarative reconciler ## High-level model 1. **Declarative element tree:** Your `@pn.component` function returns a tree of `Element` descriptors (similar to React elements / virtual DOM nodes). -2. **Function components and hooks:** All UI is built with `@pn.component` functions using `use_state`, `use_effect`, `use_navigation`, etc. — inspired by React hooks but designed for Python. +2. **Function components and hooks:** All UI is built with `@pn.component` functions using `use_state`, `use_reducer`, `use_effect`, `use_navigation`, etc. — inspired by React hooks but designed for Python. 3. **Reconciler:** On first render, the reconciler walks the tree and creates real native views via the platform backend. On subsequent renders (triggered by hook state changes), it diffs the new tree against the old one and applies the minimal set of native mutations. -4. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content. -5. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: +4. **Post-render effects:** Effects queued via `use_effect` are flushed **after** the reconciler commits native mutations, matching React semantics. This guarantees that effect callbacks interact with the committed native tree. +5. **State batching:** Multiple state updates triggered during a render pass (e.g. from effects) are automatically batched into a single re-render. Explicit batching is available via `pn.batch_updates()`. +6. **Key-based reconciliation:** Children can be assigned stable `key` values to preserve identity across re-renders — critical for lists and dynamic content. +7. **Error boundaries:** `pn.ErrorBoundary` catches render errors in child subtrees and displays fallback UI, preventing a single component failure from crashing the entire page. +8. **Direct bindings:** Under the hood, native views are created and updated through direct platform calls: - **iOS:** rubicon-objc exposes Objective-C/Swift classes (`UILabel`, `UIButton`, `UIStackView`, etc.). - **Android:** Chaquopy exposes Java classes (`android.widget.TextView`, `android.widget.Button`, etc.) via the JNI bridge. -6. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls `create_page()` internally to bootstrap your Python component, and the reconciler drives the UI from there. +9. **Thin native bootstrap:** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls `create_page()` internally to bootstrap your Python component, and the reconciler drives the UI from there. ## How it works ``` -@pn.component fn → Element tree → Reconciler → Native views +@pn.component fn → Element tree → Reconciler → Native views → Flush effects ↑ -Hook set_state() → re-render → diff → patch native views +Hook set_state() → schedule render → diff → patch native views → Flush effects + (batched) ``` The reconciler uses **key-based diffing** (matching children by key first, then by position). When a child with the same key/type is found, its props are updated in-place on the native view. When the type changes, the old native view is destroyed and a new one is created. +### Render lifecycle + +1. **Render phase:** Component functions execute. Hooks record state reads, queue effects, and register memos. No native mutations happen yet. +2. **Commit phase:** The reconciler applies the diff to native views — creating, updating, and removing views as needed. +3. **Effect phase:** Pending effects are flushed in depth-first order (children before parents). Cleanup functions from the previous render run before new effect callbacks. +4. **Drain phase:** If effects set state, a new render pass is automatically triggered and the cycle repeats (up to a safety limit to prevent infinite loops). + ## Component model PythonNative uses a single component model: **function components** decorated with `@pn.component`. @@ -40,7 +51,7 @@ def Counter(initial: int = 0): Each component is a Python function that: - Accepts props as keyword arguments -- Uses hooks for state (`use_state`), side effects (`use_effect`), navigation (`use_navigation`), and more +- Uses hooks for state (`use_state`, `use_reducer`), side effects (`use_effect`), navigation (`use_navigation`), and more - Returns an `Element` tree describing the UI - Each call site creates an independent instance with its own hook state @@ -54,7 +65,43 @@ The entry point `create_page()` is called internally by native templates to boot ## Layout -All components support layout properties inside the `style` dict: `width`, `height`, `flex`, `margin`, `min_width`, `max_width`, `min_height`, `max_height`, `align_self`. Containers (`Column`, `Row`) support `spacing`, `padding`, `alignment`, `align_items`, and `justify_content`. +PythonNative uses a **flexbox-inspired layout model** built on platform-native layout managers. + +`View` is the **universal flex container** (like React Native's `View`). It defaults to `flex_direction: "column"`. `Column` and `Row` are convenience wrappers that fix the direction. + +### Flex container properties (inside `style`) + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing + +### Child layout properties + +- `flex` — flex grow factor (shorthand) +- `flex_grow`, `flex_shrink` — individual flex properties +- `align_self` — override the parent's `align_items` for this child +- `width`, `height` — fixed dimensions +- `min_width`, `min_height` — minimum size constraints +- `margin` — outer spacing + +Under the hood: +- **Android:** `LinearLayout` with gravity, weights, and divider-based spacing +- **iOS:** `UIStackView` with axis, alignment, distribution, and layout margins + +## Native view handlers + +Platform-specific rendering logic lives in the `native_views` package, organised into dedicated submodules: + +- `native_views.base` — shared `ViewHandler` protocol and common utilities (colour parsing, padding resolution, layout keys, flex constants) +- `native_views.android` — Android handlers using Chaquopy's Java bridge (`jclass`, `dynamic_proxy`) +- `native_views.ios` — iOS handlers using rubicon-objc (`ObjCClass`, `objc_method`) + +Column, Row, and View share a single `FlexContainerHandler` class on each platform. The handler reads `flex_direction` from the element's props to configure the native layout container. + +Each handler class maps an element type name (e.g. `"Text"`, `"Button"`) to platform-native widget creation, property updates, and child management. The `NativeViewRegistry` lazily imports only the relevant platform module at runtime, so the package can be imported on any platform for testing. ## Comparison @@ -88,10 +135,17 @@ PythonNative provides cross-platform modules for common device APIs: ## Navigation model overview -- See the Navigation guide for full details. - - Navigation is handled via the `use_navigation()` hook, which returns a `NavigationHandle` with `.push()`, `.pop()`, and `.get_args()`. - - iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. - - Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph. +PythonNative provides two navigation approaches: + +- **Declarative navigators** (recommended): `NavigationContainer` with `create_stack_navigator()`, `create_tab_navigator()`, and `create_drawer_navigator()`. Navigation state is managed in Python as component state, and navigators are composable — you can nest tabs inside stacks, etc. +- **Page-level navigation**: `use_navigation()` returns a `NavigationHandle` with `.navigate()`, `.go_back()`, and `.get_params()`, delegating to native platform navigation when running on device. + +Both approaches are supported. The declarative system uses the existing reconciler pipeline — navigators are function components that render the active screen via `use_state`, and navigation context is provided via `Provider`. + +See the [Navigation guide](../guides/navigation.md) for full details. + +- iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. +- Android: single host `Activity` with a `NavHostFragment` and a stack of generic `PageFragment`s driven by a navigation graph. ## Related docs diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 6a2db75..cbeb421 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -22,10 +22,10 @@ pn.Column( **Layout:** -- `Column(*children, style=...)` — vertical stack -- `Row(*children, style=...)` — horizontal stack +- `View(*children, style=...)` — universal flex container (default `flex_direction: "column"`) +- `Column(*children, style=...)` — vertical flex container (fixed `flex_direction: "column"`) +- `Row(*children, style=...)` — horizontal flex container (fixed `flex_direction: "row"`) - `ScrollView(child, style=...)` — scrollable container -- `View(*children, style=...)` — generic container - `SafeAreaView(*children, style=...)` — safe-area-aware container - `Spacer(size, flex)` — empty space @@ -52,20 +52,59 @@ pn.Column( - `Modal(*children, visible, on_dismiss, title)` — modal dialog +**Error handling:** + +- `ErrorBoundary(child, fallback)` — catches render errors in child and displays fallback + **Lists:** - `FlatList(data, render_item, key_extractor, separator_height)` — scrollable data list -### Layout properties +### Flex layout model + +PythonNative uses a **flexbox-inspired layout model**. `View` is the universal flex container — `Column` and `Row` are convenience wrappers. + +#### Flex container properties (inside `style`) -All components accept layout properties inside the `style` dict: +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing + +#### Child layout properties + +All components accept these in their `style` dict: - `width`, `height` — fixed dimensions (dp / pt) -- `flex` — flex grow factor +- `flex` — flex grow factor (shorthand) +- `flex_grow`, `flex_shrink` — individual flex properties - `margin` — outer margin (int, float, or dict like padding) -- `min_width`, `max_width`, `min_height`, `max_height` — size constraints +- `min_width`, `min_height` — minimum size constraints +- `max_width`, `max_height` — maximum size constraints - `align_self` — override parent alignment for this child +#### Example: centering content + +```python +pn.View( + pn.Text("Centered"), + style={"flex": 1, "justify_content": "center", "align_items": "center"}, +) +``` + +#### Example: horizontal row with spacing + +```python +pn.Row( + pn.Button("Cancel"), + pn.Spacer(flex=1), + pn.Button("OK"), + style={"padding": 16, "align_items": "center"}, +) +``` + ## Function components — the building block All UI in PythonNative is built with `@pn.component` function components. Each screen is a function component that returns an element tree: @@ -129,12 +168,15 @@ Changing one `Counter` doesn't affect the other — each has its own hook state. ### Available hooks - `use_state(initial)` — local component state; returns `(value, setter)` -- `use_effect(effect, deps)` — side effects (timers, API calls, subscriptions) +- `use_reducer(reducer, initial_state)` — reducer-based state; returns `(state, dispatch)` +- `use_effect(effect, deps)` — side effects, run after native commit (timers, API calls, subscriptions) - `use_memo(factory, deps)` — memoised computed values - `use_callback(fn, deps)` — stable function references - `use_ref(initial)` — mutable ref that persists across renders - `use_context(context)` — read from a context provider -- `use_navigation()` — navigation handle for push/pop between screens +- `use_navigation()` — navigation handle for navigate/go_back/get_params +- `use_route()` — convenience hook for current route params +- `use_focus_effect(effect, deps)` — like `use_effect` but only runs when the screen is focused ### Custom hooks diff --git a/docs/concepts/hooks.md b/docs/concepts/hooks.md index 1f23b7c..49b75aa 100644 --- a/docs/concepts/hooks.md +++ b/docs/concepts/hooks.md @@ -58,9 +58,40 @@ If the initial value is expensive to compute, pass a callable: count, set_count = pn.use_state(lambda: compute_default()) ``` +### use_reducer + +For complex state logic, `use_reducer` lets you manage state transitions through a reducer function — similar to React's `useReducer`: + +```python +def reducer(state, action): + if action == "increment": + return state + 1 + if action == "decrement": + return state - 1 + if action == "reset": + return 0 + return state + +@pn.component +def Counter(): + count, dispatch = pn.use_reducer(reducer, 0) + + return pn.Column( + pn.Text(f"Count: {count}"), + pn.Row( + pn.Button("-", on_click=lambda: dispatch("decrement")), + pn.Button("+", on_click=lambda: dispatch("increment")), + pn.Button("Reset", on_click=lambda: dispatch("reset")), + style={"spacing": 8}, + ), + ) +``` + +The reducer receives the current state and an action, and returns the new state. Actions can be any value (strings, dicts, etc.). The component only re-renders when the reducer returns a different state. + ### use_effect -Run side effects after render. The effect function may return a cleanup callable. +Run side effects **after** the native view tree is committed. The effect function may return a cleanup callable. ```python @pn.component @@ -78,6 +109,8 @@ def Timer(): return pn.Text(f"Elapsed: {seconds}s") ``` +Effects are **deferred** — they are queued during the render phase and executed after the reconciler finishes committing native view mutations. This means effect callbacks can safely measure layout or interact with the committed native tree. + Dependency control: - `pn.use_effect(fn, None)` — run on every render @@ -86,7 +119,7 @@ Dependency control: ### use_navigation -Access the navigation stack from any component. Returns a `NavigationHandle` with `.push()`, `.pop()`, and `.get_args()`. +Access navigation from any component. Returns a `NavigationHandle` with `.navigate()`, `.go_back()`, and `.get_params()`. ```python @pn.component @@ -97,7 +130,7 @@ def HomeScreen(): pn.Text("Home", style={"font_size": 24}), pn.Button( "Go to Details", - on_click=lambda: nav.push(DetailScreen, args={"id": 42}), + on_click=lambda: nav.navigate("Detail", params={"id": 42}), ), style={"spacing": 12, "padding": 16}, ) @@ -105,17 +138,41 @@ def HomeScreen(): @pn.component def DetailScreen(): nav = pn.use_navigation() - item_id = nav.get_args().get("id", 0) + item_id = nav.get_params().get("id", 0) return pn.Column( pn.Text(f"Detail #{item_id}", style={"font_size": 20}), - pn.Button("Back", on_click=nav.pop), + pn.Button("Back", on_click=nav.go_back), style={"spacing": 12, "padding": 16}, ) ``` See the [Navigation guide](../guides/navigation.md) for full details. +### use_route + +Convenience hook to read the current route's parameters: + +```python +@pn.component +def DetailScreen(): + params = pn.use_route() + item_id = params.get("id", 0) + return pn.Text(f"Detail #{item_id}") +``` + +### use_focus_effect + +Like `use_effect` but only runs when the screen is focused. Useful for refreshing data when navigating back to a screen: + +```python +@pn.component +def FeedScreen(): + items, set_items = pn.use_state([]) + pn.use_focus_effect(lambda: load_items(set_items), []) + return pn.FlatList(data=items, render_item=lambda item, i: pn.Text(item)) +``` + ### use_memo Memoise an expensive computation: @@ -169,6 +226,45 @@ def UserProfile(): return pn.Text(f"Welcome, {user['name']}") ``` +## Batching state updates + +By default, each state setter call triggers a re-render. When you need to update multiple pieces of state at once, use `pn.batch_updates()` to coalesce them into a single render pass: + +```python +@pn.component +def Form(): + name, set_name = pn.use_state("") + email, set_email = pn.use_state("") + + def on_submit(): + with pn.batch_updates(): + set_name("Alice") + set_email("alice@example.com") + # single re-render here + + return pn.Column( + pn.Text(f"{name} <{email}>"), + pn.Button("Fill", on_click=on_submit), + ) +``` + +State updates triggered by effects during a render pass are automatically batched — the framework drains any pending re-renders after effect flushing completes, so you don't need `batch_updates()` inside effects. + +## Error boundaries + +Wrap risky components in `pn.ErrorBoundary` to catch render errors and display a fallback UI: + +```python +@pn.component +def App(): + return pn.ErrorBoundary( + MyRiskyComponent(), + fallback=lambda err: pn.Text(f"Something went wrong: {err}"), + ) +``` + +Without an error boundary, an exception during rendering crashes the entire page. Error boundaries catch errors during both initial mount and subsequent reconciliation. + ## Custom hooks Extract reusable stateful logic into plain functions: diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 58b22ef..5d33ec7 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -1,15 +1,33 @@ # Navigation -This guide shows how to navigate between screens and pass data using the `use_navigation()` hook. +PythonNative offers two approaches to navigation: -## Push / Pop +1. **Declarative navigators** (recommended) — component-based, inspired by React Navigation +2. **Page-level push/pop** — imperative navigation via `use_navigation()` (for native page transitions) -Call `pn.use_navigation()` inside a `@pn.component` to get a `NavigationHandle`. Use `.push()` and `.pop()` to change screens, passing a component reference with optional `args`. +## Declarative Navigation + +Declarative navigators manage screen state as components. Define your screens once, and the navigator handles rendering, transitions, and state. + +### Stack Navigator + +A stack navigator manages a stack of screens — push to go forward, pop to go back. ```python import pythonnative as pn -from app.second_page import SecondPage +from pythonnative.navigation import NavigationContainer, create_stack_navigator + +Stack = create_stack_navigator() +@pn.component +def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Home", + ) + ) @pn.component def HomeScreen(): @@ -17,37 +35,151 @@ def HomeScreen(): return pn.Column( pn.Text("Home", style={"font_size": 24}), pn.Button( - "Go next", - on_click=lambda: nav.push( - SecondPage, - args={"message": "Hello from Home"}, - ), + "Go to Detail", + on_click=lambda: nav.navigate("Detail", params={"id": 42}), ), style={"spacing": 12, "padding": 16}, ) + +@pn.component +def DetailScreen(): + nav = pn.use_navigation() + params = nav.get_params() + return pn.Column( + pn.Text(f"Detail #{params.get('id')}", style={"font_size": 20}), + pn.Button("Back", on_click=nav.go_back), + style={"spacing": 12, "padding": 16}, + ) ``` -On the target screen, retrieve args with `nav.get_args()`: +### Tab Navigator + +A tab navigator renders a **native tab bar** and switches between screens. +On Android the tab bar is a `BottomNavigationView` from Material Components; +on iOS it is a `UITabBar`. ```python +from pythonnative.navigation import create_tab_navigator + +Tab = create_tab_navigator() + @pn.component -def SecondPage(): +def App(): + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + ) +``` + +The tab bar emits a `TabBar` element that maps to platform-native views: + +| Platform | Native view | +|----------|------------------------------| +| Android | `BottomNavigationView` | +| iOS | `UITabBar` | + +### Drawer Navigator + +A drawer navigator provides a side menu for switching screens. + +```python +from pythonnative.navigation import create_drawer_navigator + +Drawer = create_drawer_navigator() + +@pn.component +def App(): + return NavigationContainer( + Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Drawer.Screen("Profile", component=ProfileScreen, options={"title": "Profile"}), + ) + ) + +@pn.component +def HomeScreen(): nav = pn.use_navigation() - message = nav.get_args().get("message", "Second Page") return pn.Column( - pn.Text(message, style={"font_size": 20}), - pn.Button("Back", on_click=nav.pop), - style={"spacing": 12, "padding": 16}, + pn.Button("Open Menu", on_click=nav.open_drawer), + pn.Text("Home Screen"), ) ``` +### Nesting Navigators + +Navigators can be nested — for example, tabs containing stacks. +When a child navigator receives a `navigate()` call for an unknown route, +it automatically **forwards** the request to its parent navigator. +Similarly, `go_back()` at the root of a child stack forwards to the parent. + +```python +Stack = create_stack_navigator() +Tab = create_tab_navigator() + +@pn.component +def HomeStack(): + return Stack.Navigator( + Stack.Screen("Feed", component=FeedScreen), + Stack.Screen("Post", component=PostScreen), + ) + +@pn.component +def App(): + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeStack, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + ) +``` + +Inside `FeedScreen`, calling `nav.navigate("Settings")` will forward to the +parent tab navigator and switch to the Settings tab. + ## NavigationHandle API -`pn.use_navigation()` returns a `NavigationHandle` with: +Inside any screen rendered by a navigator, `pn.use_navigation()` returns a handle with: + +- **`.navigate(route_name, params=...)`** — navigate to a named route with optional params +- **`.go_back()`** — pop the current screen +- **`.get_params()`** — get the current route's params dict +- **`.reset(route_name, params=...)`** — reset the stack to a single route + +### Drawer-specific methods + +When inside a drawer navigator, the handle also provides: + +- **`.open_drawer()`** — open the drawer +- **`.close_drawer()`** — close the drawer +- **`.toggle_drawer()`** — toggle the drawer open/closed -- **`.push(component, args=...)`** — navigate to a new screen. Pass a component reference (the `@pn.component` function itself), with an optional `args` dict. -- **`.pop()`** — go back to the previous screen. -- **`.get_args()`** — retrieve the args dict passed by the caller. +## Focus-aware Effects + +Use `pn.use_focus_effect()` to run effects only when a screen is focused: + +```python +@pn.component +def DataScreen(): + data, set_data = pn.use_state(None) + + pn.use_focus_effect(lambda: fetch_data(set_data), []) + + return pn.Text(f"Data: {data}") +``` + +## Route Parameters + +Use `pn.use_route()` for convenient access to route params: + +```python +@pn.component +def DetailScreen(): + params = pn.use_route() + item_id = params.get("id", 0) + return pn.Text(f"Item #{item_id}") +``` ## Lifecycle @@ -63,11 +195,6 @@ PythonNative forwards lifecycle events from the host: - `on_save_instance_state` - `on_restore_instance_state` -## Notes - -- On Android, `push` navigates via `NavController` to a `PageFragment` and passes `page_path` and optional JSON `args`. -- On iOS, `push` uses the root `UINavigationController` to push a new `ViewController` and passes page info via KVC. - ## Platform specifics ### iOS (UIViewController per page) diff --git a/docs/guides/styling.md b/docs/guides/styling.md index 1dd2026..9556fe6 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -22,7 +22,7 @@ import pythonnative as pn styles = pn.StyleSheet.create( title={"font_size": 28, "bold": True, "color": "#333"}, subtitle={"font_size": 14, "color": "#666"}, - container={"padding": 16, "spacing": 12, "alignment": "fill"}, + container={"padding": 16, "spacing": 12, "align_items": "stretch"}, ) pn.Text("Welcome", style=styles["title"]) @@ -78,25 +78,80 @@ pn.Text("Title", style={"font_size": 24, "bold": True, "text_align": "center"}) pn.Text("Subtitle", style={"font_size": 14, "color": "#666666"}) ``` -## Layout properties +## Flex layout -All components support common layout properties inside `style`: +PythonNative uses a flexbox-inspired layout model. `View` is the universal flex container, and `Column`/`Row` are convenience wrappers. -```python -pn.Text("Fixed size", style={"width": 200, "height": 50}) -pn.View(child, style={"flex": 1, "margin": 8}) -pn.Column(items, style={"margin": {"horizontal": 16, "vertical": 8}}) -``` +### Flex container properties + +These go in the `style` dict of `View`, `Column`, or `Row`: + +- `flex_direction` — `"column"` (default), `"row"`, `"column_reverse"`, `"row_reverse"` (only for `View`; `Column`/`Row` have fixed directions) +- `justify_content` — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` +- `align_items` — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"` +- `overflow` — `"visible"` (default), `"hidden"` +- `spacing` — gap between children (dp / pt) +- `padding` — inner spacing (int for all sides, or dict) + +### Child layout properties + +All components accept these in `style`: - `width`, `height` — fixed dimensions in dp (Android) / pt (iOS) -- `flex` — flex grow factor within Column/Row +- `flex` — flex grow factor (shorthand for `flex_grow`) +- `flex_grow` — how much a child should grow to fill available space +- `flex_shrink` — how much a child should shrink when space is tight - `margin` — outer spacing (int for all sides, or dict) -- `min_width`, `max_width`, `min_height`, `max_height` — size constraints -- `align_self` — override parent alignment +- `min_width`, `min_height` — minimum size constraints +- `max_width`, `max_height` — maximum size constraints +- `align_self` — override parent alignment: `"flex_start"`, `"center"`, `"flex_end"`, `"stretch"` + +### Layout examples + +**Centering content:** + +```python +pn.View( + pn.Text("Centered!"), + style={"flex": 1, "justify_content": "center", "align_items": "center"}, +) +``` + +**Horizontal row with spacer:** + +```python +pn.Row( + pn.Text("Left"), + pn.Spacer(flex=1), + pn.Text("Right"), + style={"padding": 16, "align_items": "center"}, +) +``` + +**Child with flex grow:** + +```python +pn.Column( + pn.Text("Header", style={"font_size": 20, "bold": True}), + pn.View(pn.Text("Content area"), style={"flex": 1}), + pn.Text("Footer"), + style={"flex": 1, "spacing": 8}, +) +``` + +**Horizontal button bar:** + +```python +pn.Row( + pn.Button("Cancel", style={"flex": 1}), + pn.Button("OK", style={"flex": 1, "background_color": "#007AFF", "color": "#FFF"}), + style={"spacing": 8, "padding": 16}, +) +``` ## Layout with Column and Row -`Column` (vertical) and `Row` (horizontal): +`Column` (vertical) and `Row` (horizontal) are convenience wrappers for `View`: ```python pn.Column( @@ -105,7 +160,7 @@ pn.Column( pn.Text("Password"), pn.TextInput(placeholder="Enter password", secure=True), pn.Button("Login", on_click=handle_login), - style={"spacing": 8, "padding": 16, "alignment": "fill"}, + style={"spacing": 8, "padding": 16, "align_items": "stretch"}, ) ``` @@ -113,9 +168,8 @@ pn.Column( Column and Row support `align_items` and `justify_content` inside `style`: -- **`align_items`** — cross-axis alignment: `"fill"`, `"center"`, `"leading"` / `"start"`, `"trailing"` / `"end"` -- **`justify_content`** — main-axis distribution: `"start"`, `"center"`, `"end"`, `"space_between"`, `"space_around"` -- **`alignment`** — shorthand for cross-axis alignment (same values as `align_items`) +- **`align_items`** — cross-axis alignment: `"stretch"`, `"flex_start"`, `"center"`, `"flex_end"`, `"leading"`, `"trailing"` +- **`justify_content`** — main-axis distribution: `"flex_start"`, `"center"`, `"flex_end"`, `"space_between"`, `"space_around"`, `"space_evenly"` ```python pn.Row( diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index d524568..23abd46 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,15 +1,24 @@ import emoji import pythonnative as pn +from pythonnative.navigation import NavigationContainer, create_tab_navigator MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] +Tab = create_tab_navigator() styles = pn.StyleSheet.create( title={"font_size": 24, "bold": True}, subtitle={"font_size": 16, "color": "#666666"}, medal={"font_size": 32}, - section={"spacing": 12, "padding": 16, "align_items": "stretch"}, + card={ + "spacing": 12, + "padding": 16, + "background_color": "#F8F9FA", + "align_items": "center", + }, + section={"spacing": 16, "padding": 24, "align_items": "stretch"}, + button_row={"spacing": 8, "align_items": "center"}, ) @@ -19,16 +28,21 @@ def counter_badge(initial: int = 0) -> pn.Element: count, set_count = pn.use_state(initial) medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") - return pn.Column( + return pn.View( pn.Text(f"Tapped {count} times", style=styles["subtitle"]), pn.Text(medal, style=styles["medal"]), - pn.Button("Tap me", on_click=lambda: set_count(count + 1)), - style={"spacing": 4}, + pn.Row( + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + pn.Button("Reset", on_click=lambda: set_count(0)), + style=styles["button_row"], + ), + style=styles["card"], ) @pn.component -def MainPage() -> pn.Element: +def HomeTab() -> pn.Element: + """Home tab — counter demo and push-navigation to other pages.""" nav = pn.use_navigation() return pn.ScrollView( pn.Column( @@ -36,11 +50,37 @@ def MainPage() -> pn.Element: counter_badge(), pn.Button( "Go to Second Page", - on_click=lambda: nav.push( + on_click=lambda: nav.navigate( "app.second_page.SecondPage", - args={"message": "Greetings from MainPage"}, + params={"message": "Greetings from MainPage"}, ), ), style=styles["section"], ) ) + + +@pn.component +def SettingsTab() -> pn.Element: + """Settings tab — simple placeholder content.""" + return pn.ScrollView( + pn.Column( + pn.Text("Settings", style=styles["title"]), + pn.Text("App version: 0.7.0", style=styles["subtitle"]), + pn.Text( + "This tab uses a native UITabBar on iOS " "and BottomNavigationView on Android.", + style=styles["subtitle"], + ), + style=styles["section"], + ) + ) + + +@pn.component +def MainPage() -> pn.Element: + return NavigationContainer( + Tab.Navigator( + Tab.Screen("Home", component=HomeTab, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsTab, options={"title": "Settings"}), + ) + ) diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 8783d41..40725a2 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -4,15 +4,15 @@ @pn.component def SecondPage() -> pn.Element: nav = pn.use_navigation() - message = nav.get_args().get("message", "Second Page") + message = nav.get_params().get("message", "Second Page") return pn.ScrollView( pn.Column( - pn.Text(message, style={"font_size": 20}), + pn.Text(message, style={"font_size": 24, "bold": True}), pn.Button( "Go to Third Page", - on_click=lambda: nav.push("app.third_page.ThirdPage"), + on_click=lambda: nav.navigate("app.third_page.ThirdPage"), ), - pn.Button("Back", on_click=nav.pop), - style={"spacing": 12, "padding": 16, "align_items": "stretch"}, + pn.Button("Back", on_click=nav.go_back), + style={"spacing": 16, "padding": 24, "align_items": "stretch"}, ) ) diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/third_page.py index 3ebc174..62a3388 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/third_page.py @@ -4,9 +4,11 @@ @pn.component def ThirdPage() -> pn.Element: nav = pn.use_navigation() - return pn.Column( - pn.Text("Third Page", style={"font_size": 24, "bold": True}), - pn.Text("You navigated two levels deep."), - pn.Button("Back to Second", on_click=nav.pop), - style={"spacing": 12, "padding": 16, "align_items": "stretch"}, + return pn.ScrollView( + pn.Column( + pn.Text("Third Page", style={"font_size": 24, "bold": True}), + pn.Text("You navigated two levels deep."), + pn.Button("Back to Second", on_click=nav.go_back), + style={"spacing": 16, "padding": 24, "align_items": "stretch"}, + ) ) diff --git a/mypy.ini b/mypy.ini index 9657f4d..370f2f6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.9 +python_version = 3.10 ignore_missing_imports = True warn_redundant_casts = True warn_unused_ignores = True @@ -17,3 +17,6 @@ disable_error_code = attr-defined,no-redef [mypy-pythonnative.native_views] disable_error_code = misc + +[mypy-pythonnative.native_views.*] +disable_error_code = misc diff --git a/pyproject.toml b/pyproject.toml index 3e7765e..b5ae010 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pythonnative" -version = "0.7.0" +version = "0.8.0" description = "Cross-platform native UI toolkit for Android and iOS" authors = [ { name = "Owen Carey" } diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index f076dc3..11c4e16 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -14,12 +14,13 @@ def App(): ) """ -__version__ = "0.7.0" +__version__ = "0.8.0" from .components import ( ActivityIndicator, Button, Column, + ErrorBoundary, FlatList, Image, Modal, @@ -39,6 +40,7 @@ def App(): from .element import Element from .hooks import ( Provider, + batch_updates, component, create_context, use_callback, @@ -46,9 +48,18 @@ def App(): use_effect, use_memo, use_navigation, + use_reducer, use_ref, use_state, ) +from .navigation import ( + NavigationContainer, + create_drawer_navigator, + create_stack_navigator, + create_tab_navigator, + use_focus_effect, + use_route, +) from .page import create_page from .style import StyleSheet, ThemeContext @@ -57,6 +68,7 @@ def App(): "ActivityIndicator", "Button", "Column", + "ErrorBoundary", "FlatList", "Image", "Modal", @@ -76,16 +88,25 @@ def App(): "Element", "create_page", # Hooks + "batch_updates", "component", "create_context", "use_callback", "use_context", "use_effect", + "use_focus_effect", "use_memo", "use_navigation", + "use_reducer", "use_ref", + "use_route", "use_state", "Provider", + # Navigation + "NavigationContainer", + "create_drawer_navigator", + "create_stack_navigator", + "create_tab_navigator", # Styling "StyleSheet", "ThemeContext", diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py index 820f001..eb748a2 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -9,12 +9,18 @@ Layout properties supported by all components:: - width, height, flex, margin, min_width, max_width, min_height, - max_height, align_self + width, height, flex, flex_grow, flex_shrink, margin, + min_width, max_width, min_height, max_height, align_self -Container-specific layout properties (Column / Row):: +Flex container properties (View / Column / Row):: - spacing, padding, align_items, justify_content + flex_direction, justify_content, align_items, overflow, + spacing, padding + +``View`` is the universal flex container (like React Native's ``View``). +It defaults to ``flex_direction: "column"``. ``Column`` and ``Row`` +are convenience wrappers that fix the direction to ``"column"`` and +``"row"`` respectively. """ from typing import Any, Callable, Dict, List, Optional @@ -204,15 +210,48 @@ def Slider( # ====================================================================== +def View( + *children: Element, + style: StyleValue = None, + key: Optional[str] = None, +) -> Element: + """Universal flex container (like React Native's ``View``). + + Defaults to ``flex_direction: "column"``. Override via ``style``:: + + pn.View(child_a, child_b, style={"flex_direction": "row"}) + + Flex container properties (inside ``style``): + + - ``flex_direction`` — ``"column"`` (default), ``"row"``, + ``"column_reverse"``, ``"row_reverse"`` + - ``justify_content`` — main-axis distribution: + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"`` + - ``align_items`` — cross-axis alignment: + ``"stretch"`` (default), ``"flex_start"``, ``"center"``, + ``"flex_end"`` + - ``overflow`` — ``"visible"`` (default) or ``"hidden"`` + - ``spacing``, ``padding``, ``background_color`` + """ + props: Dict[str, Any] = {"flex_direction": "column"} + props.update(resolve_style(style)) + return Element("View", props, list(children), key=key) + + def Column( *children: Element, style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Arrange children vertically. + """Arrange children vertically (``flex_direction: "column"``). + + Convenience wrapper around :func:`View`. The direction is fixed; + use :func:`View` directly if you need ``flex_direction: "row"``. Style properties: ``spacing``, ``padding``, ``align_items``, - ``justify_content``, ``background_color``, plus common layout props. + ``justify_content``, ``background_color``, ``overflow``, + plus common layout props. ``align_items`` controls cross-axis (horizontal) alignment: ``"stretch"`` (default), ``"flex_start"``/``"leading"``, @@ -222,8 +261,9 @@ def Column( ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, ``"space_between"``, ``"space_around"``, ``"space_evenly"``. """ - props: Dict[str, Any] = {} + props: Dict[str, Any] = {"flex_direction": "column"} props.update(resolve_style(style)) + props["flex_direction"] = "column" return Element("Column", props, list(children), key=key) @@ -232,10 +272,14 @@ def Row( style: StyleValue = None, key: Optional[str] = None, ) -> Element: - """Arrange children horizontally. + """Arrange children horizontally (``flex_direction: "row"``). + + Convenience wrapper around :func:`View`. The direction is fixed; + use :func:`View` directly if you need ``flex_direction: "column"``. Style properties: ``spacing``, ``padding``, ``align_items``, - ``justify_content``, ``background_color``, plus common layout props. + ``justify_content``, ``background_color``, ``overflow``, + plus common layout props. ``align_items`` controls cross-axis (vertical) alignment: ``"stretch"`` (default), ``"flex_start"``/``"top"``, @@ -245,8 +289,9 @@ def Row( ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, ``"space_between"``, ``"space_around"``, ``"space_evenly"``. """ - props: Dict[str, Any] = {} + props: Dict[str, Any] = {"flex_direction": "row"} props.update(resolve_style(style)) + props["flex_direction"] = "row" return Element("Row", props, list(children), key=key) @@ -263,17 +308,6 @@ def ScrollView( return Element("ScrollView", props, children, key=key) -def View( - *children: Element, - style: StyleValue = None, - key: Optional[str] = None, -) -> Element: - """Generic container view (``UIView`` / ``android.view.View``).""" - props: Dict[str, Any] = {} - props.update(resolve_style(style)) - return Element("View", props, list(children), key=key) - - def SafeAreaView( *children: Element, style: StyleValue = None, @@ -323,6 +357,29 @@ def Pressable( return Element("Pressable", props, children, key=key) +def ErrorBoundary( + child: Optional[Element] = None, + *, + fallback: Optional[Any] = None, + key: Optional[str] = None, +) -> Element: + """Catch render errors in *child* and display *fallback* instead. + + *fallback* may be an ``Element`` or a callable that receives the + exception and returns an ``Element``:: + + pn.ErrorBoundary( + MyRiskyComponent(), + fallback=lambda err: pn.Text(f"Error: {err}"), + ) + """ + props: Dict[str, Any] = {} + if fallback is not None: + props["__fallback__"] = fallback + children = [child] if child is not None else [] + return Element("__ErrorBoundary__", props, children, key=key) + + def FlatList( *, data: Optional[List[Any]] = None, diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py index 4d7b122..b6ff196 100644 --- a/src/pythonnative/hooks.py +++ b/src/pythonnative/hooks.py @@ -19,7 +19,8 @@ def counter(initial=0): import inspect import threading -from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar +from contextlib import contextmanager +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, TypeVar from .element import Element @@ -29,6 +30,7 @@ def counter(initial=0): _hook_context: threading.local = threading.local() +_batch_context: threading.local = threading.local() # ====================================================================== # Hook state container @@ -36,9 +38,22 @@ def counter(initial=0): class HookState: - """Stores all hook data for a single function component instance.""" + """Stores all hook data for a single function component instance. - __slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render") + Effects are **queued** during the render phase and **flushed** after + the reconciler commits native-view mutations. This guarantees that + effect callbacks can safely interact with the committed native tree. + """ + + __slots__ = ( + "states", + "effects", + "memos", + "refs", + "hook_index", + "_trigger_render", + "_pending_effects", + ) def __init__(self) -> None: self.states: List[Any] = [] @@ -47,15 +62,24 @@ def __init__(self) -> None: self.refs: List[dict] = [] self.hook_index: int = 0 self._trigger_render: Optional[Callable[[], None]] = None + self._pending_effects: List[Tuple[int, Callable, Any]] = [] def reset_index(self) -> None: self.hook_index = 0 - def run_pending_effects(self) -> None: - """Execute effects whose deps changed during the last render pass.""" - for i, (deps, cleanup) in enumerate(self.effects): - if deps is _SENTINEL: - continue + def flush_pending_effects(self) -> None: + """Execute effects queued during the render pass (called after commit).""" + pending = self._pending_effects + self._pending_effects = [] + for idx, effect_fn, deps in pending: + _, prev_cleanup = self.effects[idx] + if callable(prev_cleanup): + try: + prev_cleanup() + except Exception: + pass + cleanup = effect_fn() + self.effects[idx] = (list(deps) if deps is not None else None, cleanup) def cleanup_all_effects(self) -> None: """Run all outstanding cleanup functions (called on unmount).""" @@ -66,6 +90,7 @@ def cleanup_all_effects(self) -> None: except Exception: pass self.effects[i] = (_SENTINEL, None) + self._pending_effects = [] # ====================================================================== @@ -91,6 +116,45 @@ def _deps_changed(prev: Any, current: Any) -> bool: return any(p is not c and p != c for p, c in zip(prev, current)) +# ====================================================================== +# Batching helpers +# ====================================================================== + + +def _schedule_trigger(trigger: Callable[[], None]) -> None: + """Call *trigger* now, or defer it if inside :func:`batch_updates`.""" + if getattr(_batch_context, "depth", 0) > 0: + _batch_context.pending_trigger = trigger + else: + trigger() + + +@contextmanager +def batch_updates() -> Generator[None, None, None]: + """Batch multiple state updates so only one re-render occurs. + + Usage:: + + with pn.batch_updates(): + set_count(1) + set_name("hello") + # single re-render happens here + """ + depth = getattr(_batch_context, "depth", 0) + _batch_context.depth = depth + 1 + if depth == 0: + _batch_context.pending_trigger = None + try: + yield + finally: + _batch_context.depth -= 1 + if _batch_context.depth == 0: + trigger = _batch_context.pending_trigger + _batch_context.pending_trigger = None + if trigger is not None: + trigger() + + # ====================================================================== # Public hooks # ====================================================================== @@ -121,13 +185,60 @@ def setter(new_value: Any) -> None: if ctx.states[idx] is not new_value and ctx.states[idx] != new_value: ctx.states[idx] = new_value if ctx._trigger_render: - ctx._trigger_render() + _schedule_trigger(ctx._trigger_render) return current, setter +def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple[Any, Callable]: + """Return ``(state, dispatch)`` for reducer-based state management. + + The *reducer* is called as ``reducer(current_state, action)`` and + must return the new state. If *initial_state* is callable it is + invoked once (lazy initialisation). + + Usage:: + + def reducer(state, action): + if action == "increment": + return state + 1 + if action == "reset": + return 0 + return state + + count, dispatch = pn.use_reducer(reducer, 0) + # dispatch("increment") -> re-render with count = 1 + """ + ctx = _get_hook_state() + if ctx is None: + raise RuntimeError("use_reducer must be called inside a @component function") + + idx = ctx.hook_index + ctx.hook_index += 1 + + if idx >= len(ctx.states): + val = initial_state() if callable(initial_state) else initial_state + ctx.states.append(val) + + current = ctx.states[idx] + + def dispatch(action: Any) -> None: + new_state = reducer(ctx.states[idx], action) + if ctx.states[idx] is not new_state and ctx.states[idx] != new_state: + ctx.states[idx] = new_state + if ctx._trigger_render: + _schedule_trigger(ctx._trigger_render) + + return current, dispatch + + def use_effect(effect: Callable, deps: Optional[list] = None) -> None: - """Schedule *effect* to run after render. + """Schedule *effect* to run **after** the native tree is committed. + + Effects are queued during the render pass and flushed once the + reconciler has finished applying all native-view mutations. This + means effects can safely measure layout or interact with committed + native views. *deps* controls when the effect re-runs: @@ -146,18 +257,12 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None: if idx >= len(ctx.effects): ctx.effects.append((_SENTINEL, None)) + ctx._pending_effects.append((idx, effect, deps)) + return - prev_deps, prev_cleanup = ctx.effects[idx] + prev_deps, _prev_cleanup = ctx.effects[idx] if _deps_changed(prev_deps, deps): - if callable(prev_cleanup): - try: - prev_cleanup() - except Exception: - pass - cleanup = effect() - ctx.effects[idx] = (list(deps) if deps is not None else None, cleanup) - else: - ctx.effects[idx] = (prev_deps, prev_cleanup) + ctx._pending_effects.append((idx, effect, deps)) def use_memo(factory: Callable[[], T], deps: list) -> T: @@ -255,27 +360,28 @@ def Provider(context: Context, value: Any, child: Element) -> Element: class NavigationHandle: - """Object returned by :func:`use_navigation` providing push/pop/get_args. + """Object returned by :func:`use_navigation` providing navigation methods. - Navigates by component reference rather than string path, e.g.:: + :: nav = pn.use_navigation() - nav.push(DetailScreen, args={"id": 42}) + nav.navigate(DetailScreen, params={"id": 42}) + nav.go_back() """ def __init__(self, host: Any) -> None: self._host = host - def push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: - """Navigate forward to *page* (a ``@component`` function or class).""" - self._host._push(page, args) + def navigate(self, page: Any, params: Optional[Dict[str, Any]] = None) -> None: + """Navigate forward to *page* with optional *params*.""" + self._host._push(page, params) - def pop(self) -> None: + def go_back(self) -> None: """Navigate back to the previous screen.""" self._host._pop() - def get_args(self) -> Dict[str, Any]: - """Return arguments passed from the previous screen.""" + def get_params(self) -> Dict[str, Any]: + """Return parameters passed from the previous screen.""" return self._host._get_nav_args() diff --git a/src/pythonnative/hot_reload.py b/src/pythonnative/hot_reload.py index 1933118..7a10bd9 100644 --- a/src/pythonnative/hot_reload.py +++ b/src/pythonnative/hot_reload.py @@ -137,7 +137,7 @@ def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]: @staticmethod def reload_page(page_instance: Any) -> None: """Force a page re-render after module reload.""" - from .page import _re_render + from .page import _request_render if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None: - _re_render(page_instance) + _request_render(page_instance) diff --git a/src/pythonnative/native_views.py b/src/pythonnative/native_views.py deleted file mode 100644 index 0d75e61..0000000 --- a/src/pythonnative/native_views.py +++ /dev/null @@ -1,1404 +0,0 @@ -"""Platform-specific native view creation and update logic. - -This module replaces the old per-widget files. All platform-branching -lives here, guarded behind lazy imports so the module can be imported -on desktop for testing. -""" - -from typing import Any, Callable, Dict, Optional, Union - -from .utils import IS_ANDROID - -# ====================================================================== -# Abstract handler protocol -# ====================================================================== - - -class ViewHandler: - """Protocol for creating, updating, and managing children of a native view type.""" - - def create(self, props: Dict[str, Any]) -> Any: - raise NotImplementedError - - def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: - raise NotImplementedError - - def add_child(self, parent: Any, child: Any) -> None: - pass - - def remove_child(self, parent: Any, child: Any) -> None: - pass - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - self.add_child(parent, child) - - -# ====================================================================== -# Registry -# ====================================================================== - - -class NativeViewRegistry: - """Maps element type names to platform-specific :class:`ViewHandler` instances.""" - - def __init__(self) -> None: - self._handlers: Dict[str, ViewHandler] = {} - - def register(self, type_name: str, handler: ViewHandler) -> None: - self._handlers[type_name] = handler - - def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: - handler = self._handlers.get(type_name) - if handler is None: - raise ValueError(f"Unknown element type: {type_name!r}") - return handler.create(props) - - def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: - handler = self._handlers.get(type_name) - if handler is not None: - handler.update(native_view, changed_props) - - def add_child(self, parent: Any, child: Any, parent_type: str) -> None: - handler = self._handlers.get(parent_type) - if handler is not None: - handler.add_child(parent, child) - - def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: - handler = self._handlers.get(parent_type) - if handler is not None: - handler.remove_child(parent, child) - - def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: - handler = self._handlers.get(parent_type) - if handler is not None: - handler.insert_child(parent, child, index) - - -# ====================================================================== -# Shared helpers -# ====================================================================== - - -def parse_color_int(color: Union[str, int]) -> int: - """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. - - Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values - with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. - """ - if isinstance(color, int): - val = color - else: - c = color.strip().lstrip("#") - if len(c) == 6: - c = "FF" + c - val = int(c, 16) - if val > 0x7FFFFFFF: - val -= 0x100000000 - return val - - -def _resolve_padding( - padding: Any, -) -> tuple: - """Normalise various padding representations to ``(left, top, right, bottom)``.""" - if padding is None: - return (0, 0, 0, 0) - if isinstance(padding, (int, float)): - v = int(padding) - return (v, v, v, v) - if isinstance(padding, dict): - h = int(padding.get("horizontal", 0)) - v = int(padding.get("vertical", 0)) - left = int(padding.get("left", h)) - right = int(padding.get("right", h)) - top = int(padding.get("top", v)) - bottom = int(padding.get("bottom", v)) - a = int(padding.get("all", 0)) - if a: - left = left or a - right = right or a - top = top or a - bottom = bottom or a - return (left, top, right, bottom) - return (0, 0, 0, 0) - - -_LAYOUT_KEYS = frozenset( - { - "width", - "height", - "flex", - "margin", - "min_width", - "max_width", - "min_height", - "max_height", - "align_self", - } -) - - -# ====================================================================== -# Platform handler registration (lazy imports inside functions) -# ====================================================================== - - -def _register_android_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 - from java import dynamic_proxy, jclass - - from .utils import get_android_context - - def _ctx() -> Any: - return get_android_context() - - def _density() -> float: - return float(_ctx().getResources().getDisplayMetrics().density) - - def _dp(value: float) -> int: - return int(value * _density()) - - def _apply_layout(view: Any, props: Dict[str, Any]) -> None: - """Apply common layout properties to an Android view.""" - lp = view.getLayoutParams() - LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") - ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") - needs_set = False - - if lp is None: - lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT) - needs_set = True - - if "width" in props and props["width"] is not None: - lp.width = _dp(float(props["width"])) - needs_set = True - if "height" in props and props["height"] is not None: - lp.height = _dp(float(props["height"])) - needs_set = True - if "flex" in props and props["flex"] is not None: - try: - lp.weight = float(props["flex"]) - needs_set = True - except Exception: - pass - if "margin" in props and props["margin"] is not None: - left, top, right, bottom = _resolve_padding(props["margin"]) - try: - lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom)) - needs_set = True - except Exception: - pass - - if needs_set: - view.setLayoutParams(lp) - - # ---- Text ----------------------------------------------------------- - class AndroidTextHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - tv = jclass("android.widget.TextView")(_ctx()) - self._apply(tv, props) - _apply_layout(tv, props) - return tv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, tv: Any, props: Dict[str, Any]) -> None: - if "text" in props: - tv.setText(str(props["text"])) - if "font_size" in props and props["font_size"] is not None: - tv.setTextSize(float(props["font_size"])) - if "color" in props and props["color"] is not None: - tv.setTextColor(parse_color_int(props["color"])) - if "background_color" in props and props["background_color"] is not None: - tv.setBackgroundColor(parse_color_int(props["background_color"])) - if "bold" in props and props["bold"]: - tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 - if "max_lines" in props and props["max_lines"] is not None: - tv.setMaxLines(int(props["max_lines"])) - if "text_align" in props: - Gravity = jclass("android.view.Gravity") - mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} - tv.setGravity(mapping.get(props["text_align"], Gravity.START)) - - # ---- Button --------------------------------------------------------- - class AndroidButtonHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - btn = jclass("android.widget.Button")(_ctx()) - self._apply(btn, props) - _apply_layout(btn, props) - return btn - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, btn: Any, props: Dict[str, Any]) -> None: - if "title" in props: - btn.setText(str(props["title"])) - if "font_size" in props and props["font_size"] is not None: - btn.setTextSize(float(props["font_size"])) - if "color" in props and props["color"] is not None: - btn.setTextColor(parse_color_int(props["color"])) - if "background_color" in props and props["background_color"] is not None: - btn.setBackgroundColor(parse_color_int(props["background_color"])) - if "enabled" in props: - btn.setEnabled(bool(props["enabled"])) - if "on_click" in props: - cb = props["on_click"] - if cb is not None: - - class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback: Callable[[], None]) -> None: - super().__init__() - self.callback = callback - - def onClick(self, view: Any) -> None: - self.callback() - - btn.setOnClickListener(ClickProxy(cb)) - else: - btn.setOnClickListener(None) - - # ---- Column (vertical LinearLayout) --------------------------------- - class AndroidColumnHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ll = jclass("android.widget.LinearLayout")(_ctx()) - ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL) - self._apply(ll, props) - _apply_layout(ll, props) - return ll - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, ll: Any, props: Dict[str, Any]) -> None: - Gravity = jclass("android.view.Gravity") - if "spacing" in props and props["spacing"]: - px = _dp(float(props["spacing"])) - GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") - d = GradientDrawable() - d.setColor(0x00000000) - d.setSize(1, px) - ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) - ll.setDividerDrawable(d) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - gravity = 0 - ai = props.get("align_items") or props.get("alignment") - if ai: - cross_map = { - "stretch": Gravity.FILL_HORIZONTAL, - "fill": Gravity.FILL_HORIZONTAL, - "flex_start": Gravity.START, - "leading": Gravity.START, - "start": Gravity.START, - "center": Gravity.CENTER_HORIZONTAL, - "flex_end": Gravity.END, - "trailing": Gravity.END, - "end": Gravity.END, - } - gravity |= cross_map.get(ai, 0) - jc = props.get("justify_content") - if jc: - main_map = { - "flex_start": Gravity.TOP, - "center": Gravity.CENTER_VERTICAL, - "flex_end": Gravity.BOTTOM, - } - gravity |= main_map.get(jc, 0) - if gravity: - ll.setGravity(gravity) - if "background_color" in props and props["background_color"] is not None: - ll.setBackgroundColor(parse_color_int(props["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - # ---- Row (horizontal LinearLayout) ---------------------------------- - class AndroidRowHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ll = jclass("android.widget.LinearLayout")(_ctx()) - ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL) - self._apply(ll, props) - _apply_layout(ll, props) - return ll - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, ll: Any, props: Dict[str, Any]) -> None: - Gravity = jclass("android.view.Gravity") - if "spacing" in props and props["spacing"]: - px = _dp(float(props["spacing"])) - GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") - d = GradientDrawable() - d.setColor(0x00000000) - d.setSize(px, 1) - ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE) - ll.setDividerDrawable(d) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - gravity = 0 - ai = props.get("align_items") or props.get("alignment") - if ai: - cross_map = { - "stretch": Gravity.FILL_VERTICAL, - "fill": Gravity.FILL_VERTICAL, - "flex_start": Gravity.TOP, - "top": Gravity.TOP, - "center": Gravity.CENTER_VERTICAL, - "flex_end": Gravity.BOTTOM, - "bottom": Gravity.BOTTOM, - } - gravity |= cross_map.get(ai, 0) - jc = props.get("justify_content") - if jc: - main_map = { - "flex_start": Gravity.START, - "center": Gravity.CENTER_HORIZONTAL, - "flex_end": Gravity.END, - } - gravity |= main_map.get(jc, 0) - if gravity: - ll.setGravity(gravity) - if "background_color" in props and props["background_color"] is not None: - ll.setBackgroundColor(parse_color_int(props["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - # ---- ScrollView ----------------------------------------------------- - class AndroidScrollViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = jclass("android.widget.ScrollView")(_ctx()) - if "background_color" in props and props["background_color"] is not None: - sv.setBackgroundColor(parse_color_int(props["background_color"])) - _apply_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor(parse_color_int(changed["background_color"])) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - # ---- TextInput (EditText) with on_change ---------------------------- - class AndroidTextInputHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - et = jclass("android.widget.EditText")(_ctx()) - self._apply(et, props) - _apply_layout(et, props) - return et - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, et: Any, props: Dict[str, Any]) -> None: - if "value" in props: - et.setText(str(props["value"])) - if "placeholder" in props: - et.setHint(str(props["placeholder"])) - if "font_size" in props and props["font_size"] is not None: - et.setTextSize(float(props["font_size"])) - if "color" in props and props["color"] is not None: - et.setTextColor(parse_color_int(props["color"])) - if "background_color" in props and props["background_color"] is not None: - et.setBackgroundColor(parse_color_int(props["background_color"])) - if "secure" in props and props["secure"]: - InputType = jclass("android.text.InputType") - et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) - if "on_change" in props: - cb = props["on_change"] - if cb is not None: - TextWatcher = jclass("android.text.TextWatcher") - - class ChangeProxy(dynamic_proxy(TextWatcher)): - def __init__(self, callback: Callable[[str], None]) -> None: - super().__init__() - self.callback = callback - - def afterTextChanged(self, s: Any) -> None: - self.callback(str(s)) - - def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None: - pass - - def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None: - pass - - et.addTextChangedListener(ChangeProxy(cb)) - - # ---- Image (with URL loading) --------------------------------------- - class AndroidImageHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - iv = jclass("android.widget.ImageView")(_ctx()) - self._apply(iv, props) - _apply_layout(iv, props) - return iv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def _apply(self, iv: Any, props: Dict[str, Any]) -> None: - if "background_color" in props and props["background_color"] is not None: - iv.setBackgroundColor(parse_color_int(props["background_color"])) - if "source" in props and props["source"]: - self._load_source(iv, props["source"]) - if "scale_type" in props and props["scale_type"]: - ScaleType = jclass("android.widget.ImageView$ScaleType") - mapping = { - "cover": ScaleType.CENTER_CROP, - "contain": ScaleType.FIT_CENTER, - "stretch": ScaleType.FIT_XY, - "center": ScaleType.CENTER, - } - st = mapping.get(props["scale_type"]) - if st: - iv.setScaleType(st) - - def _load_source(self, iv: Any, source: str) -> None: - try: - if source.startswith(("http://", "https://")): - Thread = jclass("java.lang.Thread") - Runnable = jclass("java.lang.Runnable") - URL = jclass("java.net.URL") - BitmapFactory = jclass("android.graphics.BitmapFactory") - Handler = jclass("android.os.Handler") - Looper = jclass("android.os.Looper") - handler = Handler(Looper.getMainLooper()) - - class LoadTask(dynamic_proxy(Runnable)): - def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None: - super().__init__() - self.image_view = image_view - self.url_str = url_str - self.main_handler = main_handler - - def run(self) -> None: - try: - url = URL(self.url_str) - stream = url.openStream() - bitmap = BitmapFactory.decodeStream(stream) - stream.close() - - class SetImage(dynamic_proxy(Runnable)): - def __init__(self, view: Any, bmp: Any) -> None: - super().__init__() - self.view = view - self.bmp = bmp - - def run(self) -> None: - self.view.setImageBitmap(self.bmp) - - self.main_handler.post(SetImage(self.image_view, bitmap)) - except Exception: - pass - - Thread(LoadTask(iv, source, handler)).start() - else: - ctx = _ctx() - res = ctx.getResources() - pkg = ctx.getPackageName() - res_name = source.rsplit(".", 1)[0] if "." in source else source - res_id = res.getIdentifier(res_name, "drawable", pkg) - if res_id != 0: - iv.setImageResource(res_id) - except Exception: - pass - - # ---- Switch (with on_change) ---------------------------------------- - class AndroidSwitchHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sw = jclass("android.widget.Switch")(_ctx()) - self._apply(sw, props) - _apply_layout(sw, props) - return sw - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sw: Any, props: Dict[str, Any]) -> None: - if "value" in props: - sw.setChecked(bool(props["value"])) - if "on_change" in props and props["on_change"] is not None: - cb = props["on_change"] - - class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): - def __init__(self, callback: Callable[[bool], None]) -> None: - super().__init__() - self.callback = callback - - def onCheckedChanged(self, button: Any, checked: bool) -> None: - self.callback(checked) - - sw.setOnCheckedChangeListener(CheckedProxy(cb)) - - # ---- ProgressBar ---------------------------------------------------- - class AndroidProgressBarHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - style = jclass("android.R$attr").progressBarStyleHorizontal - pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) - pb.setMax(1000) - self._apply(pb, props) - _apply_layout(pb, props) - return pb - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, pb: Any, props: Dict[str, Any]) -> None: - if "value" in props: - pb.setProgress(int(float(props["value"]) * 1000)) - - # ---- ActivityIndicator (circular ProgressBar) ----------------------- - class AndroidActivityIndicatorHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - pb = jclass("android.widget.ProgressBar")(_ctx()) - if not props.get("animating", True): - pb.setVisibility(jclass("android.view.View").GONE) - _apply_layout(pb, props) - return pb - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - View = jclass("android.view.View") - if "animating" in changed: - native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) - - # ---- WebView -------------------------------------------------------- - class AndroidWebViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - wv = jclass("android.webkit.WebView")(_ctx()) - if "url" in props and props["url"]: - wv.loadUrl(str(props["url"])) - _apply_layout(wv, props) - return wv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "url" in changed and changed["url"]: - native_view.loadUrl(str(changed["url"])) - - # ---- Spacer --------------------------------------------------------- - class AndroidSpacerHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = jclass("android.view.View")(_ctx()) - if "size" in props and props["size"] is not None: - px = _dp(float(props["size"])) - lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) - v.setLayoutParams(lp) - if "flex" in props and props["flex"] is not None: - lp = v.getLayoutParams() - if lp is None: - lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0) - lp.weight = float(props["flex"]) - v.setLayoutParams(lp) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "size" in changed and changed["size"] is not None: - px = _dp(float(changed["size"])) - lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) - native_view.setLayoutParams(lp) - - # ---- View (generic container FrameLayout) --------------------------- - class AndroidViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - fl = jclass("android.widget.FrameLayout")(_ctx()) - if "background_color" in props and props["background_color"] is not None: - fl.setBackgroundColor(parse_color_int(props["background_color"])) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - _apply_layout(fl, props) - return fl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor(parse_color_int(changed["background_color"])) - if "padding" in changed: - left, top, right, bottom = _resolve_padding(changed["padding"]) - native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - if changed.keys() & _LAYOUT_KEYS: - _apply_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.addView(child, index) - - # ---- SafeAreaView (FrameLayout with fitsSystemWindows) --------------- - class AndroidSafeAreaViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - fl = jclass("android.widget.FrameLayout")(_ctx()) - fl.setFitsSystemWindows(True) - if "background_color" in props and props["background_color"] is not None: - fl.setBackgroundColor(parse_color_int(props["background_color"])) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) - return fl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor(parse_color_int(changed["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - # ---- Modal (AlertDialog) ------------------------------------------- - class AndroidModalHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - placeholder = jclass("android.view.View")(_ctx()) - placeholder.setVisibility(jclass("android.view.View").GONE) - return placeholder - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - pass - - def add_child(self, parent: Any, child: Any) -> None: - pass - - # ---- Slider (SeekBar) ----------------------------------------------- - class AndroidSliderHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sb = jclass("android.widget.SeekBar")(_ctx()) - sb.setMax(1000) - self._apply(sb, props) - _apply_layout(sb, props) - return sb - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sb: Any, props: Dict[str, Any]) -> None: - min_val = float(props.get("min_value", 0)) - max_val = float(props.get("max_value", 1)) - rng = max_val - min_val if max_val != min_val else 1 - if "value" in props: - normalized = (float(props["value"]) - min_val) / rng - sb.setProgress(int(normalized * 1000)) - if "on_change" in props and props["on_change"] is not None: - cb = props["on_change"] - - class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)): - def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None: - super().__init__() - self.callback = callback - self.mn = mn - self.rn = rn - - def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None: - if fromUser: - self.callback(self.mn + (progress / 1000.0) * self.rn) - - def onStartTrackingTouch(self, seekBar: Any) -> None: - pass - - def onStopTrackingTouch(self, seekBar: Any) -> None: - pass - - sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) - - # ---- Pressable (FrameLayout with click listener) -------------------- - class AndroidPressableHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - fl = jclass("android.widget.FrameLayout")(_ctx()) - fl.setClickable(True) - self._apply(fl, props) - return fl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, fl: Any, props: Dict[str, Any]) -> None: - if "on_press" in props and props["on_press"] is not None: - cb = props["on_press"] - - class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): - def __init__(self, callback: Callable[[], None]) -> None: - super().__init__() - self.callback = callback - - def onClick(self, view: Any) -> None: - self.callback() - - fl.setOnClickListener(PressProxy(cb)) - if "on_long_press" in props and props["on_long_press"] is not None: - cb = props["on_long_press"] - - class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)): - def __init__(self, callback: Callable[[], None]) -> None: - super().__init__() - self.callback = callback - - def onLongClick(self, view: Any) -> bool: - self.callback() - return True - - fl.setOnLongClickListener(LongPressProxy(cb)) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addView(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeView(child) - - registry.register("Text", AndroidTextHandler()) - registry.register("Button", AndroidButtonHandler()) - registry.register("Column", AndroidColumnHandler()) - registry.register("Row", AndroidRowHandler()) - registry.register("ScrollView", AndroidScrollViewHandler()) - registry.register("TextInput", AndroidTextInputHandler()) - registry.register("Image", AndroidImageHandler()) - registry.register("Switch", AndroidSwitchHandler()) - registry.register("ProgressBar", AndroidProgressBarHandler()) - registry.register("ActivityIndicator", AndroidActivityIndicatorHandler()) - registry.register("WebView", AndroidWebViewHandler()) - registry.register("Spacer", AndroidSpacerHandler()) - registry.register("View", AndroidViewHandler()) - registry.register("SafeAreaView", AndroidSafeAreaViewHandler()) - registry.register("Modal", AndroidModalHandler()) - registry.register("Slider", AndroidSliderHandler()) - registry.register("Pressable", AndroidPressableHandler()) - - -def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901 - from rubicon.objc import SEL, ObjCClass, objc_method - - NSObject = ObjCClass("NSObject") - UIColor = ObjCClass("UIColor") - UIFont = ObjCClass("UIFont") - - def _uicolor(color: Any) -> Any: - argb = parse_color_int(color) - if argb < 0: - argb += 0x100000000 - a = ((argb >> 24) & 0xFF) / 255.0 - r = ((argb >> 16) & 0xFF) / 255.0 - g = ((argb >> 8) & 0xFF) / 255.0 - b = (argb & 0xFF) / 255.0 - return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) - - def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: - """Apply common layout constraints to an iOS view.""" - if "width" in props and props["width"] is not None: - try: - for c in list(view.constraints or []): - if c.firstAttribute == 7: # NSLayoutAttributeWidth - c.setActive_(False) - view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True) - except Exception: - pass - if "height" in props and props["height"] is not None: - try: - for c in list(view.constraints or []): - if c.firstAttribute == 8: # NSLayoutAttributeHeight - c.setActive_(False) - view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) - except Exception: - pass - - # ---- Text ----------------------------------------------------------- - class IOSTextHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - label = ObjCClass("UILabel").alloc().init() - self._apply(label, props) - _apply_ios_layout(label, props) - return label - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, label: Any, props: Dict[str, Any]) -> None: - if "text" in props: - label.setText_(str(props["text"])) - if "font_size" in props and props["font_size"] is not None: - if props.get("bold"): - label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) - else: - label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) - elif "bold" in props and props["bold"]: - size = label.font().pointSize() if label.font() else 17.0 - label.setFont_(UIFont.boldSystemFontOfSize_(size)) - if "color" in props and props["color"] is not None: - label.setTextColor_(_uicolor(props["color"])) - if "background_color" in props and props["background_color"] is not None: - label.setBackgroundColor_(_uicolor(props["background_color"])) - if "max_lines" in props and props["max_lines"] is not None: - label.setNumberOfLines_(int(props["max_lines"])) - if "text_align" in props: - mapping = {"left": 0, "center": 1, "right": 2} - label.setTextAlignment_(mapping.get(props["text_align"], 0)) - - # ---- Button --------------------------------------------------------- - - _pn_btn_handler_map: dict = {} - - class _PNButtonTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[], None]] = None - - @objc_method - def onTap_(self, sender: object) -> None: - if self._callback is not None: - self._callback() - - _pn_retained_views: list = [] - - class IOSButtonHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - btn = ObjCClass("UIButton").alloc().init() - btn.retain() - _pn_retained_views.append(btn) - _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) - btn.setTitleColor_forState_(_ios_blue, 0) - self._apply(btn, props) - _apply_ios_layout(btn, props) - return btn - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, btn: Any, props: Dict[str, Any]) -> None: - if "title" in props: - btn.setTitle_forState_(str(props["title"]), 0) - if "font_size" in props and props["font_size"] is not None: - btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) - if "background_color" in props and props["background_color"] is not None: - btn.setBackgroundColor_(_uicolor(props["background_color"])) - if "color" not in props: - _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) - btn.setTitleColor_forState_(_white, 0) - if "color" in props and props["color"] is not None: - btn.setTitleColor_forState_(_uicolor(props["color"]), 0) - if "enabled" in props: - btn.setEnabled_(bool(props["enabled"])) - if "on_click" in props: - existing = _pn_btn_handler_map.get(id(btn)) - if existing is not None: - existing._callback = props["on_click"] - else: - handler = _PNButtonTarget.new() - handler._callback = props["on_click"] - _pn_btn_handler_map[id(btn)] = handler - btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) - - # ---- Column (vertical UIStackView) ---------------------------------- - class IOSColumnHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) - sv.setAxis_(1) # vertical - self._apply(sv, props) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, sv: Any, props: Dict[str, Any]) -> None: - if "spacing" in props and props["spacing"]: - sv.setSpacing_(float(props["spacing"])) - ai = props.get("align_items") or props.get("alignment") - if ai: - alignment_map = { - "stretch": 0, - "fill": 0, - "flex_start": 1, - "leading": 1, - "center": 3, - "flex_end": 4, - "trailing": 4, - } - sv.setAlignment_(alignment_map.get(ai, 0)) - jc = props.get("justify_content") - if jc: - distribution_map = { - "flex_start": 0, - "center": 0, - "flex_end": 0, - "space_between": 3, - "space_around": 4, - "space_evenly": 4, - } - sv.setDistribution_(distribution_map.get(jc, 0)) - if "background_color" in props and props["background_color"] is not None: - sv.setBackgroundColor_(_uicolor(props["background_color"])) - if "padding" in props: - left, top, right, bottom = _resolve_padding(props["padding"]) - sv.setLayoutMarginsRelativeArrangement_(True) - try: - sv.setDirectionalLayoutMargins_((top, left, bottom, right)) - except Exception: - sv.setLayoutMargins_((top, left, bottom, right)) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addArrangedSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeArrangedSubview_(child) - child.removeFromSuperview() - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.insertArrangedSubview_atIndex_(child, index) - - # ---- Row (horizontal UIStackView) ----------------------------------- - class IOSRowHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) - sv.setAxis_(0) # horizontal - self._apply(sv, props) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, sv: Any, props: Dict[str, Any]) -> None: - if "spacing" in props and props["spacing"]: - sv.setSpacing_(float(props["spacing"])) - ai = props.get("align_items") or props.get("alignment") - if ai: - alignment_map = { - "stretch": 0, - "fill": 0, - "flex_start": 1, - "top": 1, - "center": 3, - "flex_end": 4, - "bottom": 4, - } - sv.setAlignment_(alignment_map.get(ai, 0)) - jc = props.get("justify_content") - if jc: - distribution_map = { - "flex_start": 0, - "center": 0, - "flex_end": 0, - "space_between": 3, - "space_around": 4, - "space_evenly": 4, - } - sv.setDistribution_(distribution_map.get(jc, 0)) - if "background_color" in props and props["background_color"] is not None: - sv.setBackgroundColor_(_uicolor(props["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addArrangedSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - parent.removeArrangedSubview_(child) - child.removeFromSuperview() - - def insert_child(self, parent: Any, child: Any, index: int) -> None: - parent.insertArrangedSubview_atIndex_(child, index) - - # ---- ScrollView ----------------------------------------------------- - class IOSScrollViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sv = ObjCClass("UIScrollView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - sv.setBackgroundColor_(_uicolor(props["background_color"])) - _apply_ios_layout(sv, props) - return sv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - child.setTranslatesAutoresizingMaskIntoConstraints_(False) - parent.addSubview_(child) - content_guide = parent.contentLayoutGuide - frame_guide = parent.frameLayoutGuide - child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) - child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) - child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) - child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) - child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - # ---- TextInput (UITextField with on_change) ------------------------- - _pn_tf_handler_map: dict = {} - - class _PNTextFieldTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[str], None]] = None - - @objc_method - def onEdit_(self, sender: object) -> None: - if self._callback is not None: - try: - text = str(sender.text) if sender and hasattr(sender, "text") else "" - self._callback(text) - except Exception: - pass - - class IOSTextInputHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - tf = ObjCClass("UITextField").alloc().init() - tf.setBorderStyle_(2) # RoundedRect - self._apply(tf, props) - _apply_ios_layout(tf, props) - return tf - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, tf: Any, props: Dict[str, Any]) -> None: - if "value" in props: - tf.setText_(str(props["value"])) - if "placeholder" in props: - tf.setPlaceholder_(str(props["placeholder"])) - if "font_size" in props and props["font_size"] is not None: - tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) - if "color" in props and props["color"] is not None: - tf.setTextColor_(_uicolor(props["color"])) - if "background_color" in props and props["background_color"] is not None: - tf.setBackgroundColor_(_uicolor(props["background_color"])) - if "secure" in props and props["secure"]: - tf.setSecureTextEntry_(True) - if "on_change" in props: - existing = _pn_tf_handler_map.get(id(tf)) - if existing is not None: - existing._callback = props["on_change"] - else: - handler = _PNTextFieldTarget.new() - handler._callback = props["on_change"] - _pn_tf_handler_map[id(tf)] = handler - tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17) - - # ---- Image (with URL loading) --------------------------------------- - class IOSImageHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - iv = ObjCClass("UIImageView").alloc().init() - self._apply(iv, props) - _apply_ios_layout(iv, props) - return iv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def _apply(self, iv: Any, props: Dict[str, Any]) -> None: - if "background_color" in props and props["background_color"] is not None: - iv.setBackgroundColor_(_uicolor(props["background_color"])) - if "source" in props and props["source"]: - self._load_source(iv, props["source"]) - if "scale_type" in props and props["scale_type"]: - mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4} - iv.setContentMode_(mapping.get(props["scale_type"], 1)) - - def _load_source(self, iv: Any, source: str) -> None: - try: - if source.startswith(("http://", "https://")): - NSURL = ObjCClass("NSURL") - NSData = ObjCClass("NSData") - UIImage = ObjCClass("UIImage") - url = NSURL.URLWithString_(source) - data = NSData.dataWithContentsOfURL_(url) - if data: - image = UIImage.imageWithData_(data) - if image: - iv.setImage_(image) - else: - UIImage = ObjCClass("UIImage") - image = UIImage.imageNamed_(source) - if image: - iv.setImage_(image) - except Exception: - pass - - # ---- Switch (with on_change) ---------------------------------------- - _pn_switch_handler_map: dict = {} - - class _PNSwitchTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[bool], None]] = None - - @objc_method - def onToggle_(self, sender: object) -> None: - if self._callback is not None: - try: - self._callback(bool(sender.isOn())) - except Exception: - pass - - class IOSSwitchHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sw = ObjCClass("UISwitch").alloc().init() - self._apply(sw, props) - return sw - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sw: Any, props: Dict[str, Any]) -> None: - if "value" in props: - sw.setOn_animated_(bool(props["value"]), False) - if "on_change" in props: - existing = _pn_switch_handler_map.get(id(sw)) - if existing is not None: - existing._callback = props["on_change"] - else: - handler = _PNSwitchTarget.new() - handler._callback = props["on_change"] - _pn_switch_handler_map[id(sw)] = handler - sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12) - - # ---- ProgressBar (UIProgressView) ----------------------------------- - class IOSProgressBarHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - pv = ObjCClass("UIProgressView").alloc().init() - if "value" in props: - pv.setProgress_(float(props["value"])) - _apply_ios_layout(pv, props) - return pv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "value" in changed: - native_view.setProgress_(float(changed["value"])) - - # ---- ActivityIndicator ---------------------------------------------- - class IOSActivityIndicatorHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - ai = ObjCClass("UIActivityIndicatorView").alloc().init() - if props.get("animating", True): - ai.startAnimating() - return ai - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "animating" in changed: - if changed["animating"]: - native_view.startAnimating() - else: - native_view.stopAnimating() - - # ---- WebView (WKWebView) -------------------------------------------- - class IOSWebViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - wv = ObjCClass("WKWebView").alloc().init() - if "url" in props and props["url"]: - NSURL = ObjCClass("NSURL") - NSURLRequest = ObjCClass("NSURLRequest") - url_obj = NSURL.URLWithString_(str(props["url"])) - wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) - _apply_ios_layout(wv, props) - return wv - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "url" in changed and changed["url"]: - NSURL = ObjCClass("NSURL") - NSURLRequest = ObjCClass("NSURLRequest") - url_obj = NSURL.URLWithString_(str(changed["url"])) - native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) - - # ---- Spacer --------------------------------------------------------- - class IOSSpacerHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - if "size" in props and props["size"] is not None: - size = float(props["size"]) - v.setFrame_(((0, 0), (size, size))) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "size" in changed and changed["size"] is not None: - size = float(changed["size"]) - native_view.setFrame_(((0, 0), (size, size))) - - # ---- View (generic UIView) ----------------------------------------- - class IOSViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - v.setBackgroundColor_(_uicolor(props["background_color"])) - _apply_ios_layout(v, props) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) - if changed.keys() & _LAYOUT_KEYS: - _apply_ios_layout(native_view, changed) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - # ---- SafeAreaView --------------------------------------------------- - class IOSSafeAreaViewHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - if "background_color" in props and props["background_color"] is not None: - v.setBackgroundColor_(_uicolor(props["background_color"])) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - if "background_color" in changed and changed["background_color"] is not None: - native_view.setBackgroundColor_(_uicolor(changed["background_color"])) - - def add_child(self, parent: Any, child: Any) -> None: - parent.addSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - # ---- Modal ---------------------------------------------------------- - class IOSModalHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - v.setHidden_(True) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - pass - - # ---- Slider (UISlider) ---------------------------------------------- - _pn_slider_handler_map: dict = {} - - class _PNSliderTarget(NSObject): # type: ignore[valid-type] - _callback: Optional[Callable[[float], None]] = None - - @objc_method - def onSlide_(self, sender: object) -> None: - if self._callback is not None: - try: - self._callback(float(sender.value)) - except Exception: - pass - - class IOSSliderHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - sl = ObjCClass("UISlider").alloc().init() - self._apply(sl, props) - _apply_ios_layout(sl, props) - return sl - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - self._apply(native_view, changed) - - def _apply(self, sl: Any, props: Dict[str, Any]) -> None: - if "min_value" in props: - sl.setMinimumValue_(float(props["min_value"])) - if "max_value" in props: - sl.setMaximumValue_(float(props["max_value"])) - if "value" in props: - sl.setValue_(float(props["value"])) - if "on_change" in props: - existing = _pn_slider_handler_map.get(id(sl)) - if existing is not None: - existing._callback = props["on_change"] - else: - handler = _PNSliderTarget.new() - handler._callback = props["on_change"] - _pn_slider_handler_map[id(sl)] = handler - sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) - - # ---- Pressable (UIView with tap gesture) ---------------------------- - class IOSPressableHandler(ViewHandler): - def create(self, props: Dict[str, Any]) -> Any: - v = ObjCClass("UIView").alloc().init() - v.setUserInteractionEnabled_(True) - return v - - def update(self, native_view: Any, changed: Dict[str, Any]) -> None: - pass - - def add_child(self, parent: Any, child: Any) -> None: - parent.addSubview_(child) - - def remove_child(self, parent: Any, child: Any) -> None: - child.removeFromSuperview() - - registry.register("Text", IOSTextHandler()) - registry.register("Button", IOSButtonHandler()) - registry.register("Column", IOSColumnHandler()) - registry.register("Row", IOSRowHandler()) - registry.register("ScrollView", IOSScrollViewHandler()) - registry.register("TextInput", IOSTextInputHandler()) - registry.register("Image", IOSImageHandler()) - registry.register("Switch", IOSSwitchHandler()) - registry.register("ProgressBar", IOSProgressBarHandler()) - registry.register("ActivityIndicator", IOSActivityIndicatorHandler()) - registry.register("WebView", IOSWebViewHandler()) - registry.register("Spacer", IOSSpacerHandler()) - registry.register("View", IOSViewHandler()) - registry.register("SafeAreaView", IOSSafeAreaViewHandler()) - registry.register("Modal", IOSModalHandler()) - registry.register("Slider", IOSSliderHandler()) - registry.register("Pressable", IOSPressableHandler()) - - -# ====================================================================== -# Factory -# ====================================================================== - -_registry: Optional[NativeViewRegistry] = None - - -def get_registry() -> NativeViewRegistry: - """Return the singleton registry, lazily creating platform handlers.""" - global _registry - if _registry is not None: - return _registry - _registry = NativeViewRegistry() - if IS_ANDROID: - _register_android_handlers(_registry) - else: - _register_ios_handlers(_registry) - return _registry - - -def set_registry(registry: NativeViewRegistry) -> None: - """Inject a custom or mock registry (primarily for testing).""" - global _registry - _registry = registry diff --git a/src/pythonnative/native_views/__init__.py b/src/pythonnative/native_views/__init__.py new file mode 100644 index 0000000..2630f36 --- /dev/null +++ b/src/pythonnative/native_views/__init__.py @@ -0,0 +1,87 @@ +"""Platform-specific native view creation and update logic. + +This package provides the :class:`NativeViewRegistry` that maps element type +names to platform-specific :class:`~.base.ViewHandler` implementations. + +Platform handlers live in dedicated submodules: + +- :mod:`~.base` — shared :class:`~.base.ViewHandler` protocol and utilities +- :mod:`~.android` — Android handlers (Chaquopy / Java bridge) +- :mod:`~.ios` — iOS handlers (rubicon-objc) + +All platform-branching is handled at registration time via lazy imports, +so the package can be imported on any platform for testing. +""" + +from typing import Any, Dict, Optional + +from .base import ViewHandler + + +class NativeViewRegistry: + """Maps element type names to platform-specific :class:`ViewHandler` instances.""" + + def __init__(self) -> None: + self._handlers: Dict[str, ViewHandler] = {} + + def register(self, type_name: str, handler: ViewHandler) -> None: + self._handlers[type_name] = handler + + def create_view(self, type_name: str, props: Dict[str, Any]) -> Any: + handler = self._handlers.get(type_name) + if handler is None: + raise ValueError(f"Unknown element type: {type_name!r}") + return handler.create(props) + + def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None: + handler = self._handlers.get(type_name) + if handler is not None: + handler.update(native_view, changed_props) + + def add_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.add_child(parent, child) + + def remove_child(self, parent: Any, child: Any, parent_type: str) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.remove_child(parent, child) + + def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None: + handler = self._handlers.get(parent_type) + if handler is not None: + handler.insert_child(parent, child, index) + + +# ====================================================================== +# Singleton registry +# ====================================================================== + +_registry: Optional[NativeViewRegistry] = None + + +def get_registry() -> NativeViewRegistry: + """Return the singleton registry, lazily creating platform handlers.""" + global _registry + if _registry is not None: + return _registry + _registry = NativeViewRegistry() + + from ..utils import IS_ANDROID + + if IS_ANDROID: + from .android import register_handlers + + register_handlers(_registry) + else: + from .ios import register_handlers + + register_handlers(_registry) + return _registry + + +def set_registry(registry: NativeViewRegistry) -> None: + """Inject a custom or mock registry (primarily for testing).""" + global _registry + _registry = registry diff --git a/src/pythonnative/native_views/android.py b/src/pythonnative/native_views/android.py new file mode 100644 index 0000000..d36ae81 --- /dev/null +++ b/src/pythonnative/native_views/android.py @@ -0,0 +1,832 @@ +"""Android native view handlers (Chaquopy / Java bridge). + +Each handler class maps a PythonNative element type to an Android widget, +implementing view creation, property updates, and child management. + +This module is only imported on Android at runtime; desktop tests inject +a mock registry via :func:`~.set_registry` and never trigger this import. +""" + +from typing import Any, Callable, Dict + +from java import dynamic_proxy, jclass + +from ..utils import get_android_context +from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def _ctx() -> Any: + return get_android_context() + + +def _density() -> float: + return float(_ctx().getResources().getDisplayMetrics().density) + + +def _dp(value: float) -> int: + return int(value * _density()) + + +def _apply_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout properties (child-level flex props) to an Android view.""" + lp = view.getLayoutParams() + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + Gravity = jclass("android.view.Gravity") + needs_set = False + + if lp is None: + lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT) + needs_set = True + + if "width" in props and props["width"] is not None: + lp.width = _dp(float(props["width"])) + needs_set = True + if "height" in props and props["height"] is not None: + lp.height = _dp(float(props["height"])) + needs_set = True + + flex = props.get("flex") + flex_grow = props.get("flex_grow") + weight = None + if flex is not None: + weight = float(flex) + elif flex_grow is not None: + weight = float(flex_grow) + if weight is not None: + try: + lp.weight = weight + needs_set = True + except Exception: + pass + + if "margin" in props and props["margin"] is not None: + left, top, right, bottom = resolve_padding(props["margin"]) + try: + lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom)) + needs_set = True + except Exception: + pass + + if "align_self" in props and props["align_self"] is not None: + align_map = { + "flex_start": Gravity.START | Gravity.TOP, + "leading": Gravity.START | Gravity.TOP, + "center": Gravity.CENTER, + "flex_end": Gravity.END | Gravity.BOTTOM, + "trailing": Gravity.END | Gravity.BOTTOM, + "stretch": Gravity.FILL, + } + g = align_map.get(props["align_self"]) + if g is not None: + lp.gravity = g + needs_set = True + + if needs_set: + view.setLayoutParams(lp) + + if "min_width" in props and props["min_width"] is not None: + view.setMinimumWidth(_dp(float(props["min_width"]))) + if "min_height" in props and props["min_height"] is not None: + view.setMinimumHeight(_dp(float(props["min_height"]))) + + +def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None: + """Apply visual properties shared across many handlers.""" + if "background_color" in props and props["background_color"] is not None: + view.setBackgroundColor(parse_color_int(props["background_color"])) + if "overflow" in props: + clip = props["overflow"] == "hidden" + try: + view.setClipChildren(clip) + view.setClipToPadding(clip) + except Exception: + pass + + +def _apply_flex_container(container: Any, props: Dict[str, Any]) -> None: + """Apply flex container properties to a LinearLayout. + + Handles spacing, padding, alignment, justification, background, and overflow. + """ + LinearLayout = jclass("android.widget.LinearLayout") + Gravity = jclass("android.view.Gravity") + + if "flex_direction" in props: + vertical = is_vertical(props["flex_direction"]) + container.setOrientation(LinearLayout.VERTICAL if vertical else LinearLayout.HORIZONTAL) + + direction = props.get("flex_direction", "column") + vertical = is_vertical(direction) + + if "spacing" in props and props["spacing"]: + px = _dp(float(props["spacing"])) + GradientDrawable = jclass("android.graphics.drawable.GradientDrawable") + d = GradientDrawable() + d.setColor(0x00000000) + d.setSize(1 if vertical else px, px if vertical else 1) + container.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE) + container.setDividerDrawable(d) + + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + container.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + + gravity = 0 + ai = props.get("align_items") or props.get("alignment") + if ai: + if vertical: + cross_map = { + "stretch": Gravity.FILL_HORIZONTAL, + "fill": Gravity.FILL_HORIZONTAL, + "flex_start": Gravity.START, + "leading": Gravity.START, + "start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, + "trailing": Gravity.END, + "end": Gravity.END, + } + else: + cross_map = { + "stretch": Gravity.FILL_VERTICAL, + "fill": Gravity.FILL_VERTICAL, + "flex_start": Gravity.TOP, + "top": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, + "bottom": Gravity.BOTTOM, + } + gravity |= cross_map.get(ai, 0) + + jc = props.get("justify_content") + if jc: + if vertical: + main_map = { + "flex_start": Gravity.TOP, + "center": Gravity.CENTER_VERTICAL, + "flex_end": Gravity.BOTTOM, + } + else: + main_map = { + "flex_start": Gravity.START, + "center": Gravity.CENTER_HORIZONTAL, + "flex_end": Gravity.END, + } + gravity |= main_map.get(jc, 0) + + if gravity: + container.setGravity(gravity) + + _apply_common_visual(container, props) + + +# ====================================================================== +# Flex container handler (shared by Column, Row, View) +# ====================================================================== + + +class FlexContainerHandler(ViewHandler): + """Unified handler for flex layout containers (Column, Row, View). + + All three element types use ``LinearLayout`` with orientation + determined by the ``flex_direction`` prop. + """ + + def create(self, props: Dict[str, Any]) -> Any: + ll = jclass("android.widget.LinearLayout")(_ctx()) + direction = props.get("flex_direction", "column") + LinearLayout = jclass("android.widget.LinearLayout") + ll.setOrientation(LinearLayout.VERTICAL if is_vertical(direction) else LinearLayout.HORIZONTAL) + _apply_flex_container(ll, props) + _apply_layout(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if changed.keys() & CONTAINER_KEYS: + _apply_flex_container(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.addView(child, index) + + +# ====================================================================== +# Leaf handlers +# ====================================================================== + + +class TextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tv = jclass("android.widget.TextView")(_ctx()) + self._apply(tv, props) + _apply_layout(tv, props) + return tv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, tv: Any, props: Dict[str, Any]) -> None: + if "text" in props: + tv.setText(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + tv.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + tv.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tv.setBackgroundColor(parse_color_int(props["background_color"])) + if "bold" in props and props["bold"]: + tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1 + if "max_lines" in props and props["max_lines"] is not None: + tv.setMaxLines(int(props["max_lines"])) + if "text_align" in props: + Gravity = jclass("android.view.Gravity") + mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END} + tv.setGravity(mapping.get(props["text_align"], Gravity.START)) + + +class ButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = jclass("android.widget.Button")(_ctx()) + self._apply(btn, props) + _apply_layout(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setText(str(props["title"])) + if "font_size" in props and props["font_size"] is not None: + btn.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + btn.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor(parse_color_int(props["background_color"])) + if "enabled" in props: + btn.setEnabled(bool(props["enabled"])) + if "on_click" in props: + cb = props["on_click"] + if cb is not None: + + class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(ClickProxy(cb)) + else: + btn.setOnClickListener(None) + + +class ScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = jclass("android.widget.ScrollView")(_ctx()) + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor(parse_color_int(props["background_color"])) + _apply_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +class TextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + et = jclass("android.widget.EditText")(_ctx()) + self._apply(et, props) + _apply_layout(et, props) + return et + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, et: Any, props: Dict[str, Any]) -> None: + if "value" in props: + et.setText(str(props["value"])) + if "placeholder" in props: + et.setHint(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + et.setTextSize(float(props["font_size"])) + if "color" in props and props["color"] is not None: + et.setTextColor(parse_color_int(props["color"])) + if "background_color" in props and props["background_color"] is not None: + et.setBackgroundColor(parse_color_int(props["background_color"])) + if "secure" in props and props["secure"]: + InputType = jclass("android.text.InputType") + et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD) + if "on_change" in props: + cb = props["on_change"] + if cb is not None: + TextWatcher = jclass("android.text.TextWatcher") + + class ChangeProxy(dynamic_proxy(TextWatcher)): + def __init__(self, callback: Callable[[str], None]) -> None: + super().__init__() + self.callback = callback + + def afterTextChanged(self, s: Any) -> None: + self.callback(str(s)) + + def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None: + pass + + def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None: + pass + + et.addTextChangedListener(ChangeProxy(cb)) + + +class ImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = jclass("android.widget.ImageView")(_ctx()) + self._apply(iv, props) + _apply_layout(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_layout(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor(parse_color_int(props["background_color"])) + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + ScaleType = jclass("android.widget.ImageView$ScaleType") + mapping = { + "cover": ScaleType.CENTER_CROP, + "contain": ScaleType.FIT_CENTER, + "stretch": ScaleType.FIT_XY, + "center": ScaleType.CENTER, + } + st = mapping.get(props["scale_type"]) + if st: + iv.setScaleType(st) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + Thread = jclass("java.lang.Thread") + Runnable = jclass("java.lang.Runnable") + URL = jclass("java.net.URL") + BitmapFactory = jclass("android.graphics.BitmapFactory") + Handler = jclass("android.os.Handler") + Looper = jclass("android.os.Looper") + handler = Handler(Looper.getMainLooper()) + + class LoadTask(dynamic_proxy(Runnable)): + def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None: + super().__init__() + self.image_view = image_view + self.url_str = url_str + self.main_handler = main_handler + + def run(self) -> None: + try: + url = URL(self.url_str) + stream = url.openStream() + bitmap = BitmapFactory.decodeStream(stream) + stream.close() + + class SetImage(dynamic_proxy(Runnable)): + def __init__(self, view: Any, bmp: Any) -> None: + super().__init__() + self.view = view + self.bmp = bmp + + def run(self) -> None: + self.view.setImageBitmap(self.bmp) + + self.main_handler.post(SetImage(self.image_view, bitmap)) + except Exception: + pass + + Thread(LoadTask(iv, source, handler)).start() + else: + ctx = _ctx() + res = ctx.getResources() + pkg = ctx.getPackageName() + res_name = source.rsplit(".", 1)[0] if "." in source else source + res_id = res.getIdentifier(res_name, "drawable", pkg) + if res_id != 0: + iv.setImageResource(res_id) + except Exception: + pass + + +class SwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = jclass("android.widget.Switch")(_ctx()) + self._apply(sw, props) + _apply_layout(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setChecked(bool(props["value"])) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)): + def __init__(self, callback: Callable[[bool], None]) -> None: + super().__init__() + self.callback = callback + + def onCheckedChanged(self, button: Any, checked: bool) -> None: + self.callback(checked) + + sw.setOnCheckedChangeListener(CheckedProxy(cb)) + + +class ProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + style = jclass("android.R$attr").progressBarStyleHorizontal + pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style) + pb.setMax(1000) + self._apply(pb, props) + _apply_layout(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, pb: Any, props: Dict[str, Any]) -> None: + if "value" in props: + pb.setProgress(int(float(props["value"]) * 1000)) + + +class ActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pb = jclass("android.widget.ProgressBar")(_ctx()) + if not props.get("animating", True): + pb.setVisibility(jclass("android.view.View").GONE) + _apply_layout(pb, props) + return pb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + View = jclass("android.view.View") + if "animating" in changed: + native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE) + + +class WebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = jclass("android.webkit.WebView")(_ctx()) + if "url" in props and props["url"]: + wv.loadUrl(str(props["url"])) + _apply_layout(wv, props) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + native_view.loadUrl(str(changed["url"])) + + +class SpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = jclass("android.view.View")(_ctx()) + if "size" in props and props["size"] is not None: + px = _dp(float(props["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + v.setLayoutParams(lp) + if "flex" in props and props["flex"] is not None: + lp = v.getLayoutParams() + if lp is None: + lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0) + lp.weight = float(props["flex"]) + v.setLayoutParams(lp) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + px = _dp(float(changed["size"])) + lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px) + native_view.setLayoutParams(lp) + + +class SafeAreaViewHandler(ViewHandler): + """Safe-area container using FrameLayout with ``fitsSystemWindows``.""" + + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setFitsSystemWindows(True) + if "background_color" in props and props["background_color"] is not None: + fl.setBackgroundColor(parse_color_int(props["background_color"])) + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom)) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor(parse_color_int(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +class ModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + placeholder = jclass("android.view.View")(_ctx()) + placeholder.setVisibility(jclass("android.view.View").GONE) + return placeholder + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + pass + + +class SliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sb = jclass("android.widget.SeekBar")(_ctx()) + sb.setMax(1000) + self._apply(sb, props) + _apply_layout(sb, props) + return sb + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sb: Any, props: Dict[str, Any]) -> None: + min_val = float(props.get("min_value", 0)) + max_val = float(props.get("max_value", 1)) + rng = max_val - min_val if max_val != min_val else 1 + if "value" in props: + normalized = (float(props["value"]) - min_val) / rng + sb.setProgress(int(normalized * 1000)) + if "on_change" in props and props["on_change"] is not None: + cb = props["on_change"] + + class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)): + def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None: + super().__init__() + self.callback = callback + self.mn = mn + self.rn = rn + + def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None: + if fromUser: + self.callback(self.mn + (progress / 1000.0) * self.rn) + + def onStartTrackingTouch(self, seekBar: Any) -> None: + pass + + def onStopTrackingTouch(self, seekBar: Any) -> None: + pass + + sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng)) + + +_android_tabbar_state: dict = {"callback": None, "items": []} + + +class TabBarHandler(ViewHandler): + """Native tab bar using ``BottomNavigationView`` from Material Components. + + Falls back to a horizontal ``LinearLayout`` with ``Button`` children + when Material Components is unavailable. + """ + + _is_material: bool = True + + def create(self, props: Dict[str, Any]) -> Any: + try: + bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx()) + bnv.setBackgroundColor(parse_color_int("#FFFFFF")) + ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams") + LayoutParams = jclass("android.widget.LinearLayout$LayoutParams") + lp = LayoutParams(ViewGroupLP.MATCH_PARENT, ViewGroupLP.WRAP_CONTENT) + bnv.setLayoutParams(lp) + self._is_material = True + self._apply_full(bnv, props) + return bnv + except Exception: + self._is_material = False + return self._create_fallback(props) + + def _create_fallback(self, props: Dict[str, Any]) -> Any: + """Horizontal LinearLayout with Button children as a tab-bar fallback.""" + LinearLayout = jclass("android.widget.LinearLayout") + ll = LinearLayout(_ctx()) + ll.setOrientation(LinearLayout.HORIZONTAL) + ll.setBackgroundColor(parse_color_int("#F8F8F8")) + self._apply_fallback(ll, props) + return ll + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if self._is_material: + self._apply_partial(native_view, changed) + else: + self._apply_fallback(native_view, changed) + + def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None: + """Initial creation — all props are present.""" + items = props.get("items", []) + self._set_menu(bnv, items) + self._set_active(bnv, props.get("active_tab"), items) + cb = props.get("on_tab_select") + if cb is not None: + self._set_listener(bnv, cb, items) + + def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None: + """Reconciler update — only changed props are present.""" + prev_items = _android_tabbar_state["items"] + + if "items" in changed: + items = changed["items"] + self._set_menu(bnv, items) + else: + items = prev_items + + if "active_tab" in changed: + self._set_active(bnv, changed["active_tab"], items) + + if "on_tab_select" in changed: + cb = changed["on_tab_select"] + if cb is not None: + self._set_listener(bnv, cb, items) + + def _set_menu(self, bnv: Any, items: list) -> None: + _android_tabbar_state["items"] = items + try: + menu = bnv.getMenu() + menu.clear() + for i, item in enumerate(items): + title = item.get("title", item.get("name", "")) + menu.add(0, i, i, str(title)) + except Exception: + pass + + def _set_active(self, bnv: Any, active: Any, items: list) -> None: + if active and items: + for i, item in enumerate(items): + if item.get("name") == active: + try: + bnv.setSelectedItemId(i) + except Exception: + pass + break + + def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None: + _android_tabbar_state["callback"] = cb + _android_tabbar_state["items"] = items + try: + listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener") + + class _TabSelectProxy(dynamic_proxy(listener_cls)): + def __init__(self, callback: Callable, tab_items: list) -> None: + super().__init__() + self.callback = callback + self.tab_items = tab_items + + def onNavigationItemSelected(self, menu_item: Any) -> bool: + idx = menu_item.getItemId() + if 0 <= idx < len(self.tab_items): + self.callback(self.tab_items[idx].get("name", "")) + return True + + bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items)) + except Exception: + pass + + def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None: + items = props.get("items", []) + active = props.get("active_tab") + cb = props.get("on_tab_select") + if "items" in props: + ll.removeAllViews() + for item in items: + name = item.get("name", "") + title = item.get("title", name) + btn = jclass("android.widget.Button")(_ctx()) + btn.setText(str(title)) + btn.setEnabled(name != active) + if cb is not None: + tab_name = name + + def _make_click(n: str) -> Callable[[], None]: + return lambda: cb(n) + + class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + btn.setOnClickListener(_ClickProxy(_make_click(tab_name))) + ll.addView(btn) + + +class PressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + fl = jclass("android.widget.FrameLayout")(_ctx()) + fl.setClickable(True) + self._apply(fl, props) + return fl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, fl: Any, props: Dict[str, Any]) -> None: + if "on_press" in props and props["on_press"] is not None: + cb = props["on_press"] + + class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onClick(self, view: Any) -> None: + self.callback() + + fl.setOnClickListener(PressProxy(cb)) + if "on_long_press" in props and props["on_long_press"] is not None: + cb = props["on_long_press"] + + class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)): + def __init__(self, callback: Callable[[], None]) -> None: + super().__init__() + self.callback = callback + + def onLongClick(self, view: Any) -> bool: + self.callback() + return True + + fl.setOnLongClickListener(LongPressProxy(cb)) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addView(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeView(child) + + +# ====================================================================== +# Registration +# ====================================================================== + + +def register_handlers(registry: Any) -> None: + """Register all Android view handlers with the given registry.""" + flex = FlexContainerHandler() + registry.register("Text", TextHandler()) + registry.register("Button", ButtonHandler()) + registry.register("Column", flex) + registry.register("Row", flex) + registry.register("View", flex) + registry.register("ScrollView", ScrollViewHandler()) + registry.register("TextInput", TextInputHandler()) + registry.register("Image", ImageHandler()) + registry.register("Switch", SwitchHandler()) + registry.register("ProgressBar", ProgressBarHandler()) + registry.register("ActivityIndicator", ActivityIndicatorHandler()) + registry.register("WebView", WebViewHandler()) + registry.register("Spacer", SpacerHandler()) + registry.register("SafeAreaView", SafeAreaViewHandler()) + registry.register("Modal", ModalHandler()) + registry.register("Slider", SliderHandler()) + registry.register("TabBar", TabBarHandler()) + registry.register("Pressable", PressableHandler()) diff --git a/src/pythonnative/native_views/base.py b/src/pythonnative/native_views/base.py new file mode 100644 index 0000000..e644968 --- /dev/null +++ b/src/pythonnative/native_views/base.py @@ -0,0 +1,150 @@ +"""Shared base classes and utilities for native view handlers. + +Provides the :class:`ViewHandler` abstract base class and common helper +functions used by both Android and iOS platform implementations. +""" + +from typing import Any, Dict, Union + + +class ViewHandler: + """Protocol for creating, updating, and managing children of a native view type.""" + + def create(self, props: Dict[str, Any]) -> Any: + raise NotImplementedError + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + raise NotImplementedError + + def add_child(self, parent: Any, child: Any) -> None: + pass + + def remove_child(self, parent: Any, child: Any) -> None: + pass + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + self.add_child(parent, child) + + +# ====================================================================== +# Color parsing +# ====================================================================== + + +def parse_color_int(color: Union[str, int]) -> int: + """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int. + + Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values + with a high alpha byte (e.g. 0xFF…) must be converted to negative ints. + """ + if isinstance(color, int): + val = color + else: + c = color.strip().lstrip("#") + if len(c) == 6: + c = "FF" + c + val = int(c, 16) + if val > 0x7FFFFFFF: + val -= 0x100000000 + return val + + +# ====================================================================== +# Padding / margin helpers +# ====================================================================== + + +def resolve_padding(padding: Any) -> tuple: + """Normalise various padding representations to ``(left, top, right, bottom)``.""" + if padding is None: + return (0, 0, 0, 0) + if isinstance(padding, (int, float)): + v = int(padding) + return (v, v, v, v) + if isinstance(padding, dict): + h = int(padding.get("horizontal", 0)) + v = int(padding.get("vertical", 0)) + left = int(padding.get("left", h)) + right = int(padding.get("right", h)) + top = int(padding.get("top", v)) + bottom = int(padding.get("bottom", v)) + a = int(padding.get("all", 0)) + if a: + left = left or a + right = right or a + top = top or a + bottom = bottom or a + return (left, top, right, bottom) + return (0, 0, 0, 0) + + +# ====================================================================== +# Flex layout constants +# ====================================================================== + +FLEX_DIRECTION_COLUMN = "column" +FLEX_DIRECTION_ROW = "row" +FLEX_DIRECTION_COLUMN_REVERSE = "column_reverse" +FLEX_DIRECTION_ROW_REVERSE = "row_reverse" + +JUSTIFY_FLEX_START = "flex_start" +JUSTIFY_CENTER = "center" +JUSTIFY_FLEX_END = "flex_end" +JUSTIFY_SPACE_BETWEEN = "space_between" +JUSTIFY_SPACE_AROUND = "space_around" +JUSTIFY_SPACE_EVENLY = "space_evenly" + +ALIGN_STRETCH = "stretch" +ALIGN_FLEX_START = "flex_start" +ALIGN_CENTER = "center" +ALIGN_FLEX_END = "flex_end" + +POSITION_RELATIVE = "relative" +POSITION_ABSOLUTE = "absolute" + +OVERFLOW_VISIBLE = "visible" +OVERFLOW_HIDDEN = "hidden" +OVERFLOW_SCROLL = "scroll" + + +def is_vertical(direction: str) -> bool: + """Return ``True`` if *direction* represents a vertical (column) axis.""" + return direction in (FLEX_DIRECTION_COLUMN, FLEX_DIRECTION_COLUMN_REVERSE) + + +# ====================================================================== +# Layout property keys +# ====================================================================== + +LAYOUT_KEYS = frozenset( + { + "width", + "height", + "flex", + "flex_grow", + "flex_shrink", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + "position", + "top", + "right", + "bottom", + "left", + } +) + +CONTAINER_KEYS = frozenset( + { + "flex_direction", + "justify_content", + "align_items", + "overflow", + "spacing", + "padding", + "background_color", + } +) diff --git a/src/pythonnative/native_views/ios.py b/src/pythonnative/native_views/ios.py new file mode 100644 index 0000000..95fa0b2 --- /dev/null +++ b/src/pythonnative/native_views/ios.py @@ -0,0 +1,777 @@ +"""iOS native view handlers (rubicon-objc). + +Each handler class maps a PythonNative element type to a UIKit widget, +implementing view creation, property updates, and child management. + +This module is only imported on iOS at runtime; desktop tests inject +a mock registry via :func:`~.set_registry` and never trigger this import. +""" + +import ctypes as _ct +from typing import Any, Callable, Dict, Optional + +from rubicon.objc import SEL, ObjCClass, objc_method + +from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding + +NSObject = ObjCClass("NSObject") +UIColor = ObjCClass("UIColor") +UIFont = ObjCClass("UIFont") + + +# ====================================================================== +# Shared helpers +# ====================================================================== + + +def _uicolor(color: Any) -> Any: + """Convert a color value to a ``UIColor`` instance.""" + argb = parse_color_int(color) + if argb < 0: + argb += 0x100000000 + a = ((argb >> 24) & 0xFF) / 255.0 + r = ((argb >> 16) & 0xFF) / 255.0 + g = ((argb >> 8) & 0xFF) / 255.0 + b = (argb & 0xFF) / 255.0 + return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a) + + +def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None: + """Apply common layout constraints to an iOS view.""" + if "width" in props and props["width"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 7: # NSLayoutAttributeWidth + c.setActive_(False) + view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True) + except Exception: + pass + if "height" in props and props["height"] is not None: + try: + for c in list(view.constraints or []): + if c.firstAttribute == 8: # NSLayoutAttributeHeight + c.setActive_(False) + view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True) + except Exception: + pass + if "min_width" in props and props["min_width"] is not None: + try: + view.widthAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_width"])).setActive_(True) + except Exception: + pass + if "min_height" in props and props["min_height"] is not None: + try: + view.heightAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_height"])).setActive_(True) + except Exception: + pass + + +def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None: + """Apply visual properties shared across many handlers.""" + if "background_color" in props and props["background_color"] is not None: + view.setBackgroundColor_(_uicolor(props["background_color"])) + if "overflow" in props: + view.setClipsToBounds_(props["overflow"] == "hidden") + + +def _apply_flex_container(sv: Any, props: Dict[str, Any]) -> None: + """Apply flex container properties to a UIStackView. + + Handles axis, spacing, alignment, distribution, background, padding, and overflow. + """ + if "flex_direction" in props: + vertical = is_vertical(props["flex_direction"]) + sv.setAxis_(1 if vertical else 0) + + if "spacing" in props and props["spacing"]: + sv.setSpacing_(float(props["spacing"])) + + ai = props.get("align_items") or props.get("alignment") + if ai: + direction = props.get("flex_direction") + vertical = is_vertical(direction) if direction else bool(sv.axis()) + if vertical: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "leading": 1, + "center": 3, + "flex_end": 4, + "trailing": 4, + } + else: + alignment_map = { + "stretch": 0, + "fill": 0, + "flex_start": 1, + "top": 1, + "center": 3, + "flex_end": 4, + "bottom": 4, + } + sv.setAlignment_(alignment_map.get(ai, 0)) + + jc = props.get("justify_content") + if jc: + # UIStackViewDistribution: + # 0 = fill, 1 = fillEqually, 2 = fillProportionally, + # 3 = equalSpacing (≈ space_between), 4 = equalCentering (≈ space_evenly) + distribution_map = { + "flex_start": 0, + "center": 0, + "flex_end": 0, + "space_between": 3, + "space_around": 4, + "space_evenly": 4, + } + sv.setDistribution_(distribution_map.get(jc, 0)) + + _apply_common_visual(sv, props) + + if "padding" in props: + left, top, right, bottom = resolve_padding(props["padding"]) + sv.setLayoutMarginsRelativeArrangement_(True) + try: + sv.setDirectionalLayoutMargins_((top, left, bottom, right)) + except Exception: + sv.setLayoutMargins_((top, left, bottom, right)) + + +# ====================================================================== +# ObjC callback targets (retained at module level) +# ====================================================================== + +_pn_btn_handler_map: dict = {} +_pn_retained_views: list = [] + + +class _PNButtonTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[], None]] = None + + @objc_method + def onTap_(self, sender: object) -> None: + if self._callback is not None: + self._callback() + + +_pn_tf_handler_map: dict = {} + + +class _PNTextFieldTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[str], None]] = None + + @objc_method + def onEdit_(self, sender: object) -> None: + if self._callback is not None: + try: + text = str(sender.text) if sender and hasattr(sender, "text") else "" + self._callback(text) + except Exception: + pass + + +_pn_switch_handler_map: dict = {} + + +class _PNSwitchTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[bool], None]] = None + + @objc_method + def onToggle_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(bool(sender.isOn())) + except Exception: + pass + + +_pn_slider_handler_map: dict = {} + + +class _PNSliderTarget(NSObject): # type: ignore[valid-type] + _callback: Optional[Callable[[float], None]] = None + + @objc_method + def onSlide_(self, sender: object) -> None: + if self._callback is not None: + try: + self._callback(float(sender.value)) + except Exception: + pass + + +# ====================================================================== +# Flex container handler (shared by Column, Row, View) +# ====================================================================== + + +class FlexContainerHandler(ViewHandler): + """Unified handler for flex layout containers (Column, Row, View). + + All three element types use ``UIStackView`` with axis determined + by the ``flex_direction`` prop. + """ + + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0))) + direction = props.get("flex_direction", "column") + sv.setAxis_(1 if is_vertical(direction) else 0) + _apply_flex_container(sv, props) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if changed.keys() & CONTAINER_KEYS: + _apply_flex_container(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addArrangedSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + parent.removeArrangedSubview_(child) + child.removeFromSuperview() + + def insert_child(self, parent: Any, child: Any, index: int) -> None: + parent.insertArrangedSubview_atIndex_(child, index) + + +# ====================================================================== +# Leaf handlers +# ====================================================================== + + +class TextHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + label = ObjCClass("UILabel").alloc().init() + self._apply(label, props) + _apply_ios_layout(label, props) + return label + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, label: Any, props: Dict[str, Any]) -> None: + if "text" in props: + label.setText_(str(props["text"])) + if "font_size" in props and props["font_size"] is not None: + if props.get("bold"): + label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"]))) + else: + label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + elif "bold" in props and props["bold"]: + size = label.font().pointSize() if label.font() else 17.0 + label.setFont_(UIFont.boldSystemFontOfSize_(size)) + if "color" in props and props["color"] is not None: + label.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + label.setBackgroundColor_(_uicolor(props["background_color"])) + if "max_lines" in props and props["max_lines"] is not None: + label.setNumberOfLines_(int(props["max_lines"])) + if "text_align" in props: + mapping = {"left": 0, "center": 1, "right": 2} + label.setTextAlignment_(mapping.get(props["text_align"], 0)) + + +class ButtonHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + btn = ObjCClass("UIButton").alloc().init() + btn.retain() + _pn_retained_views.append(btn) + _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0) + btn.setTitleColor_forState_(_ios_blue, 0) + self._apply(btn, props) + _apply_ios_layout(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, btn: Any, props: Dict[str, Any]) -> None: + if "title" in props: + btn.setTitle_forState_(str(props["title"]), 0) + if "font_size" in props and props["font_size"] is not None: + btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "background_color" in props and props["background_color"] is not None: + btn.setBackgroundColor_(_uicolor(props["background_color"])) + if "color" not in props: + _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0) + btn.setTitleColor_forState_(_white, 0) + if "color" in props and props["color"] is not None: + btn.setTitleColor_forState_(_uicolor(props["color"]), 0) + if "enabled" in props: + btn.setEnabled_(bool(props["enabled"])) + if "on_click" in props: + existing = _pn_btn_handler_map.get(id(btn)) + if existing is not None: + existing._callback = props["on_click"] + else: + handler = _PNButtonTarget.new() + handler._callback = props["on_click"] + _pn_btn_handler_map[id(btn)] = handler + btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6) + + +class ScrollViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sv = ObjCClass("UIScrollView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + sv.setBackgroundColor_(_uicolor(props["background_color"])) + _apply_ios_layout(sv, props) + return sv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + child.setTranslatesAutoresizingMaskIntoConstraints_(False) + parent.addSubview_(child) + content_guide = parent.contentLayoutGuide + frame_guide = parent.frameLayoutGuide + child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True) + child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True) + child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True) + child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True) + child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +class TextInputHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + tf = ObjCClass("UITextField").alloc().init() + tf.setBorderStyle_(2) # RoundedRect + self._apply(tf, props) + _apply_ios_layout(tf, props) + return tf + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, tf: Any, props: Dict[str, Any]) -> None: + if "value" in props: + tf.setText_(str(props["value"])) + if "placeholder" in props: + tf.setPlaceholder_(str(props["placeholder"])) + if "font_size" in props and props["font_size"] is not None: + tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"]))) + if "color" in props and props["color"] is not None: + tf.setTextColor_(_uicolor(props["color"])) + if "background_color" in props and props["background_color"] is not None: + tf.setBackgroundColor_(_uicolor(props["background_color"])) + if "secure" in props and props["secure"]: + tf.setSecureTextEntry_(True) + if "on_change" in props: + existing = _pn_tf_handler_map.get(id(tf)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNTextFieldTarget.new() + handler._callback = props["on_change"] + _pn_tf_handler_map[id(tf)] = handler + tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17) + + +class ImageHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + iv = ObjCClass("UIImageView").alloc().init() + self._apply(iv, props) + _apply_ios_layout(iv, props) + return iv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply(self, iv: Any, props: Dict[str, Any]) -> None: + if "background_color" in props and props["background_color"] is not None: + iv.setBackgroundColor_(_uicolor(props["background_color"])) + if "source" in props and props["source"]: + self._load_source(iv, props["source"]) + if "scale_type" in props and props["scale_type"]: + mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4} + iv.setContentMode_(mapping.get(props["scale_type"], 1)) + + def _load_source(self, iv: Any, source: str) -> None: + try: + if source.startswith(("http://", "https://")): + NSURL = ObjCClass("NSURL") + NSData = ObjCClass("NSData") + UIImage = ObjCClass("UIImage") + url = NSURL.URLWithString_(source) + data = NSData.dataWithContentsOfURL_(url) + if data: + image = UIImage.imageWithData_(data) + if image: + iv.setImage_(image) + else: + UIImage = ObjCClass("UIImage") + image = UIImage.imageNamed_(source) + if image: + iv.setImage_(image) + except Exception: + pass + + +class SwitchHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sw = ObjCClass("UISwitch").alloc().init() + self._apply(sw, props) + return sw + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sw: Any, props: Dict[str, Any]) -> None: + if "value" in props: + sw.setOn_animated_(bool(props["value"]), False) + if "on_change" in props: + existing = _pn_switch_handler_map.get(id(sw)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSwitchTarget.new() + handler._callback = props["on_change"] + _pn_switch_handler_map[id(sw)] = handler + sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12) + + +class ProgressBarHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + pv = ObjCClass("UIProgressView").alloc().init() + if "value" in props: + pv.setProgress_(float(props["value"])) + _apply_ios_layout(pv, props) + return pv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "value" in changed: + native_view.setProgress_(float(changed["value"])) + + +class ActivityIndicatorHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + ai = ObjCClass("UIActivityIndicatorView").alloc().init() + if props.get("animating", True): + ai.startAnimating() + return ai + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "animating" in changed: + if changed["animating"]: + native_view.startAnimating() + else: + native_view.stopAnimating() + + +class WebViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + wv = ObjCClass("WKWebView").alloc().init() + if "url" in props and props["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(props["url"])) + wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + _apply_ios_layout(wv, props) + return wv + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "url" in changed and changed["url"]: + NSURL = ObjCClass("NSURL") + NSURLRequest = ObjCClass("NSURLRequest") + url_obj = NSURL.URLWithString_(str(changed["url"])) + native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj)) + + +class SpacerHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "size" in props and props["size"] is not None: + size = float(props["size"]) + v.setFrame_(((0, 0), (size, size))) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "size" in changed and changed["size"] is not None: + size = float(changed["size"]) + native_view.setFrame_(((0, 0), (size, size))) + + +class SafeAreaViewHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + if "background_color" in props and props["background_color"] is not None: + v.setBackgroundColor_(_uicolor(props["background_color"])) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + if "background_color" in changed and changed["background_color"] is not None: + native_view.setBackgroundColor_(_uicolor(changed["background_color"])) + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +class ModalHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setHidden_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + +class SliderHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + sl = ObjCClass("UISlider").alloc().init() + self._apply(sl, props) + _apply_ios_layout(sl, props) + return sl + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed) + + def _apply(self, sl: Any, props: Dict[str, Any]) -> None: + if "min_value" in props: + sl.setMinimumValue_(float(props["min_value"])) + if "max_value" in props: + sl.setMaximumValue_(float(props["max_value"])) + if "value" in props: + sl.setValue_(float(props["value"])) + if "on_change" in props: + existing = _pn_slider_handler_map.get(id(sl)) + if existing is not None: + existing._callback = props["on_change"] + else: + handler = _PNSliderTarget.new() + handler._callback = props["on_change"] + _pn_slider_handler_map[id(sl)] = handler + sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12) + + +_pn_tabbar_state: dict = {"callback": None, "items": []} +_pn_tabbar_delegate_installed: bool = False +_pn_tabbar_delegate_ptr: Any = None + +# --------------------------------------------------------------------------- +# UITabBar delegate via raw ctypes +# +# rubicon-objc's @objc_method crashes (SIGSEGV in PyObject_GetAttr) when +# UIKit invokes the delegate through the FFI closure — the reconstructed +# Python wrappers for ``self`` or ``item`` end up with ob_type == NULL. +# +# We sidestep rubicon-objc entirely: create a minimal ObjC class with +# libobjc, register a CFUNCTYPE IMP for tabBar:didSelectItem:, and use +# objc_msgSend to read ``item.tag`` from the raw pointer. +# --------------------------------------------------------------------------- + +_libobjc = _ct.cdll.LoadLibrary("libobjc.A.dylib") + +_sel_reg = _libobjc.sel_registerName +_sel_reg.restype = _ct.c_void_p +_sel_reg.argtypes = [_ct.c_char_p] + +_get_cls = _libobjc.objc_getClass +_get_cls.restype = _ct.c_void_p +_get_cls.argtypes = [_ct.c_char_p] + +_alloc_cls = _libobjc.objc_allocateClassPair +_alloc_cls.restype = _ct.c_void_p +_alloc_cls.argtypes = [_ct.c_void_p, _ct.c_char_p, _ct.c_size_t] + +_reg_cls = _libobjc.objc_registerClassPair +_reg_cls.argtypes = [_ct.c_void_p] + +_add_method = _libobjc.class_addMethod +_add_method.restype = _ct.c_bool +_add_method.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_char_p] + +_objc_msgSend = _libobjc.objc_msgSend + +# Pre-register selectors used in the raw delegate path +_SEL_ALLOC = _sel_reg(b"alloc") +_SEL_INIT = _sel_reg(b"init") +_SEL_RETAIN = _sel_reg(b"retain") +_SEL_SET_DELEGATE = _sel_reg(b"setDelegate:") +_SEL_TAG = _sel_reg(b"tag") + +# IMP type: void (id self, SEL _cmd, id tabBar, id item) +_DELEGATE_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p) + + +def _tabbar_did_select_imp(self_ptr: int, cmd_ptr: int, tabbar_ptr: int, item_ptr: int) -> None: + """Raw C callback for ``tabBar:didSelectItem:``.""" + try: + _objc_msgSend.restype = _ct.c_long + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p] + tag: int = _objc_msgSend(item_ptr, _SEL_TAG) + + cb = _pn_tabbar_state["callback"] + tab_items = _pn_tabbar_state["items"] + if cb is not None and tab_items and 0 <= tag < len(tab_items): + cb(tab_items[tag].get("name", "")) + except Exception: + pass + + +# prevent GC of the C callback +_tabbar_imp_ref = _DELEGATE_IMP_TYPE(_tabbar_did_select_imp) + +# Create and register a minimal ObjC class for the delegate +_NS_OBJECT_CLS = _get_cls(b"NSObject") +_PN_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTabBarDelegateCTypes", 0) +if _PN_DELEGATE_CLS: + _add_method( + _PN_DELEGATE_CLS, + _sel_reg(b"tabBar:didSelectItem:"), + _ct.cast(_tabbar_imp_ref, _ct.c_void_p), + b"v@:@@", + ) + _reg_cls(_PN_DELEGATE_CLS) + + +def _ensure_tabbar_delegate(tab_bar: Any) -> None: + """Create the singleton delegate (if needed) and assign it to *tab_bar*.""" + global _pn_tabbar_delegate_ptr + if _pn_tabbar_delegate_ptr is None and _PN_DELEGATE_CLS: + _objc_msgSend.restype = _ct.c_void_p + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p] + raw = _objc_msgSend(_PN_DELEGATE_CLS, _SEL_ALLOC) + raw = _objc_msgSend(raw, _SEL_INIT) + raw = _objc_msgSend(raw, _SEL_RETAIN) + _pn_tabbar_delegate_ptr = raw + + if _pn_tabbar_delegate_ptr is not None: + _objc_msgSend.restype = None + _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p] + tab_bar_ptr = tab_bar.ptr if hasattr(tab_bar, "ptr") else tab_bar + _objc_msgSend(tab_bar_ptr, _SEL_SET_DELEGATE, _pn_tabbar_delegate_ptr) + + +class TabBarHandler(ViewHandler): + """Native tab bar using ``UITabBar``. + + Each tab is a ``UITabBarItem`` with a ``tag`` matching its index + in the items list. A raw ctypes delegate forwards selection + events back to the Python ``on_tab_select`` callback. + """ + + def create(self, props: Dict[str, Any]) -> Any: + tab_bar = ObjCClass("UITabBar").alloc().initWithFrame_(((0, 0), (0, 49))) + tab_bar.retain() + _pn_retained_views.append(tab_bar) + self._apply_full(tab_bar, props) + _apply_ios_layout(tab_bar, props) + return tab_bar + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply_partial(native_view, changed) + if changed.keys() & LAYOUT_KEYS: + _apply_ios_layout(native_view, changed) + + def _apply_full(self, tab_bar: Any, props: Dict[str, Any]) -> None: + items = props.get("items", []) + self._set_bar_items(tab_bar, items) + self._set_active(tab_bar, props.get("active_tab"), items) + self._set_callback(tab_bar, props.get("on_tab_select"), items) + + def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None: + prev_items = _pn_tabbar_state["items"] + + if "items" in changed: + items = changed["items"] + self._set_bar_items(tab_bar, items) + else: + items = prev_items + + if "active_tab" in changed: + self._set_active(tab_bar, changed["active_tab"], items) + + if "on_tab_select" in changed: + self._set_callback(tab_bar, changed["on_tab_select"], items) + + def _set_bar_items(self, tab_bar: Any, items: list) -> None: + UITabBarItem = ObjCClass("UITabBarItem") + bar_items = [] + for i, item in enumerate(items): + title = item.get("title", item.get("name", "")) + bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), None, i) + bar_items.append(bar_item) + tab_bar.setItems_animated_(bar_items, False) + + def _set_active(self, tab_bar: Any, active: Any, items: list) -> None: + if not active or not items: + return + for i, item in enumerate(items): + if item.get("name") == active: + try: + all_items = list(tab_bar.items or []) + if i < len(all_items): + tab_bar.setSelectedItem_(all_items[i]) + except Exception: + pass + break + + def _set_callback(self, tab_bar: Any, cb: Any, items: list) -> None: + _pn_tabbar_state["callback"] = cb + _pn_tabbar_state["items"] = items + _ensure_tabbar_delegate(tab_bar) + + +class PressableHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> Any: + v = ObjCClass("UIView").alloc().init() + v.setUserInteractionEnabled_(True) + return v + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + pass + + def add_child(self, parent: Any, child: Any) -> None: + parent.addSubview_(child) + + def remove_child(self, parent: Any, child: Any) -> None: + child.removeFromSuperview() + + +# ====================================================================== +# Registration +# ====================================================================== + + +def register_handlers(registry: Any) -> None: + """Register all iOS view handlers with the given registry.""" + flex = FlexContainerHandler() + registry.register("Text", TextHandler()) + registry.register("Button", ButtonHandler()) + registry.register("Column", flex) + registry.register("Row", flex) + registry.register("View", flex) + registry.register("ScrollView", ScrollViewHandler()) + registry.register("TextInput", TextInputHandler()) + registry.register("Image", ImageHandler()) + registry.register("Switch", SwitchHandler()) + registry.register("ProgressBar", ProgressBarHandler()) + registry.register("ActivityIndicator", ActivityIndicatorHandler()) + registry.register("WebView", WebViewHandler()) + registry.register("Spacer", SpacerHandler()) + registry.register("SafeAreaView", SafeAreaViewHandler()) + registry.register("Modal", ModalHandler()) + registry.register("Slider", SliderHandler()) + registry.register("TabBar", TabBarHandler()) + registry.register("Pressable", PressableHandler()) diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py new file mode 100644 index 0000000..a0cd5a6 --- /dev/null +++ b/src/pythonnative/navigation.py @@ -0,0 +1,571 @@ +"""Declarative navigation for PythonNative. + +Provides a component-based navigation system inspired by React Navigation. +Navigators manage screen state in Python; they render the active screen's +component using the standard reconciler pipeline. + +Usage:: + + from pythonnative.navigation import ( + NavigationContainer, + create_stack_navigator, + create_tab_navigator, + create_drawer_navigator, + ) + + Stack = create_stack_navigator() + + @pn.component + def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + ) +""" + +from typing import Any, Callable, Dict, List, Optional + +from .element import Element +from .hooks import ( + Provider, + _NavigationContext, + component, + create_context, + use_context, + use_effect, + use_memo, + use_ref, + use_state, +) + +# ====================================================================== +# Focus context +# ====================================================================== + +_FocusContext = create_context(False) + +# ====================================================================== +# Data structures +# ====================================================================== + + +class _ScreenDef: + """Configuration for a single screen within a navigator.""" + + __slots__ = ("name", "component", "options") + + def __init__(self, name: str, component_fn: Any, options: Optional[Dict[str, Any]] = None) -> None: + self.name = name + self.component = component_fn + self.options = options or {} + + def __repr__(self) -> str: + return f"Screen({self.name!r})" + + +class _RouteEntry: + """An entry in the navigation stack.""" + + __slots__ = ("name", "params") + + def __init__(self, name: str, params: Optional[Dict[str, Any]] = None) -> None: + self.name = name + self.params = params or {} + + def __repr__(self) -> str: + return f"Route({self.name!r})" + + +# ====================================================================== +# Navigation handle for declarative navigators +# ====================================================================== + + +class _DeclarativeNavHandle: + """Navigation handle provided by declarative navigators. + + Implements the same interface as :class:`~pythonnative.hooks.NavigationHandle` + so that ``use_navigation()`` returns a compatible object regardless of + whether the app uses the legacy page-based navigation or declarative + navigators. + + When *parent* is provided, unknown routes and root-level ``go_back`` + calls are forwarded to the parent handle. This enables nested + navigators (e.g. a stack inside a tab) to delegate navigation actions + that they cannot handle locally. + """ + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + parent: Any = None, + ) -> None: + self._screen_map = screen_map + self._get_stack = get_stack + self._set_stack = set_stack + self._parent = parent + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Navigate to a named route, pushing it onto the stack. + + If *route_name* is not known locally and a parent handle exists, + the call is forwarded to the parent navigator. + """ + if route_name in self._screen_map: + entry = _RouteEntry(route_name, params) + self._set_stack(lambda s: list(s) + [entry]) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + + def go_back(self) -> None: + """Pop the current screen from the stack. + + If the stack is at its root and a parent handle exists, the call + is forwarded to the parent navigator. + """ + stack = self._get_stack() + if len(stack) > 1: + self._set_stack(lambda s: list(s[:-1])) + elif self._parent is not None: + self._parent.go_back() + + def get_params(self) -> Dict[str, Any]: + """Return the parameters for the current route.""" + stack = self._get_stack() + return stack[-1].params if stack else {} + + def reset(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Reset the stack to a single route.""" + if route_name not in self._screen_map: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + self._set_stack([_RouteEntry(route_name, params)]) + + +class _TabNavHandle(_DeclarativeNavHandle): + """Navigation handle for tab navigators with tab switching.""" + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + switch_tab: Callable[[str, Optional[Dict[str, Any]]], None], + parent: Any = None, + ) -> None: + super().__init__(screen_map, get_stack, set_stack, parent=parent) + self._switch_tab = switch_tab + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Switch to a tab by name, or forward to parent for unknown routes.""" + if route_name in self._screen_map: + self._switch_tab(route_name, params) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + + +class _DrawerNavHandle(_DeclarativeNavHandle): + """Navigation handle for drawer navigators with open/close control.""" + + def __init__( + self, + screen_map: Dict[str, "_ScreenDef"], + get_stack: Callable[[], List["_RouteEntry"]], + set_stack: Callable, + switch_screen: Callable[[str, Optional[Dict[str, Any]]], None], + set_drawer_open: Callable[[bool], None], + get_drawer_open: Callable[[], bool], + parent: Any = None, + ) -> None: + super().__init__(screen_map, get_stack, set_stack, parent=parent) + self._switch_screen = switch_screen + self._set_drawer_open = set_drawer_open + self._get_drawer_open = get_drawer_open + + def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: + """Switch to a screen and close the drawer, or forward to parent.""" + if route_name in self._screen_map: + self._switch_screen(route_name, params) + self._set_drawer_open(False) + elif self._parent is not None: + self._parent.navigate(route_name, params=params) + else: + raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + + def open_drawer(self) -> None: + """Open the drawer.""" + self._set_drawer_open(True) + + def close_drawer(self) -> None: + """Close the drawer.""" + self._set_drawer_open(False) + + def toggle_drawer(self) -> None: + """Toggle the drawer open/closed.""" + self._set_drawer_open(not self._get_drawer_open()) + + +# ====================================================================== +# Stack navigator +# ====================================================================== + + +def _build_screen_map(screens: Any) -> Dict[str, "_ScreenDef"]: + """Build an ordered dict of name -> _ScreenDef from a list.""" + result: Dict[str, _ScreenDef] = {} + for s in screens or []: + if isinstance(s, _ScreenDef): + result[s.name] = s + return result + + +@component +def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_map = _build_screen_map(screens) + if not screen_map: + return Element("View", {}, []) + + parent_nav = use_context(_NavigationContext) + + first_route = initial_route or next(iter(screen_map)) + stack, set_stack = use_state(lambda: [_RouteEntry(first_route)]) + + stack_ref = use_ref(None) + stack_ref["current"] = stack + + handle = use_memo( + lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref["current"], set_stack, parent=parent_nav), [] + ) + handle._screen_map = screen_map + handle._parent = parent_nav + + current = stack[-1] + screen_def = screen_map.get(current.name) + if screen_def is None: + return Element("Text", {"text": f"Unknown route: {current.name}"}, []) + + screen_el = screen_def.component() + return Provider(_NavigationContext, handle, Provider(_FocusContext, True, screen_el)) + + +def create_stack_navigator() -> Any: + """Create a stack-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Stack = create_stack_navigator() + + Stack.Screen("Home", component=HomeScreen) + + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Home", + ) + """ + + class _StackNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this stack navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the stack navigator with the given screens.""" + return _stack_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _StackNavigator() + + +# ====================================================================== +# Tab navigator +# ====================================================================== + + +@component +def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_list = list(screens or []) + screen_map = _build_screen_map(screen_list) + if not screen_map: + return Element("View", {}, []) + + parent_nav = use_context(_NavigationContext) + + first_route = initial_route or screen_list[0].name + active_tab, set_active_tab = use_state(first_route) + tab_params, set_tab_params = use_state(lambda: {first_route: {}}) + + params_ref = use_ref(None) + params_ref["current"] = tab_params + + def switch_tab(name: str, params: Optional[Dict[str, Any]] = None) -> None: + set_active_tab(name) + if params: + set_tab_params(lambda p: {**p, name: params}) + + def get_stack() -> List[_RouteEntry]: + p = params_ref["current"] or {} + return [_RouteEntry(active_tab, p.get(active_tab, {}))] + + handle = use_memo(lambda: _TabNavHandle(screen_map, get_stack, lambda _: None, switch_tab, parent=parent_nav), []) + handle._screen_map = screen_map + handle._switch_tab = switch_tab + handle._parent = parent_nav + + screen_def = screen_map.get(active_tab) + if screen_def is None: + screen_def = screen_map[screen_list[0].name] + + tab_items: List[Dict[str, str]] = [] + for s in screen_list: + if isinstance(s, _ScreenDef): + tab_items.append({"name": s.name, "title": s.options.get("title", s.name)}) + + def on_tab_select(name: str) -> None: + switch_tab(name) + + tab_bar = Element( + "TabBar", + {"items": tab_items, "active_tab": active_tab, "on_tab_select": on_tab_select}, + [], + key="__tab_bar__", + ) + + screen_el = screen_def.component() + content = Provider( + _NavigationContext, + handle, + Provider(_FocusContext, True, screen_el), + ) + + return Element( + "View", + {"flex_direction": "column", "flex": 1}, + [Element("View", {"flex": 1}, [content]), tab_bar], + ) + + +def create_tab_navigator() -> Any: + """Create a tab-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Tab = create_tab_navigator() + + Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen), + ) + """ + + class _TabNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this tab navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the tab navigator with the given screens.""" + return _tab_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _TabNavigator() + + +# ====================================================================== +# Drawer navigator +# ====================================================================== + + +@component +def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: + screen_list = list(screens or []) + screen_map = _build_screen_map(screen_list) + if not screen_map: + return Element("View", {}, []) + + parent_nav = use_context(_NavigationContext) + + first_route = initial_route or screen_list[0].name + active_screen, set_active_screen = use_state(first_route) + drawer_open, set_drawer_open = use_state(False) + screen_params, set_screen_params = use_state(lambda: {first_route: {}}) + + params_ref = use_ref(None) + params_ref["current"] = screen_params + + def switch_screen(name: str, params: Optional[Dict[str, Any]] = None) -> None: + set_active_screen(name) + if params: + set_screen_params(lambda p: {**p, name: params}) + + def get_stack() -> List[_RouteEntry]: + p = params_ref["current"] or {} + return [_RouteEntry(active_screen, p.get(active_screen, {}))] + + handle = use_memo( + lambda: _DrawerNavHandle( + screen_map, + get_stack, + lambda _: None, + switch_screen, + set_drawer_open, + lambda: drawer_open, + parent=parent_nav, + ), + [], + ) + handle._screen_map = screen_map + handle._switch_screen = switch_screen + handle._set_drawer_open = set_drawer_open + handle._get_drawer_open = lambda: drawer_open + handle._parent = parent_nav + + screen_def = screen_map.get(active_screen) + if screen_def is None: + screen_def = screen_map[screen_list[0].name] + + screen_el = screen_def.component() + content = Provider( + _NavigationContext, + handle, + Provider(_FocusContext, True, screen_el), + ) + + children: List[Element] = [Element("View", {"flex": 1}, [content])] + + if drawer_open: + menu_items: List[Element] = [] + for s in screen_list: + if not isinstance(s, _ScreenDef): + continue + label = s.options.get("title", s.name) + item_name = s.name + + def make_select(n: str) -> Callable[[], None]: + def _select() -> None: + switch_screen(n) + set_drawer_open(False) + + return _select + + menu_items.append( + Element("Button", {"title": label, "on_click": make_select(item_name)}, [], key=f"__drawer_{item_name}") + ) + + drawer_panel = Element( + "View", + {"background_color": "#FFFFFF", "width": 250}, + menu_items, + ) + children.insert(0, drawer_panel) + + return Element("View", {"flex_direction": "row", "flex": 1}, children) + + +def create_drawer_navigator() -> Any: + """Create a drawer-based navigator. + + Returns an object with ``Navigator`` and ``Screen`` members:: + + Drawer = create_drawer_navigator() + + Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Drawer.Screen("Settings", component=SettingsScreen), + ) + + The navigation handle returned by ``use_navigation()`` inside a drawer + navigator includes ``open_drawer()``, ``close_drawer()``, and + ``toggle_drawer()`` methods. + """ + + class _DrawerNavigator: + @staticmethod + def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef": + """Define a screen within this drawer navigator.""" + return _ScreenDef(name, component, options) + + @staticmethod + def Navigator(*screens: Any, initial_route: Optional[str] = None, key: Optional[str] = None) -> Element: + """Render the drawer navigator with the given screens.""" + return _drawer_navigator_impl(screens=list(screens), initial_route=initial_route, key=key) + + return _DrawerNavigator() + + +# ====================================================================== +# NavigationContainer +# ====================================================================== + + +def NavigationContainer(child: Element, *, key: Optional[str] = None) -> Element: + """Root container for the navigation tree. + + Wraps the child navigator in a full-size view. All declarative + navigators (stack, tab, drawer) should be nested inside a + ``NavigationContainer``:: + + @pn.component + def App(): + return NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + ) + ) + """ + return Element("View", {"flex": 1}, [child], key=key) + + +# ====================================================================== +# Hooks +# ====================================================================== + + +def use_route() -> Dict[str, Any]: + """Return the current route's parameters. + + Convenience hook that reads from the navigation context:: + + @pn.component + def DetailScreen(): + params = pn.use_route() + item_id = params.get("id") + ... + """ + nav = use_context(_NavigationContext) + if nav is None: + return {} + get_params = getattr(nav, "get_params", None) + if get_params: + return get_params() + return {} + + +def use_focus_effect(effect: Callable, deps: Optional[list] = None) -> None: + """Run *effect* only when the screen is focused. + + Like ``use_effect`` but skips execution when the screen is not the + active/focused screen in a navigator:: + + @pn.component + def HomeScreen(): + pn.use_focus_effect(lambda: print("screen focused"), []) + """ + is_focused = use_context(_FocusContext) + all_deps = [is_focused] + (list(deps) if deps is not None else []) + + def wrapped_effect() -> Any: + if is_focused: + return effect() + return None + + use_effect(wrapped_effect, all_deps) diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index f46d9e6..0d77c75 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -29,6 +29,8 @@ def MainPage(): from .utils import IS_ANDROID, set_android_context +_MAX_RENDER_PASSES = 25 + # ====================================================================== # Component path resolution # ====================================================================== @@ -62,6 +64,9 @@ def _init_host_common(host: Any) -> None: host._args = {} host._reconciler = None host._root_native_view = None + host._nav_handle = None + host._is_rendering = False + host._render_queued = False def _on_create(host: Any) -> None: @@ -70,28 +75,68 @@ def _on_create(host: Any) -> None: from .reconciler import Reconciler host._reconciler = Reconciler(get_registry()) - host._reconciler._page_re_render = lambda: _re_render(host) + host._reconciler._page_re_render = lambda: _request_render(host) + host._nav_handle = NavigationHandle(host) - nav_handle = NavigationHandle(host) app_element = host._component() - provider_element = Provider(_NavigationContext, nav_handle, app_element) + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) + + host._is_rendering = True + try: + host._root_native_view = host._reconciler.mount(provider_element) + host._attach_root(host._root_native_view) + _drain_renders(host) + finally: + host._is_rendering = False + - host._root_native_view = host._reconciler.mount(provider_element) - host._attach_root(host._root_native_view) +def _request_render(host: Any) -> None: + """State-change trigger. Defers if a render is already in progress.""" + if host._is_rendering: + host._render_queued = True + return + _re_render(host) def _re_render(host: Any) -> None: - from .hooks import NavigationHandle, Provider, _NavigationContext + """Perform a full render pass, draining any state set during effects.""" + from .hooks import Provider, _NavigationContext - nav_handle = NavigationHandle(host) - app_element = host._component() - provider_element = Provider(_NavigationContext, nav_handle, app_element) + host._is_rendering = True + try: + host._render_queued = False + + app_element = host._component() + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) + + new_root = host._reconciler.reconcile(provider_element) + if new_root is not host._root_native_view: + host._detach_root(host._root_native_view) + host._root_native_view = new_root + host._attach_root(new_root) + + _drain_renders(host) + finally: + host._is_rendering = False + + +def _drain_renders(host: Any) -> None: + """Flush additional renders queued by effects that set state.""" + from .hooks import Provider, _NavigationContext + + for _ in range(_MAX_RENDER_PASSES): + if not host._render_queued: + break + host._render_queued = False + + app_element = host._component() + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) - new_root = host._reconciler.reconcile(provider_element) - if new_root is not host._root_native_view: - host._detach_root(host._root_native_view) - host._root_native_view = new_root - host._attach_root(new_root) + new_root = host._reconciler.reconcile(provider_element) + if new_root is not host._root_native_view: + host._detach_root(host._root_native_view) + host._root_native_view = new_root + host._attach_root(new_root) def _set_args(host: Any, args: Any) -> None: @@ -392,10 +437,10 @@ def _get_nav_args(self) -> Dict[str, Any]: return self._args def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: - raise RuntimeError("push() requires a native runtime (iOS or Android)") + raise RuntimeError("navigate() requires a native runtime (iOS or Android)") def _pop(self) -> None: - raise RuntimeError("pop() requires a native runtime (iOS or Android)") + raise RuntimeError("go_back() requires a native runtime (iOS or Android)") def _attach_root(self, native_view: Any) -> None: pass diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index 8f2c5ab..f24ab5d 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -11,8 +11,12 @@ ``@component``). Their hook state is preserved across renders. - **Provider elements** (type ``"__Provider__"``), which push/pop context values during tree traversal. +- **Error boundary elements** (type ``"__ErrorBoundary__"``), which + catch exceptions in child subtrees and render a fallback. - **Key-based child reconciliation** for stable identity across re-renders. +- **Post-render effect flushing**: after each mount or reconcile pass, + all queued effects are executed so they see the committed native tree. """ from typing import Any, List, Optional @@ -36,6 +40,10 @@ def __init__(self, element: Element, native_view: Any, children: List["VNode"]) class Reconciler: """Create, diff, and patch native view trees from Element descriptors. + After each ``mount`` or ``reconcile`` call the reconciler walks the + committed tree and flushes all pending effects so that effect + callbacks run **after** native mutations are applied. + Parameters ---------- backend: @@ -56,6 +64,7 @@ def __init__(self, backend: Any) -> None: def mount(self, element: Element) -> Any: """Build native views from *element* and return the root native view.""" self._tree = self._create_tree(element) + self._flush_effects() return self._tree.native_view def reconcile(self, new_element: Element) -> Any: @@ -65,11 +74,28 @@ def reconcile(self, new_element: Element) -> Any: """ if self._tree is None: self._tree = self._create_tree(new_element) + self._flush_effects() return self._tree.native_view self._tree = self._reconcile_node(self._tree, new_element) + self._flush_effects() return self._tree.native_view + # ------------------------------------------------------------------ + # Effect flushing + # ------------------------------------------------------------------ + + def _flush_effects(self) -> None: + """Walk the committed tree and flush pending effects (depth-first).""" + if self._tree is not None: + self._flush_tree_effects(self._tree) + + def _flush_tree_effects(self, node: VNode) -> None: + for child in node.children: + self._flush_tree_effects(child) + if node.hook_state is not None: + node.hook_state.flush_pending_effects() + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -87,6 +113,10 @@ def _create_tree(self, element: Element) -> VNode: children = [child_node] if child_node else [] return VNode(element, native_view, children) + # Error boundary: catch exceptions in the child subtree + if element.type == "__ErrorBoundary__": + return self._create_error_boundary(element) + # Function component: call with hook context if callable(element.type): from .hooks import HookState, _set_hook_state @@ -114,6 +144,20 @@ def _create_tree(self, element: Element) -> VNode: children.append(child_node) return VNode(element, native_view, children) + def _create_error_boundary(self, element: Element) -> VNode: + fallback_fn = element.props.get("__fallback__") + try: + child_node = self._create_tree(element.children[0]) if element.children else None + except Exception as exc: + if fallback_fn is not None: + fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn + child_node = self._create_tree(fallback_el) + else: + raise + native_view = child_node.native_view if child_node else None + children = [child_node] if child_node else [] + return VNode(element, native_view, children) + def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: if not self._same_type(old.element, new_el): new_node = self._create_tree(new_el) @@ -138,6 +182,10 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: old.element = new_el return old + # Error boundary + if new_el.type == "__ErrorBoundary__": + return self._reconcile_error_boundary(old, new_el) + # Function component if callable(new_el.type): from .hooks import _set_hook_state @@ -175,10 +223,34 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: old.element = new_el return old + def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode: + fallback_fn = new_el.props.get("__fallback__") + try: + if old.children and new_el.children: + child = self._reconcile_node(old.children[0], new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + elif new_el.children: + child = self._create_tree(new_el.children[0]) + old.children = [child] + old.native_view = child.native_view + except Exception as exc: + for c in old.children: + self._destroy_tree(c) + if fallback_fn is not None: + fallback_el = fallback_fn(exc) if callable(fallback_fn) else fallback_fn + child = self._create_tree(fallback_el) + old.children = [child] + old.native_view = child.native_view + else: + raise + old.element = new_el + return old + def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None: old_children = parent.children parent_type = parent.element.type - is_native = isinstance(parent_type, str) and parent_type != "__Provider__" + is_native = isinstance(parent_type, str) and parent_type not in ("__Provider__", "__ErrorBoundary__") old_by_key: dict = {} old_unkeyed: list = [] @@ -215,7 +287,11 @@ def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> Non self.backend.insert_child(parent.native_view, node.native_view, parent_type, i) new_child_nodes.append(node) else: + old_native = matched.native_view updated = self._reconcile_node(matched, new_el) + if is_native and updated.native_view is not old_native: + self.backend.remove_child(parent.native_view, old_native, parent_type) + self.backend.insert_child(parent.native_view, updated.native_view, parent_type, i) new_child_nodes.append(updated) # Destroy unused old nodes @@ -229,6 +305,18 @@ def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> Non self.backend.remove_child(parent.native_view, node.native_view, parent_type) self._destroy_tree(node) + # Reorder native children when keyed children changed positions. + # Without this, native sibling order drifts from the logical tree + # when keyed children swap positions across reconcile passes. + if is_native and used_keyed: + old_key_order = [c.element.key for c in old_children if c.element.key in used_keyed] + new_key_order = [n.element.key for n in new_child_nodes if n.element.key in used_keyed] + if old_key_order != new_key_order: + for node in new_child_nodes: + self.backend.remove_child(parent.native_view, node.native_view, parent_type) + for node in new_child_nodes: + self.backend.add_child(parent.native_view, node.native_view, parent_type) + parent.children = new_child_nodes def _destroy_tree(self, node: VNode) -> None: diff --git a/tests/test_components.py b/tests/test_components.py index 469c23c..eaac14e 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -4,6 +4,7 @@ ActivityIndicator, Button, Column, + ErrorBoundary, FlatList, Image, Modal, @@ -103,6 +104,7 @@ def test_column_with_children() -> None: assert el.props["spacing"] == 10 assert el.props["padding"] == 16 assert el.props["align_items"] == "stretch" + assert el.props["flex_direction"] == "column" def test_row_with_children() -> None: @@ -110,17 +112,19 @@ def test_row_with_children() -> None: assert el.type == "Row" assert len(el.children) == 2 assert el.props["spacing"] == 5 + assert el.props["flex_direction"] == "row" -def test_column_no_style_empty_props() -> None: +def test_column_no_style_has_flex_direction() -> None: el = Column() - assert el.props == {} + assert el.props == {"flex_direction": "column"} def test_column_layout_via_style() -> None: el = Column(style={"flex": 2, "margin": {"horizontal": 8}}) assert el.props["flex"] == 2 assert el.props["margin"] == {"horizontal": 8} + assert el.props["flex_direction"] == "column" def test_column_justify_content() -> None: @@ -134,6 +138,16 @@ def test_row_justify_content() -> None: assert el.props["justify_content"] == "space_between" +def test_column_direction_cannot_be_overridden() -> None: + el = Column(style={"flex_direction": "row"}) + assert el.props["flex_direction"] == "column" + + +def test_row_direction_cannot_be_overridden() -> None: + el = Row(style={"flex_direction": "column"}) + assert el.props["flex_direction"] == "row" + + # --------------------------------------------------------------------------- # ScrollView # --------------------------------------------------------------------------- @@ -236,7 +250,7 @@ def test_column_key() -> None: # --------------------------------------------------------------------------- -# New components +# View (flex container) # --------------------------------------------------------------------------- @@ -248,6 +262,44 @@ def test_view_container() -> None: assert el.props["background_color"] == "#FFF" assert el.props["padding"] == 8 assert el.props["width"] == 200 + assert el.props["flex_direction"] == "column" + + +def test_view_default_direction_column() -> None: + el = View() + assert el.props["flex_direction"] == "column" + + +def test_view_direction_override() -> None: + el = View(style={"flex_direction": "row"}) + assert el.props["flex_direction"] == "row" + + +def test_view_flex_props() -> None: + el = View( + Text("a"), + style={ + "flex_direction": "row", + "justify_content": "space_between", + "align_items": "center", + "overflow": "hidden", + }, + ) + assert el.props["flex_direction"] == "row" + assert el.props["justify_content"] == "space_between" + assert el.props["align_items"] == "center" + assert el.props["overflow"] == "hidden" + + +def test_view_flex_grow_shrink() -> None: + el = Text("flex child", style={"flex_grow": 1, "flex_shrink": 0}) + assert el.props["flex_grow"] == 1 + assert el.props["flex_shrink"] == 0 + + +# --------------------------------------------------------------------------- +# Other containers +# --------------------------------------------------------------------------- def test_safe_area_view() -> None: @@ -318,3 +370,34 @@ def test_flat_list_empty() -> None: def test_spacer_flex() -> None: el = Spacer(flex=1) assert el.props["flex"] == 1 + + +# ====================================================================== +# ErrorBoundary +# ====================================================================== + + +def test_error_boundary_creates_element() -> None: + child = Text("risky") + fallback = Text("error") + el = ErrorBoundary(child, fallback=fallback) + assert el.type == "__ErrorBoundary__" + assert el.props["__fallback__"] is fallback + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_error_boundary_callable_fallback() -> None: + fn = lambda exc: Text(str(exc)) # noqa: E731 + el = ErrorBoundary(Text("risky"), fallback=fn) + assert callable(el.props["__fallback__"]) + + +def test_error_boundary_no_child() -> None: + el = ErrorBoundary(fallback=Text("empty")) + assert len(el.children) == 0 + + +def test_error_boundary_with_key() -> None: + el = ErrorBoundary(Text("x"), fallback=Text("err"), key="eb1") + assert el.key == "eb1" diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 8fa017d..1eddbeb 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -9,6 +9,7 @@ Provider, _NavigationContext, _set_hook_state, + batch_updates, component, create_context, use_callback, @@ -16,6 +17,7 @@ use_effect, use_memo, use_navigation, + use_reducer, use_ref, use_state, ) @@ -115,37 +117,156 @@ def test_use_state_setter_functional_update() -> None: # ====================================================================== -# use_effect +# use_reducer # ====================================================================== -def test_use_effect_runs_on_mount() -> None: +def test_use_reducer_returns_initial_state() -> None: + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + _set_hook_state(ctx) + try: + state, dispatch = use_reducer(reducer, 42) + assert state == 42 + finally: + _set_hook_state(None) + + +def test_use_reducer_lazy_initial_state() -> None: + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + _set_hook_state(ctx) + try: + state, _ = use_reducer(reducer, lambda: 99) + assert state == 99 + finally: + _set_hook_state(None) + + +def test_use_reducer_dispatch_triggers_render() -> None: + renders: list = [] + + def reducer(state: int, action: str) -> int: + if action == "increment": + return state + 1 + if action == "reset": + return 0 + return state + + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + state, dispatch = use_reducer(reducer, 0) + dispatch("increment") + assert ctx.states[0] == 1 + assert len(renders) == 1 + dispatch("increment") + assert ctx.states[0] == 2 + assert len(renders) == 2 + dispatch("reset") + assert ctx.states[0] == 0 + assert len(renders) == 3 + finally: + _set_hook_state(None) + + +def test_use_reducer_no_render_on_same_state() -> None: + renders: list = [] + + def reducer(state: int, action: str) -> int: + return state + + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, dispatch = use_reducer(reducer, 5) + dispatch("noop") + assert len(renders) == 0 + finally: + _set_hook_state(None) + + +def test_use_reducer_in_reconciler() -> None: + captured_dispatch: list = [None] + + def reducer(state: int, action: str) -> int: + if action == "increment": + return state + 1 + return state + + @component + def counter() -> Element: + count, dispatch = use_reducer(reducer, 0) + captured_dispatch[0] = dispatch + return Element("Text", {"text": str(count)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + re_rendered: list = [] + rec._page_re_render = lambda: re_rendered.append(1) + + root = rec.mount(counter()) + assert root.props["text"] == "0" + + dispatch_fn = captured_dispatch[0] + assert dispatch_fn is not None + dispatch_fn("increment") + assert len(re_rendered) == 1 + + +# ====================================================================== +# use_effect (deferred execution) +# ====================================================================== + + +def test_use_effect_is_deferred() -> None: + """Effects are queued during render, not run immediately.""" calls: list = [] ctx = HookState() _set_hook_state(ctx) try: use_effect(lambda: calls.append("mounted"), []) - assert calls == ["mounted"] + assert calls == [], "Effect should NOT run during render" finally: _set_hook_state(None) + ctx.flush_pending_effects() + assert calls == ["mounted"], "Effect should run after flush" + def test_use_effect_cleanup_on_rerun() -> None: cleanups: list = [] + + def make_effect(label: str): # type: ignore[no-untyped-def] + def effect() -> Any: + return lambda: cleanups.append(label) + + return effect + ctx = HookState() _set_hook_state(ctx) try: - use_effect(lambda: cleanups.append, None) + use_effect(make_effect("first"), None) finally: _set_hook_state(None) + ctx.flush_pending_effects() ctx.reset_index() _set_hook_state(ctx) try: - use_effect(lambda: cleanups.append, None) + use_effect(make_effect("second"), None) finally: _set_hook_state(None) + ctx.flush_pending_effects() + + assert "first" in cleanups def test_use_effect_skips_with_same_deps() -> None: @@ -157,6 +278,7 @@ def test_use_effect_skips_with_same_deps() -> None: use_effect(lambda: calls.append("run"), [1, 2]) finally: _set_hook_state(None) + ctx.flush_pending_effects() ctx.reset_index() _set_hook_state(ctx) @@ -164,10 +286,122 @@ def test_use_effect_skips_with_same_deps() -> None: use_effect(lambda: calls.append("run"), [1, 2]) finally: _set_hook_state(None) + ctx.flush_pending_effects() assert calls == ["run"] +def test_use_effect_runs_after_reconciler_mount() -> None: + """Effects run automatically after Reconciler.mount() completes.""" + calls: list = [] + + @component + def my_comp() -> Element: + use_effect(lambda: calls.append("effect"), []) + return Element("Text", {"text": "hi"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp()) + assert calls == ["effect"] + + +def test_use_effect_runs_after_reconciler_reconcile() -> None: + """Effects run automatically after Reconciler.reconcile() completes.""" + calls: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + use_effect(lambda: calls.append(f"effect-{dep}"), [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=0)) + assert calls == ["effect-0"] + + rec.reconcile(my_comp(dep=1)) + assert calls == ["effect-0", "effect-1"] + + +def test_use_effect_cleanup_on_unmount() -> None: + """Cleanup functions run when component is destroyed.""" + cleanups: list = [] + ctx = HookState() + + _set_hook_state(ctx) + try: + use_effect(lambda: (lambda: cleanups.append("cleaned")), []) + finally: + _set_hook_state(None) + ctx.flush_pending_effects() + + assert cleanups == [] + ctx.cleanup_all_effects() + assert cleanups == ["cleaned"] + + +# ====================================================================== +# batch_updates +# ====================================================================== + + +def test_batch_updates_defers_render() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(0) + _, set_b = use_state(0) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(1) + set_b(2) + assert len(renders) == 0, "Render should be deferred inside batch" + + assert len(renders) == 1, "Exactly one render after batch exits" + + +def test_batch_updates_nested() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(0) + _, set_b = use_state(0) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(1) + with batch_updates(): + set_b(2) + assert len(renders) == 0 + assert len(renders) == 0, "Nested batch should not trigger render" + + assert len(renders) == 1 + + +def test_batch_updates_no_render_when_unchanged() -> None: + renders: list = [] + ctx = HookState() + ctx._trigger_render = lambda: renders.append(1) + _set_hook_state(ctx) + try: + _, set_a = use_state(5) + finally: + _set_hook_state(None) + + with batch_updates(): + set_a(5) + + assert len(renders) == 0 + + # ====================================================================== # use_memo / use_callback # ====================================================================== @@ -459,7 +693,32 @@ def _pop(self) -> None: try: nav = use_navigation() assert nav is handle - assert nav.get_args() == {"id": 42} + assert nav.get_params() == {"id": 42} finally: _set_hook_state(None) _NavigationContext._stack.pop() + + +def test_navigation_handle_methods() -> None: + pushed: list = [] + popped: list = [] + + class FakeHost: + def _push(self, page: Any, args: Any = None) -> None: + pushed.append((page, args)) + + def _pop(self) -> None: + popped.append(1) + + def _get_nav_args(self) -> dict: + return {"key": "value"} + + handle = NavigationHandle(FakeHost()) + + handle.navigate("SomePage", params={"x": 1}) + assert pushed == [("SomePage", {"x": 1})] + + handle.go_back() + assert len(popped) == 1 + + assert handle.get_params() == {"key": "value"} diff --git a/tests/test_native_views.py b/tests/test_native_views.py new file mode 100644 index 0000000..41f6080 --- /dev/null +++ b/tests/test_native_views.py @@ -0,0 +1,252 @@ +"""Unit tests for the native_views package. + +Tests the registry, base handler protocol, and shared utility functions. +Platform-specific handlers (android/ios) are not tested here since they +require their respective runtime environments; they are exercised by +E2E tests on device. +""" + +from typing import Any, Dict + +import pytest + +from pythonnative.native_views import NativeViewRegistry, set_registry +from pythonnative.native_views.base import ( + CONTAINER_KEYS, + LAYOUT_KEYS, + ViewHandler, + is_vertical, + parse_color_int, + resolve_padding, +) + +# ====================================================================== +# parse_color_int +# ====================================================================== + + +def test_parse_color_hex6() -> None: + result = parse_color_int("#FF0000") + assert result == parse_color_int("FF0000") + expected = int("FFFF0000", 16) + if expected > 0x7FFFFFFF: + expected -= 0x100000000 + assert result == expected + + +def test_parse_color_hex8() -> None: + result = parse_color_int("#80FF0000") + raw = int("80FF0000", 16) + expected = raw - 0x100000000 # signed conversion + assert result == expected + + +def test_parse_color_int_passthrough() -> None: + assert parse_color_int(0x00FF00) == 0x00FF00 + + +def test_parse_color_signed_conversion() -> None: + result = parse_color_int("#FFFFFFFF") + assert result < 0 + + +def test_parse_color_with_whitespace() -> None: + assert parse_color_int(" #FF0000 ") == parse_color_int("#FF0000") + + +# ====================================================================== +# resolve_padding +# ====================================================================== + + +def test_resolve_padding_none() -> None: + assert resolve_padding(None) == (0, 0, 0, 0) + + +def test_resolve_padding_int() -> None: + assert resolve_padding(16) == (16, 16, 16, 16) + + +def test_resolve_padding_float() -> None: + assert resolve_padding(8.5) == (8, 8, 8, 8) + + +def test_resolve_padding_dict_horizontal_vertical() -> None: + result = resolve_padding({"horizontal": 10, "vertical": 20}) + assert result == (10, 20, 10, 20) + + +def test_resolve_padding_dict_individual() -> None: + result = resolve_padding({"left": 1, "top": 2, "right": 3, "bottom": 4}) + assert result == (1, 2, 3, 4) + + +def test_resolve_padding_dict_all() -> None: + result = resolve_padding({"all": 12}) + assert result == (12, 12, 12, 12) + + +def test_resolve_padding_unsupported_type() -> None: + assert resolve_padding("invalid") == (0, 0, 0, 0) + + +# ====================================================================== +# is_vertical +# ====================================================================== + + +def test_is_vertical_column() -> None: + assert is_vertical("column") is True + + +def test_is_vertical_column_reverse() -> None: + assert is_vertical("column_reverse") is True + + +def test_is_vertical_row() -> None: + assert is_vertical("row") is False + + +def test_is_vertical_row_reverse() -> None: + assert is_vertical("row_reverse") is False + + +# ====================================================================== +# LAYOUT_KEYS / CONTAINER_KEYS +# ====================================================================== + + +def test_layout_keys_contains_expected() -> None: + expected = { + "width", + "height", + "flex", + "flex_grow", + "flex_shrink", + "margin", + "min_width", + "max_width", + "min_height", + "max_height", + "align_self", + "position", + "top", + "right", + "bottom", + "left", + } + assert expected == LAYOUT_KEYS + + +def test_container_keys_contains_expected() -> None: + expected = { + "flex_direction", + "justify_content", + "align_items", + "overflow", + "spacing", + "padding", + "background_color", + } + assert expected == CONTAINER_KEYS + + +# ====================================================================== +# ViewHandler protocol +# ====================================================================== + + +def test_view_handler_create_raises() -> None: + handler = ViewHandler() + with pytest.raises(NotImplementedError): + handler.create({}) + + +def test_view_handler_update_raises() -> None: + handler = ViewHandler() + with pytest.raises(NotImplementedError): + handler.update(None, {}) + + +def test_view_handler_add_child_noop() -> None: + handler = ViewHandler() + handler.add_child(None, None) + + +def test_view_handler_remove_child_noop() -> None: + handler = ViewHandler() + handler.remove_child(None, None) + + +def test_view_handler_insert_child_delegates() -> None: + calls: list = [] + + class TestHandler(ViewHandler): + def add_child(self, parent: Any, child: Any) -> None: + calls.append(("add", parent, child)) + + handler = TestHandler() + handler.insert_child("parent", "child", 0) + assert calls == [("add", "parent", "child")] + + +# ====================================================================== +# NativeViewRegistry +# ====================================================================== + + +class StubView: + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + self.type_name = type_name + self.props = dict(props) + + +class StubHandler(ViewHandler): + def create(self, props: Dict[str, Any]) -> StubView: + return StubView("Stub", props) + + def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None: + native_view.props.update(changed_props) + + +def test_registry_create_view() -> None: + reg = NativeViewRegistry() + reg.register("Text", StubHandler()) + view = reg.create_view("Text", {"text": "hello"}) + assert isinstance(view, StubView) + assert view.props["text"] == "hello" + + +def test_registry_unknown_type_raises() -> None: + reg = NativeViewRegistry() + with pytest.raises(ValueError, match="Unknown element type"): + reg.create_view("NonExistent", {}) + + +def test_registry_update_view() -> None: + reg = NativeViewRegistry() + reg.register("Text", StubHandler()) + view = reg.create_view("Text", {"text": "old"}) + reg.update_view(view, "Text", {"text": "new"}) + assert view.props["text"] == "new" + + +def test_registry_update_unknown_type_noop() -> None: + reg = NativeViewRegistry() + reg.update_view(StubView("X", {}), "X", {"a": 1}) + + +def test_registry_child_ops_unknown_type_noop() -> None: + reg = NativeViewRegistry() + reg.add_child(None, None, "Unknown") + reg.remove_child(None, None, "Unknown") + reg.insert_child(None, None, "Unknown", 0) + + +def test_set_registry_injects() -> None: + reg = NativeViewRegistry() + set_registry(reg) + from pythonnative.native_views import _registry + + assert _registry is reg + set_registry(None) diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..f45ca55 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,846 @@ +"""Comprehensive tests for the declarative navigation system.""" + +from typing import Any, Dict, List + +import pytest + +from pythonnative.element import Element +from pythonnative.hooks import HookState, _NavigationContext, _set_hook_state, component, use_navigation +from pythonnative.navigation import ( + NavigationContainer, + _build_screen_map, + _DeclarativeNavHandle, + _DrawerNavHandle, + _FocusContext, + _RouteEntry, + _ScreenDef, + _TabNavHandle, + create_drawer_navigator, + create_stack_navigator, + create_tab_navigator, + use_focus_effect, + use_route, +) +from pythonnative.reconciler import Reconciler + +# ====================================================================== +# Mock backend (same as test_reconciler / test_hooks) +# ====================================================================== + + +class MockView: + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + MockView._next_id += 1 + self.id = MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["MockView"] = [] + + +class MockBackend: + def __init__(self) -> None: + self.ops: List[Any] = [] + + def create_view(self, type_name: str, props: Dict[str, Any]) -> MockView: + view = MockView(type_name, props) + self.ops.append(("create", type_name, view.id)) + return view + + def update_view(self, view: MockView, type_name: str, changed: Dict[str, Any]) -> None: + view.props.update(changed) + self.ops.append(("update", type_name, view.id, tuple(sorted(changed.keys())))) + + def add_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children.append(child) + self.ops.append(("add_child", parent.id, child.id)) + + def remove_child(self, parent: MockView, child: MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + self.ops.append(("remove_child", parent.id, child.id)) + + def insert_child(self, parent: MockView, child: MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + self.ops.append(("insert_child", parent.id, child.id, index)) + + +# ====================================================================== +# Data structures +# ====================================================================== + + +def test_screen_def_creation() -> None: + s = _ScreenDef("Home", lambda: None, {"title": "Home"}) + assert s.name == "Home" + assert s.options == {"title": "Home"} + assert "Home" in repr(s) + + +def test_screen_def_defaults() -> None: + s = _ScreenDef("Detail", lambda: None) + assert s.options == {} + + +def test_route_entry() -> None: + r = _RouteEntry("Home", {"id": 42}) + assert r.name == "Home" + assert r.params == {"id": 42} + assert "Home" in repr(r) + + +def test_route_entry_defaults() -> None: + r = _RouteEntry("Home") + assert r.params == {} + + +def test_build_screen_map() -> None: + screens = [ + _ScreenDef("A", lambda: None), + _ScreenDef("B", lambda: None), + "not a screen", + ] + result = _build_screen_map(screens) + assert set(result.keys()) == {"A", "B"} + + +def test_build_screen_map_empty() -> None: + assert _build_screen_map(None) == {} + assert _build_screen_map([]) == {} + + +# ====================================================================== +# _DeclarativeNavHandle +# ====================================================================== + + +def test_declarative_handle_navigate() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home")] + screens = {"Home": _ScreenDef("Home", lambda: None), "Detail": _ScreenDef("Detail", lambda: None)} + + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle(screens, lambda: stack, set_stack) + handle.navigate("Detail", {"id": 5}) + + assert len(captured) == 1 + assert captured[0][-1].name == "Detail" + assert captured[0][-1].params == {"id": 5} + + +def test_declarative_handle_go_back() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home"), _RouteEntry("Detail")] + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle({}, lambda: stack, set_stack) + handle.go_back() + + assert len(captured[-1]) == 1 + assert captured[-1][0].name == "Home" + + +def test_declarative_handle_go_back_stops_at_root() -> None: + stack: List[_RouteEntry] = [_RouteEntry("Home")] + captured: list = [] + + def set_stack(val: Any) -> None: + nonlocal stack + if callable(val): + stack = val(stack) + else: + stack = val + captured.append(list(stack)) + + handle = _DeclarativeNavHandle({}, lambda: stack, set_stack) + handle.go_back() + + assert captured == [] + assert len(stack) == 1 + assert stack[0].name == "Home" + + +def test_declarative_handle_get_params() -> None: + stack = [_RouteEntry("Home"), _RouteEntry("Detail", {"id": 42})] + handle = _DeclarativeNavHandle({}, lambda: stack, lambda _: None) + + assert handle.get_params() == {"id": 42} + + +def test_declarative_handle_get_params_empty_stack() -> None: + handle = _DeclarativeNavHandle({}, lambda: [], lambda _: None) + assert handle.get_params() == {} + + +def test_declarative_handle_reset() -> None: + stack: List[_RouteEntry] = [_RouteEntry("A"), _RouteEntry("B"), _RouteEntry("C")] + screens = {"Home": _ScreenDef("Home", lambda: None)} + captured: list = [] + + def set_stack(val: Any) -> None: + captured.append(val) + + handle = _DeclarativeNavHandle(screens, lambda: stack, set_stack) + handle.reset("Home", {"fresh": True}) + + assert len(captured) == 1 + assert len(captured[0]) == 1 + assert captured[0][0].name == "Home" + + +def test_declarative_handle_navigate_unknown_raises() -> None: + handle = _DeclarativeNavHandle({"Home": _ScreenDef("Home", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.navigate("Missing") + + +def test_declarative_handle_reset_unknown_raises() -> None: + handle = _DeclarativeNavHandle({"Home": _ScreenDef("Home", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.reset("Missing") + + +# ====================================================================== +# Tab nav handle +# ====================================================================== + + +def test_tab_handle_navigate_switches_tab() -> None: + switched: list = [] + screens = {"A": _ScreenDef("A", lambda: None), "B": _ScreenDef("B", lambda: None)} + + def switch_tab(name: str, params: Any = None) -> None: + switched.append((name, params)) + + handle = _TabNavHandle(screens, lambda: [], lambda _: None, switch_tab) + handle.navigate("B", {"x": 1}) + + assert switched == [("B", {"x": 1})] + + +# ====================================================================== +# Drawer nav handle +# ====================================================================== + + +def test_drawer_handle_open_close_toggle() -> None: + drawer_state = [False] + + def set_open(val: bool) -> None: + drawer_state[0] = val + + screens = {"A": _ScreenDef("A", lambda: None)} + + def noop_switch(n: str, p: Any = None) -> None: + pass + + handle = _DrawerNavHandle(screens, lambda: [], lambda _: None, noop_switch, set_open, lambda: drawer_state[0]) + + handle.open_drawer() + assert drawer_state[0] is True + + handle.close_drawer() + assert drawer_state[0] is False + + handle.toggle_drawer() + assert drawer_state[0] is True + + handle.toggle_drawer() + assert drawer_state[0] is False + + +def test_drawer_handle_navigate_closes_drawer() -> None: + drawer_state = [True] + switched: list = [] + + def set_open(val: bool) -> None: + drawer_state[0] = val + + def switch_screen(name: str, params: Any = None) -> None: + switched.append(name) + + screens = {"A": _ScreenDef("A", lambda: None), "B": _ScreenDef("B", lambda: None)} + handle = _DrawerNavHandle(screens, lambda: [], lambda _: None, switch_screen, set_open, lambda: drawer_state[0]) + + handle.navigate("B") + assert switched == ["B"] + assert drawer_state[0] is False + + +# ====================================================================== +# NavigationContainer +# ====================================================================== + + +def test_navigation_container_wraps_child() -> None: + child = Element("Text", {"text": "hi"}, []) + el = NavigationContainer(child) + assert el.type == "View" + assert el.props.get("flex") == 1 + assert len(el.children) == 1 + assert el.children[0] is child + + +def test_navigation_container_with_key() -> None: + child = Element("Text", {"text": "hi"}, []) + el = NavigationContainer(child, key="nav") + assert el.key == "nav" + + +# ====================================================================== +# create_stack_navigator +# ====================================================================== + + +def test_stack_screen_creates_screen_def() -> None: + Stack = create_stack_navigator() + s = Stack.Screen("Home", component=lambda: None, options={"title": "Home"}) + assert isinstance(s, _ScreenDef) + assert s.name == "Home" + + +def test_stack_navigator_element() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) + assert isinstance(el, Element) + assert callable(el.type) + + +def test_stack_navigator_renders_initial_screen() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + root = rec.mount(el) + + assert any(op[0] == "create" and op[1] == "Text" for op in backend.ops) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "home" + + +def test_stack_navigator_respects_initial_route() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + initial_route="Detail", + ) + root = rec.mount(el) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "detail" + + +def test_stack_navigator_provides_navigation_context() -> None: + Stack = create_stack_navigator() + captured_nav: list = [None] + + @component + def HomeScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "home"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) + rec.mount(el) + + assert captured_nav[0] is not None + assert hasattr(captured_nav[0], "navigate") + assert hasattr(captured_nav[0], "go_back") + assert hasattr(captured_nav[0], "get_params") + + +def test_stack_navigator_empty_screens() -> None: + Stack = create_stack_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# create_tab_navigator +# ====================================================================== + + +def test_tab_screen_creates_screen_def() -> None: + Tab = create_tab_navigator() + s = Tab.Screen("Home", component=lambda: None, options={"title": "Home"}) + assert isinstance(s, _ScreenDef) + + +def test_tab_navigator_renders_initial_screen() -> None: + Tab = create_tab_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def SettingsScreen() -> Element: + return Element("Text", {"text": "settings"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + ) + root = rec.mount(el) + + def find_texts(view: MockView) -> list: + result = [] + if view.type_name == "Text": + result.append(view.props.get("text")) + for c in view.children: + result.extend(find_texts(c)) + return result + + texts = find_texts(root) + assert "home" in texts + + +def test_tab_navigator_renders_native_tab_bar() -> None: + Tab = create_tab_navigator() + + @component + def ScreenA() -> Element: + return Element("Text", {"text": "a"}, []) + + @component + def ScreenB() -> Element: + return Element("Text", {"text": "b"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("TabA", component=ScreenA, options={"title": "Tab A"}), + Tab.Screen("TabB", component=ScreenB, options={"title": "Tab B"}), + ) + root = rec.mount(el) + + def find_tab_bar(view: MockView) -> Any: + if view.type_name == "TabBar": + return view + for c in view.children: + r = find_tab_bar(c) + if r is not None: + return r + return None + + tab_bar = find_tab_bar(root) + assert tab_bar is not None + assert tab_bar.props["items"] == [ + {"name": "TabA", "title": "Tab A"}, + {"name": "TabB", "title": "Tab B"}, + ] + assert tab_bar.props["active_tab"] == "TabA" + assert callable(tab_bar.props["on_tab_select"]) + + +def test_tab_navigator_empty_screens() -> None: + Tab = create_tab_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# create_drawer_navigator +# ====================================================================== + + +def test_drawer_screen_creates_screen_def() -> None: + Drawer = create_drawer_navigator() + s = Drawer.Screen("Home", component=lambda: None) + assert isinstance(s, _ScreenDef) + + +def test_drawer_navigator_renders_initial_screen() -> None: + Drawer = create_drawer_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def SettingsScreen() -> Element: + return Element("Text", {"text": "settings"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Drawer.Navigator( + Drawer.Screen("Home", component=HomeScreen), + Drawer.Screen("Settings", component=SettingsScreen), + ) + root = rec.mount(el) + + def find_texts(view: MockView) -> list: + result = [] + if view.type_name == "Text": + result.append(view.props.get("text")) + for c in view.children: + result.extend(find_texts(c)) + return result + + texts = find_texts(root) + assert "home" in texts + + +def test_drawer_navigator_empty_screens() -> None: + Drawer = create_drawer_navigator() + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Drawer.Navigator() + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# use_route +# ====================================================================== + + +def test_use_route_returns_params() -> None: + stack = [_RouteEntry("Home"), _RouteEntry("Detail", {"id": 99})] + screens = {"Home": _ScreenDef("Home", lambda: None), "Detail": _ScreenDef("Detail", lambda: None)} + handle = _DeclarativeNavHandle(screens, lambda: stack, lambda _: None) + + _NavigationContext._stack.append(handle) + ctx = HookState() + _set_hook_state(ctx) + try: + params = use_route() + assert params == {"id": 99} + finally: + _set_hook_state(None) + _NavigationContext._stack.pop() + + +def test_use_route_no_context() -> None: + ctx = HookState() + _set_hook_state(ctx) + try: + params = use_route() + assert params == {} + finally: + _set_hook_state(None) + + +# ====================================================================== +# use_focus_effect +# ====================================================================== + + +def test_use_focus_effect_runs_when_focused() -> None: + calls: list = [] + + _FocusContext._stack.append(True) + ctx = HookState() + _set_hook_state(ctx) + try: + use_focus_effect(lambda: calls.append("focused"), []) + finally: + _set_hook_state(None) + _FocusContext._stack.pop() + + ctx.flush_pending_effects() + assert calls == ["focused"] + + +def test_use_focus_effect_skips_when_not_focused() -> None: + calls: list = [] + + _FocusContext._stack.append(False) + ctx = HookState() + _set_hook_state(ctx) + try: + use_focus_effect(lambda: calls.append("focused"), []) + finally: + _set_hook_state(None) + _FocusContext._stack.pop() + + ctx.flush_pending_effects() + assert calls == [] + + +# ====================================================================== +# Integration: stack navigator with reconciler +# ====================================================================== + + +def test_stack_navigator_navigate_and_go_back() -> None: + Stack = create_stack_navigator() + captured_nav: list = [None] + + @component + def HomeScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "detail"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + renders: list = [] + rec._page_re_render = lambda: renders.append(1) + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + rec.mount(el) + + nav = captured_nav[0] + assert nav is not None + + nav.navigate("Detail", {"id": 1}) + assert len(renders) == 1 + + +def test_stack_navigator_with_navigation_container() -> None: + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = NavigationContainer(Stack.Navigator(Stack.Screen("Home", component=HomeScreen))) + root = rec.mount(el) + assert root.type_name == "View" + + +# ====================================================================== +# Parent forwarding (nested navigators) +# ====================================================================== + + +def test_declarative_handle_forwards_navigate_to_parent() -> None: + """Unknown routes in a child navigator forward to the parent.""" + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + child_screens = {"A": _ScreenDef("A", lambda: None)} + handle = _DeclarativeNavHandle(child_screens, lambda: [_RouteEntry("A")], lambda _: None, parent=_MockParent()) + + handle.navigate("UnknownRoute", {"key": "value"}) + assert parent_calls == [("navigate", "UnknownRoute", {"key": "value"})] + + +def test_declarative_handle_forwards_go_back_at_root() -> None: + """go_back at the root of a child navigator forwards to the parent.""" + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + child_screens = {"A": _ScreenDef("A", lambda: None)} + stack: List[_RouteEntry] = [_RouteEntry("A")] + handle = _DeclarativeNavHandle(child_screens, lambda: stack, lambda _: None, parent=_MockParent()) + + handle.go_back() + assert parent_calls == [("go_back",)] + + +def test_declarative_handle_no_parent_raises_on_unknown() -> None: + """Without a parent, unknown routes still raise ValueError.""" + handle = _DeclarativeNavHandle({"A": _ScreenDef("A", lambda: None)}, lambda: [], lambda _: None) + with pytest.raises(ValueError, match="Unknown route"): + handle.navigate("Missing") + + +def test_tab_handle_forwards_unknown_to_parent() -> None: + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + screens = {"TabA": _ScreenDef("TabA", lambda: None)} + + def noop_switch(name: str, params: Any = None) -> None: + pass + + handle = _TabNavHandle(screens, lambda: [], lambda _: None, noop_switch, parent=_MockParent()) + handle.navigate("ExternalRoute", {"x": 1}) + assert parent_calls == [("navigate", "ExternalRoute", {"x": 1})] + + +def test_drawer_handle_forwards_unknown_to_parent() -> None: + parent_calls: list = [] + + class _MockParent: + def navigate(self, route_name: str, params: Any = None) -> None: + parent_calls.append(("navigate", route_name, params)) + + def go_back(self) -> None: + parent_calls.append(("go_back",)) + + screens = {"DrawerA": _ScreenDef("DrawerA", lambda: None)} + + def noop_switch(n: str, p: Any = None) -> None: + pass + + handle = _DrawerNavHandle( + screens, lambda: [], lambda _: None, noop_switch, lambda _: None, lambda: False, parent=_MockParent() + ) + handle.navigate("ExternalRoute") + assert parent_calls == [("navigate", "ExternalRoute", None)] + + +def test_stack_inside_tab_forwards_to_parent() -> None: + """A Stack.Navigator nested inside a Tab.Navigator can forward.""" + Stack = create_stack_navigator() + Tab = create_tab_navigator() + + captured_nav: list = [None] + + @component + def InnerScreen() -> Element: + nav = use_navigation() + captured_nav[0] = nav + return Element("Text", {"text": "inner"}, []) + + @component + def InnerStack() -> Element: + return Stack.Navigator(Stack.Screen("Inner", component=InnerScreen)) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Tab.Navigator( + Tab.Screen("TabA", component=InnerStack), + Tab.Screen("TabB", component=lambda: Element("Text", {"text": "b"}, [])), + ) + rec.mount(el) + + nav = captured_nav[0] + assert nav is not None + + nav.navigate("TabB") + assert True # no error means forwarding worked + + +# ====================================================================== +# Public API surface +# ====================================================================== + + +def test_navigation_exports_from_package() -> None: + import pythonnative as pn + + assert hasattr(pn, "NavigationContainer") + assert hasattr(pn, "create_stack_navigator") + assert hasattr(pn, "create_tab_navigator") + assert hasattr(pn, "create_drawer_navigator") + assert hasattr(pn, "use_route") + assert hasattr(pn, "use_focus_effect") diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index a97ecc0..08ac250 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -2,7 +2,10 @@ from typing import Any, Dict, List +import pytest + from pythonnative.element import Element +from pythonnative.hooks import component from pythonnative.reconciler import Reconciler # ====================================================================== @@ -290,7 +293,7 @@ def test_keyed_children_preserve_identity() -> None: Element("Text", {"text": "C"}, [], key="c"), ], ) - rec.mount(el1) + root = rec.mount(el1) view_a = rec._tree.children[0].native_view view_b = rec._tree.children[1].native_view view_c = rec._tree.children[2].native_view @@ -311,6 +314,11 @@ def test_keyed_children_preserve_identity() -> None: assert rec._tree.children[1].native_view is view_a assert rec._tree.children[2].native_view is view_b + # Native children must also reflect the new order + assert root.children[0] is view_c + assert root.children[1] is view_a + assert root.children[2] is view_b + def test_keyed_children_remove_by_key() -> None: backend = MockBackend() @@ -369,3 +377,188 @@ def test_keyed_children_insert_new() -> None: assert len(rec._tree.children) == 3 assert rec._tree.children[1].element.key == "b" + + +# ====================================================================== +# Tests: error boundaries +# ====================================================================== + + +def test_error_boundary_catches_mount_error() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad_component(**props: Any) -> Element: + raise ValueError("boom") + + fallback = Element("Text", {"text": "error caught"}, []) + child = Element(bad_component, {}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert root.props["text"] == "error caught" + + +def test_error_boundary_callable_fallback() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad_component(**props: Any) -> Element: + raise RuntimeError("oops") + + def fallback_fn(exc: Exception) -> Element: + return Element("Text", {"text": f"caught: {exc}"}, []) + + child = Element(bad_component, {}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert "caught: oops" in root.props["text"] + + +def test_error_boundary_no_error_renders_child() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + child = Element("Text", {"text": "ok"}, []) + fallback = Element("Text", {"text": "error"}, []) + eb = Element("__ErrorBoundary__", {"__fallback__": fallback}, [child]) + + root = rec.mount(eb) + assert root.type_name == "Text" + assert root.props["text"] == "ok" + + +def test_error_boundary_catches_reconcile_error() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + call_count = [0] + + @component + def flaky() -> Element: + call_count[0] += 1 + if call_count[0] > 1: + raise RuntimeError("reconcile boom") + return Element("Text", {"text": "ok"}, []) + + def fallback_fn(exc: Exception) -> Element: + return Element("Text", {"text": f"recovered: {exc}"}, []) + + eb1 = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [flaky()]) + root = rec.mount(eb1) + assert root.props["text"] == "ok" + + eb2 = Element("__ErrorBoundary__", {"__fallback__": fallback_fn}, [flaky()]) + root = rec.reconcile(eb2) + assert "recovered" in root.props["text"] + + +def test_error_boundary_without_fallback_propagates() -> None: + backend = MockBackend() + rec = Reconciler(backend) + + def bad(**props: Any) -> Element: + raise ValueError("no fallback") + + child = Element(bad, {}, []) + eb = Element("__ErrorBoundary__", {}, [child]) + + with pytest.raises(ValueError, match="no fallback"): + rec.mount(eb) + + +# ====================================================================== +# Tests: post-render effect flushing +# ====================================================================== + + +def test_effects_flushed_after_mount() -> None: + calls: list = [] + + @component + def my_comp() -> Element: + from pythonnative.hooks import use_effect + + use_effect(lambda: calls.append("mounted"), []) + return Element("Text", {"text": "hi"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp()) + assert calls == ["mounted"] + + +def test_effects_flushed_after_reconcile() -> None: + calls: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + from pythonnative.hooks import use_effect + + use_effect(lambda: calls.append(f"e{dep}"), [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=1)) + assert calls == ["e1"] + + rec.reconcile(my_comp(dep=2)) + assert calls == ["e1", "e2"] + + +def test_effect_cleanup_runs_on_rerun() -> None: + log: list = [] + + @component + def my_comp(dep: int = 0) -> Element: + from pythonnative.hooks import use_effect + + def effect() -> Any: + log.append(f"run-{dep}") + return lambda: log.append(f"cleanup-{dep}") + + use_effect(effect, [dep]) + return Element("Text", {"text": str(dep)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(dep=1)) + assert log == ["run-1"] + + rec.reconcile(my_comp(dep=2)) + assert log == ["run-1", "cleanup-1", "run-2"] + + +def test_provider_child_native_view_swap() -> None: + """When a Provider wraps different component types across renders, + the parent native container must swap the old native subview for the new one.""" + from pythonnative.hooks import Provider, create_context + + ctx = create_context(None) + + @component + def CompA() -> Element: + return Element("Text", {"text": "A"}, []) + + @component + def CompB() -> Element: + return Element("Text", {"text": "B"}, []) + + backend = MockBackend() + rec = Reconciler(backend) + + tree1 = Element("View", {}, [Provider(ctx, "v1", CompA())]) + root = rec.mount(tree1) + assert len(root.children) == 1 + assert root.children[0].props["text"] == "A" + old_child_id = root.children[0].id + + tree2 = Element("View", {}, [Provider(ctx, "v2", CompB())]) + rec.reconcile(tree2) + assert len(root.children) == 1 + assert root.children[0].props["text"] == "B" + assert root.children[0].id != old_child_id diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 24bcf62..23c865a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -18,6 +18,7 @@ def test_public_api_names() -> None: "Button", "Column", "Element", + "ErrorBoundary", "FlatList", "Image", "Modal", @@ -36,16 +37,25 @@ def test_public_api_names() -> None: # Core "create_page", # Hooks + "batch_updates", "component", "create_context", "use_callback", "use_context", "use_effect", + "use_focus_effect", "use_memo", "use_navigation", + "use_reducer", "use_ref", + "use_route", "use_state", "Provider", + # Navigation + "NavigationContainer", + "create_drawer_navigator", + "create_stack_navigator", + "create_tab_navigator", # Styling "StyleSheet", "ThemeContext",