From a42f9fa18f6ae5b87e7cd63adf8b07c4fec432b3 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 19 May 2026 11:32:24 -0700 Subject: [PATCH 1/3] feat(components,hooks)!: add props, fragment, memo, and native picker --- docs/api/pythonnative.md | 6 +- docs/api/sdk.md | 2 +- docs/concepts/components.md | 14 +- docs/concepts/hooks.md | 37 + docs/guides/animations.md | 13 +- docs/guides/platform-accessibility.md | 2 +- examples/hello-world/app/screens/settings.py | 2 +- examples/hello-world/app/screens/showcase.py | 29 +- src/pythonnative/__init__.py | 44 +- src/pythonnative/animated.py | 38 + src/pythonnative/components.py | 1176 +++++++++++------- src/pythonnative/hooks.py | 94 +- src/pythonnative/native_views/android.py | 99 +- src/pythonnative/native_views/base.py | 95 +- src/pythonnative/native_views/ios.py | 123 +- src/pythonnative/reconciler.py | 137 +- src/pythonnative/sdk/__init__.py | 3 +- tests/test_animated.py | 79 +- tests/test_components.py | 34 + tests/test_hooks.py | 113 ++ tests/test_native_views.py | 59 - tests/test_new_components.py | 37 +- tests/test_reconciler.py | 62 + 23 files changed, 1628 insertions(+), 670 deletions(-) diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 44de382..e08a42c 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -36,9 +36,9 @@ The reference is split per module so each page stays scannable: | Area | Page | Key symbols | |---|---|---| -| Element factories | [Components](components.md) | [`Text`][pythonnative.Text], [`Button`][pythonnative.Button], [`Column`][pythonnative.Column], [`Row`][pythonnative.Row], [`ScrollView`][pythonnative.ScrollView], [`FlatList`][pythonnative.FlatList], [`SectionList`][pythonnative.SectionList], [`Modal`][pythonnative.Modal], [`Pressable`][pythonnative.Pressable], [`StatusBar`][pythonnative.StatusBar], [`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView], [`RefreshControl`][pythonnative.RefreshControl], [`Picker`][pythonnative.Picker], [`ErrorBoundary`][pythonnative.ErrorBoundary] | -| Hooks | [Hooks](hooks.md) | [`use_state`][pythonnative.use_state], [`use_reducer`][pythonnative.use_reducer], [`use_effect`][pythonnative.use_effect], [`use_memo`][pythonnative.use_memo], [`use_ref`][pythonnative.use_ref], [`use_context`][pythonnative.use_context], [`use_window_dimensions`][pythonnative.use_window_dimensions], [`use_safe_area_insets`][pythonnative.use_safe_area_insets], [`use_keyboard_height`][pythonnative.use_keyboard_height] | -| Animations | [Animated](animated.md) | `Animated`, [`AnimatedValue`][pythonnative.AnimatedValue] | +| Element factories | [Components](components.md) | [`Text`][pythonnative.Text], [`Button`][pythonnative.Button], [`Column`][pythonnative.Column], [`Row`][pythonnative.Row], [`ScrollView`][pythonnative.ScrollView], [`FlatList`][pythonnative.FlatList], [`SectionList`][pythonnative.SectionList], [`Modal`][pythonnative.Modal], [`Pressable`][pythonnative.Pressable], [`StatusBar`][pythonnative.StatusBar], [`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView], [`RefreshControl`][pythonnative.RefreshControl], [`Picker`][pythonnative.Picker], [`Fragment`][pythonnative.Fragment], [`ErrorBoundary`][pythonnative.ErrorBoundary] | +| Hooks | [Hooks](hooks.md) | [`use_state`][pythonnative.use_state], [`use_reducer`][pythonnative.use_reducer], [`use_effect`][pythonnative.use_effect], [`use_memo`][pythonnative.use_memo], [`use_ref`][pythonnative.use_ref], [`use_context`][pythonnative.use_context], [`use_window_dimensions`][pythonnative.use_window_dimensions], [`use_safe_area_insets`][pythonnative.use_safe_area_insets], [`use_keyboard_height`][pythonnative.use_keyboard_height], [`memo`][pythonnative.memo] | +| Animations | [Animated](animated.md) | `Animated`, [`AnimatedValue`][pythonnative.AnimatedValue], [`use_animated_value`][pythonnative.use_animated_value] | | System dialogs | [Alerts](alerts.md) | [`Alert`][pythonnative.Alert] | | Platform | [Platform](platform.md) | [`Platform`][pythonnative.Platform] | | Navigation | [Navigation](navigation.md) | [`NavigationContainer`][pythonnative.NavigationContainer], [`create_stack_navigator`][pythonnative.create_stack_navigator], [`create_tab_navigator`][pythonnative.create_tab_navigator], [`create_drawer_navigator`][pythonnative.create_drawer_navigator], [`use_navigation`][pythonnative.use_navigation] | diff --git a/docs/api/sdk.md b/docs/api/sdk.md index a82b02d..c1d7799 100644 --- a/docs/api/sdk.md +++ b/docs/api/sdk.md @@ -23,7 +23,7 @@ convenience and are documented on their canonical pages: | [`Element`][pythonnative.element.Element] | [Element](element.md) | | [`ViewHandler`][pythonnative.native_views.base.ViewHandler] | [Native views](native_views.md) | | [`Style`][pythonnative.style.Style], [`StyleProp`][pythonnative.style.StyleProp], [`Color`][pythonnative.style.Color], [`Dimension`][pythonnative.style.Dimension], [`EdgeInsets`][pythonnative.style.EdgeInsets], [`EdgeValue`][pythonnative.style.EdgeValue], `FlexDirection`, `JustifyContent`, `Overflow`, `Position`, [`TransformSpec`][pythonnative.style.TransformSpec], [`style`][pythonnative.style.style] | [Style](style.md) | -| `parse_color_int`, `resolve_padding` | `pythonnative.native_views.base` | +| `parse_color_int` | `pythonnative.native_views.base` | ## Custom-component primitives diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 8ecd3ce..ce65986 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -75,6 +75,12 @@ pn.Column( - [`ErrorBoundary(child, fallback)`][pythonnative.ErrorBoundary]: catches render errors in child and displays fallback. +**Composition:** + +- [`Fragment(*children)`][pythonnative.Fragment]: group siblings into a + parent's child list without an extra wrapping view (analogous to + React's `<>…`). + **Lists:** - [`FlatList(data, render_item, key_extractor, item_height, ...)`][pythonnative.FlatList]: @@ -86,7 +92,7 @@ pn.Column( **Platform UI:** -- [`StatusBar(style, background_color, hidden)`][pythonnative.StatusBar]: +- [`StatusBar(bar_style, background_color, hidden)`][pythonnative.StatusBar]: configure the device's status bar (light/dark icons, color, hidden). - [`KeyboardAvoidingView(*children, behavior)`][pythonnative.KeyboardAvoidingView]: shift content up when the software keyboard appears. @@ -249,6 +255,9 @@ hook state. persists across renders. When passed via the `ref=` prop, the reconciler populates `ref["current"]` with the underlying native view. +- [`use_animated_value(initial)`][pythonnative.use_animated_value]: + stable [`AnimatedValue`][pythonnative.AnimatedValue] across renders; + the canonical way to drive `Animated.View`. - [`use_context(context)`][pythonnative.use_context]: read from a context provider. - [`use_navigation()`][pythonnative.use_navigation]: navigation @@ -263,6 +272,9 @@ hook state. reactive safe-area insets. - [`use_keyboard_height()`][pythonnative.use_keyboard_height]: reactive software-keyboard height. +- [`@memo`][pythonnative.memo]: decorator that skips a function + component's re-render when its props are shallowly equal and its + internal state is unchanged. ### Custom hooks diff --git a/docs/concepts/hooks.md b/docs/concepts/hooks.md index 0a9eb54..b32b40f 100644 --- a/docs/concepts/hooks.md +++ b/docs/concepts/hooks.md @@ -209,6 +209,17 @@ render_count = pn.use_ref(0) render_count["current"] += 1 ``` +### use_animated_value + +Create an [`AnimatedValue`][pythonnative.AnimatedValue] that's stable +across renders. Equivalent to wrapping `pn.Animated.Value(initial)` in +`use_memo(..., [])` but more discoverable: + +```python +opacity = pn.use_animated_value(0.0) +pn.Animated.timing(opacity, to=1.0, duration=300).start() +``` + ### use_context Read a value from the nearest `Provider` ancestor: @@ -267,6 +278,32 @@ automatically batched; the framework drains any pending re-renders after effect flushing completes, so you don't need `batch_updates()` inside effects. +## Memoizing function components + +Wrap a function component with [`@pn.memo`][pythonnative.memo] to skip +its body when neither its props nor its internal state have changed: + +```python +@pn.memo +@pn.component +def ExpensiveRow(label: str, value: int): + return pn.Row( + pn.Text(label, style={"flex": 1}), + pn.Text(str(value)), + ) +``` + +When a `memo`'d component is reconciled, the reconciler compares the +new props against the previous props using shallow equality. If they +match and none of the component's `use_state` / `use_reducer` setters +have fired since the last render, the previously-rendered subtree is +reused and the component body is not re-executed. This is the +component-level equivalent of [`use_memo`][pythonnative.use_memo]. + +`memo` is typically used on pure, prop-driven leaves that re-render +frequently as part of a larger tree, e.g. rows inside a list whose +identity doesn't change between renders of the parent. + ## Error boundaries Wrap risky components in diff --git a/docs/guides/animations.md b/docs/guides/animations.md index ff50154..b2fe640 100644 --- a/docs/guides/animations.md +++ b/docs/guides/animations.md @@ -8,8 +8,9 @@ frame. ## Mental model -1. Create an [`AnimatedValue`][pythonnative.AnimatedValue] using - [`use_memo`][pythonnative.use_memo] (so it survives re-renders). +1. Create an [`AnimatedValue`][pythonnative.AnimatedValue] with + [`use_animated_value`][pythonnative.use_animated_value] (so it + survives re-renders). 2. Bind the value into the `style` of an `Animated.View`, `Animated.Text`, or `Animated.Image`. 3. Drive the value with `Animated.timing`, `Animated.spring`, or @@ -31,7 +32,7 @@ import pythonnative as pn @pn.component def FadeInBox(): - opacity = pn.use_memo(lambda: pn.Animated.Value(0.0), []) + opacity = pn.use_animated_value(0.0) def _fade_in(): pn.Animated.timing(opacity, to=1.0, duration=400).start() @@ -57,7 +58,7 @@ def FadeInBox(): ```python @pn.component def Bouncy(): - scale = pn.use_memo(lambda: pn.Animated.Value(1.0), []) + scale = pn.use_animated_value(1.0) def _press(): pn.Animated.spring(scale, to=1.2, stiffness=200, damping=8).start() @@ -79,8 +80,8 @@ animation property. ## Sequencing and parallel composition ```python -opacity = pn.Animated.Value(0.0) -translate_y = pn.Animated.Value(20.0) +opacity = pn.use_animated_value(0.0) +translate_y = pn.use_animated_value(20.0) pn.Animated.parallel([ pn.Animated.timing(opacity, to=1.0, duration=300), diff --git a/docs/guides/platform-accessibility.md b/docs/guides/platform-accessibility.md index 2d30fdb..d00b0bc 100644 --- a/docs/guides/platform-accessibility.md +++ b/docs/guides/platform-accessibility.md @@ -55,7 +55,7 @@ Mount [`StatusBar`][pythonnative.StatusBar] anywhere in the tree (it renders nothing visible) to control style and visibility: ```python -pn.StatusBar(style="light", background_color="#000000") +pn.StatusBar(bar_style="light", background_color="#000000") ``` `style` is `"light"` (light icons, dark background), `"dark"` (dark diff --git a/examples/hello-world/app/screens/settings.py b/examples/hello-world/app/screens/settings.py index 2eec057..2d334ad 100644 --- a/examples/hello-world/app/screens/settings.py +++ b/examples/hello-world/app/screens/settings.py @@ -38,7 +38,7 @@ def _view_showcase() -> None: return pn.ScrollView( pn.Column( - pn.StatusBar(style="dark"), + pn.StatusBar(bar_style="dark"), pn.Text("Settings", style=styles["title"]), pn.Text(f"PythonNative v{pn.__version__}", style=styles["subtitle"]), pn.Text( diff --git a/examples/hello-world/app/screens/showcase.py b/examples/hello-world/app/screens/showcase.py index 32334bd..fddec13 100644 --- a/examples/hello-world/app/screens/showcase.py +++ b/examples/hello-world/app/screens/showcase.py @@ -32,9 +32,9 @@ @pn.component def AnimatedCard() -> pn.Element: - """Demonstrates ``Animated.View`` driven by ``AnimatedValue`` + ``use_memo``.""" - opacity = pn.use_memo(lambda: pn.Animated.Value(0.0), []) - scale = pn.use_memo(lambda: pn.Animated.Value(0.9), []) + """Demonstrates ``Animated.View`` driven by ``use_animated_value``.""" + opacity = pn.use_animated_value(0.0) + scale = pn.use_animated_value(0.9) def _enter() -> None: pn.Animated.parallel( @@ -63,8 +63,11 @@ def _enter() -> None: ) +@pn.memo @pn.component def TypographyDemo() -> pn.Element: + """Wrapped in [`pn.memo`][pythonnative.memo] so it skips re-render when parent state changes.""" + print("[TypographyDemo] render (should only appear once)") return pn.Column( pn.Text("Headline", style={"font_size": 28, "font_weight": "700"}), pn.Text( @@ -84,6 +87,7 @@ def TypographyDemo() -> pn.Element: ) +@pn.memo @pn.component def BordersAndShadows() -> pn.Element: return pn.View( @@ -96,6 +100,7 @@ def BordersAndShadows() -> pn.Element: ) +@pn.memo @pn.component def Chips() -> pn.Element: return pn.Row( @@ -112,6 +117,20 @@ def Chips() -> pn.Element: ) +def section_heading(title: str, hint: str) -> pn.Element: + """Compose two sibling [`pn.Text`][pythonnative.Text] nodes via [`pn.Fragment`][pythonnative.Fragment]. + + Returning a Fragment from a plain helper (not a ``@pn.component``) + lets the surrounding parent (here a [`pn.Column`][pythonnative.Column]) + flatten the siblings into its own child list without an extra + wrapper view. + """ + return pn.Fragment( + pn.Text(title, style=styles["section_title"]), + pn.Text(hint, style=styles["hint"]), + ) + + @pn.component def ShowcaseScreen() -> pn.Element: nav = pn.use_navigation() @@ -133,10 +152,10 @@ def go_back() -> None: pn.Column( pn.Text(message, style=styles["title"]), AnimatedCard(), - pn.Text("Typography", style=styles["section_title"]), + section_heading("Typography", "Memoized via @pn.memo; renders only once."), TypographyDemo(), BordersAndShadows(), - pn.Text("Chips", style=styles["section_title"]), + section_heading("Chips", "Composed via pn.Fragment without an extra container."), Chips(), pn.Pressable( pn.View( diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index d99c7e7..02dcb53 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -55,32 +55,51 @@ def App(): from . import sdk from .alerts import Alert -from .animated import Animated, AnimatedValue +from .animated import Animated, AnimatedValue, use_animated_value from .components import ( ActivityIndicator, + ActivityIndicatorProps, Button, + ButtonProps, Column, ErrorBoundary, FlatList, + Fragment, Image, + ImageProps, KeyboardAvoidingView, + KeyboardAvoidingViewProps, Modal, + ModalProps, Picker, + PickerProps, Pressable, + PressableProps, ProgressBar, + ProgressBarProps, RefreshControl, Row, SafeAreaView, + SafeAreaViewProps, ScrollView, + ScrollViewProps, SectionList, Slider, + SliderProps, Spacer, + SpacerProps, StatusBar, + StatusBarProps, Switch, + SwitchProps, Text, TextInput, + TextInputProps, + TextProps, View, + ViewProps, WebView, + WebViewProps, ) from .element import Element from .hooks import ( @@ -88,6 +107,7 @@ def App(): batch_updates, component, create_context, + memo, use_callback, use_context, use_effect, @@ -152,6 +172,7 @@ def App(): "Column", "ErrorBoundary", "FlatList", + "Fragment", "Image", "KeyboardAvoidingView", "Modal", @@ -171,6 +192,25 @@ def App(): "TextInput", "View", "WebView", + # Built-in Props dataclasses + "ActivityIndicatorProps", + "ButtonProps", + "ImageProps", + "KeyboardAvoidingViewProps", + "ModalProps", + "PickerProps", + "PressableProps", + "ProgressBarProps", + "SafeAreaViewProps", + "ScrollViewProps", + "SliderProps", + "SpacerProps", + "StatusBarProps", + "SwitchProps", + "TextInputProps", + "TextProps", + "ViewProps", + "WebViewProps", # Core "Element", "create_screen", @@ -178,6 +218,7 @@ def App(): "batch_updates", "component", "create_context", + "memo", "use_callback", "use_context", "use_effect", @@ -225,6 +266,7 @@ def App(): # Animation "Animated", "AnimatedValue", + "use_animated_value", # Imperative "Alert", # Native modules diff --git a/src/pythonnative/animated.py b/src/pythonnative/animated.py index c3f4b9d..24c8822 100644 --- a/src/pythonnative/animated.py +++ b/src/pythonnative/animated.py @@ -661,7 +661,45 @@ def advance(self, dt: float) -> bool: Animated = _AnimatedNamespace() +def use_animated_value(initial: float = 0.0) -> AnimatedValue: + """Return an [`AnimatedValue`][pythonnative.AnimatedValue] with a stable identity across renders. + + Convenience wrapper for the common pattern + ``pn.use_memo(lambda: AnimatedValue(initial), [])``. The same + instance is returned on every render of the same component, so + you can drive it from event handlers without recreating it. + + Args: + initial: The starting numeric value. + + Returns: + A mount-stable [`AnimatedValue`][pythonnative.AnimatedValue]. + + Example: + ```python + import pythonnative as pn + + @pn.component + def FadeIn(): + opacity = pn.use_animated_value(0.0) + + def fade_in(): + pn.Animated.timing(opacity, to=1.0, duration=300).start() + + pn.use_effect(lambda: fade_in(), []) + return pn.Animated.View( + pn.Text("Hello"), + style=pn.style(opacity=opacity), + ) + ``` + """ + from .hooks import use_memo + + return use_memo(lambda: AnimatedValue(initial), []) + + __all__ = [ "AnimatedValue", "Animated", + "use_animated_value", ] diff --git a/src/pythonnative/components.py b/src/pythonnative/components.py index da66d33..282a113 100644 --- a/src/pythonnative/components.py +++ b/src/pythonnative/components.py @@ -1,43 +1,34 @@ -"""Built-in element factories for declarative UI composition. +"""Built-in element factories and the typed prop schemas they share. -Each function in this module returns an [`Element`][pythonnative.Element] -describing a native UI widget. Element factories are pure data: no -native views are created until the reconciler mounts the element tree. +Each ``@dataclass(frozen=True)`` class in this module — ``TextProps``, +``ButtonProps``, etc. — is the canonical schema for one built-in +component. Each factory function (``Text``, ``Button``, …) is a thin +ergonomic wrapper that builds an [`Element`][pythonnative.Element] +through the shared :func:`_make_element` helper, so style resolution, +``ref`` attachment, ``None``-default dropping, and forced overrides +(e.g. ``Column``'s fixed ``flex_direction``) live in exactly one place. -All visual and layout properties are passed via the `style` parameter, -which accepts a dict or a list of dicts (later entries override -earlier ones; see [`resolve_style`][pythonnative.style.resolve_style]). - -Layout properties supported by every component: - -- `width`, `height`, `flex`, `flex_grow`, `flex_shrink`, `margin`, - `min_width`, `max_width`, `min_height`, `max_height`, `align_self`. - -Flex container properties (`View` / `Column` / `Row`): - -- `flex_direction`, `justify_content`, `align_items`, `overflow`, - `spacing`, `padding`. - -[`View`][pythonnative.View] is the universal flex container (like React -Native's `View`). It defaults to `flex_direction: "column"`. -[`Column`][pythonnative.Column] and [`Row`][pythonnative.Row] are -convenience wrappers that fix the direction. +The same Props dataclasses are used by the `pythonnative.sdk` surface +for third-party components, so the built-in API and the extension API +speak the same shape. Example: ```python import pythonnative as pn pn.Column( - pn.Text("Hello", style={"font_size": 18}), + pn.Text("Hello", style=pn.style(font_size=18)), pn.Button("Tap", on_click=lambda: print("tapped")), - style={"spacing": 12, "padding": 16}, + style=pn.style(spacing=12, padding=16), ) ``` """ +from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Literal, Optional from .element import Element +from .sdk import Props from .style import ( AutoCapitalize, Color, @@ -49,33 +40,265 @@ ) # ====================================================================== -# Leaf components +# Canonical element builder # ====================================================================== -def _accessibility_props( - accessibility_label: Optional[str], - accessibility_hint: Optional[str], - accessibility_role: Optional[str], - accessible: Optional[bool], -) -> Dict[str, Any]: - """Collect the four accessibility prop keys into a dict. +def _make_element( + name: str, + *children: Element, + style: StyleProp = None, + ref: Optional[Dict[str, Any]] = None, + key: Optional[str] = None, + _defaults: Optional[Dict[str, Any]] = None, + _forced: Optional[Dict[str, Any]] = None, + **props: Any, +) -> Element: + """Build an [`Element`][pythonnative.Element] of type ``name``. + + This is the single helper every built-in factory routes through, so + the cross-cutting concerns that used to be duplicated per component + live in one place: + + 1. ``style`` is flattened via + [`resolve_style`][pythonnative.style.resolve_style] (list-of-dicts + and ``None`` both handled). + 2. ``_defaults`` are filled in for keys not already present (used for + things like ``View``'s default ``flex_direction: "column"`` that + a user style may legitimately override). + 3. ``**props`` are merged on top, with ``None`` values *dropped* so + optional kwargs don't pollute the prop dict. + 4. ``ref`` is attached under the reserved ``"ref"`` key. + 5. ``_forced`` overrides everything (used by ``Column`` / ``Row`` to + lock their flex direction regardless of user style). - Internal helper kept here so every component factory can expose - the same four kwargs without repeating the ``if x is not None`` - plumbing. Returns an empty dict when no accessibility values are - supplied so we don't bloat element props. + Args: + name: Element type name (e.g. ``"Text"``). + *children: Child elements. + style: Style dict, list of dicts, or ``None``. + ref: Optional ``use_ref()`` dict; the reconciler populates + ``ref["current"]`` with the underlying native view. + key: Stable identity for keyed reconciliation. + _defaults: Internal: fill-only-if-missing prop defaults. + _forced: Internal: prop overrides applied last. + **props: Per-component props. ``None`` values are dropped. + + Returns: + A fresh [`Element`][pythonnative.Element]. """ - out: Dict[str, Any] = {} - if accessibility_label is not None: - out["accessibility_label"] = accessibility_label - if accessibility_hint is not None: - out["accessibility_hint"] = accessibility_hint - if accessibility_role is not None: - out["accessibility_role"] = accessibility_role - if accessible is not None: - out["accessible"] = accessible - return out + out: Dict[str, Any] = dict(resolve_style(style)) + if _defaults: + for k, v in _defaults.items(): + out.setdefault(k, v) + for k, v in props.items(): + if v is not None: + out[k] = v + if ref is not None: + out["ref"] = ref + if _forced: + out.update(_forced) + return Element(name, out, list(children), key=key) + + +# ====================================================================== +# Props dataclasses +# ====================================================================== +# +# These are the canonical schemas for every built-in component. They +# subclass the SDK's ``Props`` base, so the same shape works for both +# the built-in factory functions and the third-party +# [`element_factory`][pythonnative.element_factory] API. + + +@dataclass(frozen=True) +class TextProps(Props): + """Props for [`Text`][pythonnative.Text].""" + + text: str = "" + accessibility_label: Optional[str] = None + accessibility_hint: Optional[str] = None + accessibility_role: Optional[str] = None + accessible: Optional[bool] = None + + +@dataclass(frozen=True) +class ButtonProps(Props): + """Props for [`Button`][pythonnative.Button].""" + + title: str = "" + on_click: Optional[Callable[[], None]] = None + enabled: bool = True + accessibility_label: Optional[str] = None + accessibility_hint: Optional[str] = None + accessibility_role: Optional[str] = None + accessible: Optional[bool] = None + + +@dataclass(frozen=True) +class TextInputProps(Props): + """Props for [`TextInput`][pythonnative.TextInput].""" + + value: str = "" + placeholder: Optional[str] = None + on_change: Optional[Callable[[str], None]] = None + on_submit: Optional[Callable[[str], None]] = None + secure: bool = False + multiline: bool = False + keyboard_type: Optional[KeyboardType] = None + auto_capitalize: Optional[AutoCapitalize] = None + auto_correct: Optional[bool] = None + auto_focus: bool = False + return_key_type: Optional[ReturnKeyType] = None + max_length: Optional[int] = None + placeholder_color: Optional[Color] = None + accessibility_label: Optional[str] = None + accessibility_hint: Optional[str] = None + accessible: Optional[bool] = None + + +@dataclass(frozen=True) +class ImageProps(Props): + """Props for [`Image`][pythonnative.Image].""" + + source: Optional[str] = None + scale_type: Optional[ScaleType] = None + tint_color: Optional[Color] = None + accessibility_label: Optional[str] = None + accessibility_role: Optional[str] = None + accessible: Optional[bool] = None + + +@dataclass(frozen=True) +class SwitchProps(Props): + """Props for [`Switch`][pythonnative.Switch].""" + + value: bool = False + on_change: Optional[Callable[[bool], None]] = None + + +@dataclass(frozen=True) +class ProgressBarProps(Props): + """Props for [`ProgressBar`][pythonnative.ProgressBar].""" + + value: float = 0.0 + + +@dataclass(frozen=True) +class ActivityIndicatorProps(Props): + """Props for [`ActivityIndicator`][pythonnative.ActivityIndicator].""" + + animating: bool = True + + +@dataclass(frozen=True) +class WebViewProps(Props): + """Props for [`WebView`][pythonnative.WebView].""" + + url: Optional[str] = None + + +@dataclass(frozen=True) +class SpacerProps(Props): + """Props for [`Spacer`][pythonnative.Spacer].""" + + size: Optional[float] = None + flex: Optional[float] = None + + +@dataclass(frozen=True) +class SliderProps(Props): + """Props for [`Slider`][pythonnative.Slider].""" + + value: float = 0.0 + min_value: float = 0.0 + max_value: float = 1.0 + on_change: Optional[Callable[[float], None]] = None + + +@dataclass(frozen=True) +class ViewProps(Props): + """Props for [`View`][pythonnative.View], [`Column`][pythonnative.Column], and [`Row`][pythonnative.Row].""" + + accessibility_label: Optional[str] = None + accessibility_hint: Optional[str] = None + accessibility_role: Optional[str] = None + accessible: Optional[bool] = None + + +@dataclass(frozen=True) +class ScrollViewProps(Props): + """Props for [`ScrollView`][pythonnative.ScrollView].""" + + refresh_control: Optional[Dict[str, Any]] = None + scroll_axis: Optional[Literal["vertical", "horizontal"]] = None + + +@dataclass(frozen=True) +class SafeAreaViewProps(Props): + """Props for [`SafeAreaView`][pythonnative.SafeAreaView].""" + + +@dataclass(frozen=True) +class ModalProps(Props): + """Props for [`Modal`][pythonnative.Modal].""" + + visible: bool = False + on_dismiss: Optional[Callable[[], None]] = None + title: Optional[str] = None + animation_type: Literal["slide", "fade", "none"] = "slide" + transparent: bool = False + + +@dataclass(frozen=True) +class PressableProps(Props): + """Props for [`Pressable`][pythonnative.Pressable].""" + + on_press: Optional[Callable[[], None]] = None + on_long_press: Optional[Callable[[], None]] = None + pressed_opacity: float = 0.6 + accessibility_label: Optional[str] = None + accessibility_hint: Optional[str] = None + accessibility_role: Optional[str] = None + accessible: Optional[bool] = None + + +@dataclass(frozen=True) +class StatusBarProps(Props): + """Props for [`StatusBar`][pythonnative.StatusBar].""" + + bar_style: Optional[Literal["light", "dark", "default"]] = None + background_color: Optional[Color] = None + hidden: Optional[bool] = None + + +@dataclass(frozen=True) +class KeyboardAvoidingViewProps(Props): + """Props for [`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView].""" + + behavior: Literal["padding", "position"] = "padding" + + +@dataclass(frozen=True) +class PickerProps(Props): + """Props for [`Picker`][pythonnative.Picker]. + + ``items`` is an ordered list of ``{"value": Any, "label": str}`` + entries. ``value`` is matched against ``items[i]["value"]`` to + determine the currently selected row. + """ + + value: Any = None + items: List[Dict[str, Any]] = field(default_factory=list) + on_change: Optional[Callable[[Any], None]] = None + placeholder: str = "Select…" + accessibility_label: Optional[str] = None + accessibility_hint: Optional[str] = None + accessible: Optional[bool] = None + + +# ====================================================================== +# Leaf factories +# ====================================================================== def Text( @@ -91,34 +314,38 @@ def Text( ) -> Element: """Display a string of text. - Style properties: `font_size`, `color`, `bold`, `font_weight`, - `font_family`, `italic`, `text_align`, `background_color`, - `max_lines`, `letter_spacing`, `line_height`, `text_decoration` - (`"underline"` / `"line_through"`), `border_radius`, - `border_width`, `border_color`, `shadow_*`, `opacity`, - `transform`, plus the common layout props. + Style properties: ``font_size``, ``color``, ``bold``, + ``font_weight``, ``font_family``, ``italic``, ``text_align``, + ``background_color``, ``max_lines``, ``letter_spacing``, + ``line_height``, ``text_decoration`` (``"underline"`` / + ``"line_through"``), ``border_radius``, ``border_width``, + ``border_color``, ``shadow_*``, ``opacity``, ``transform``, plus + the common layout props. Args: text: Text content to display. - style: Style dict (or list of dicts) controlling appearance and - layout. + style: Style dict (or list of dicts). accessibility_label: Spoken description for screen readers. accessibility_hint: Spoken extra detail (iOS only). accessibility_role: Semantic role for assistive tech. accessible: Override whether the element is exposed to AT. - ref: Optional ``use_ref()`` dict; the reconciler populates - ``ref["current"]`` with the underlying native view. - key: Stable identity for keyed reconciliation in lists. + ref: Optional ``use_ref()`` dict. + key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Text"`. + An [`Element`][pythonnative.Element] of type ``"Text"``. """ - props: Dict[str, Any] = {"text": text} - props.update(resolve_style(style)) - props.update(_accessibility_props(accessibility_label, accessibility_hint, accessibility_role, accessible)) - if ref is not None: - props["ref"] = ref - return Element("Text", props, [], key=key) + return _make_element( + "Text", + style=style, + ref=ref, + key=key, + text=text, + accessibility_label=accessibility_label, + accessibility_hint=accessibility_hint, + accessibility_role=accessibility_role, + accessible=accessible, + ) def Button( @@ -129,55 +356,55 @@ def Button( style: StyleProp = None, accessibility_label: Optional[str] = None, accessibility_hint: Optional[str] = None, + accessibility_role: Optional[str] = None, accessible: Optional[bool] = None, ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: """Display a tappable button. - Style properties: `color`, `background_color`, `font_size`, - `border_radius`, `border_width`, `border_color`, `shadow_*`, - `opacity`, `transform`, plus the common layout props. + Style properties: ``color``, ``background_color``, ``font_size``, + ``border_radius``, ``border_width``, ``border_color``, ``shadow_*``, + ``opacity``, ``transform``, plus the common layout props. + + Buttons get ``accessibility_role="button"`` by default. Args: title: Button label. on_click: Callback invoked when the user taps the button. - enabled: When `False`, the button is disabled and cannot be + enabled: When ``False``, the button is disabled and cannot be tapped. style: Style dict (or list of dicts). accessibility_label: Spoken description for screen readers. accessibility_hint: Spoken extra detail (iOS only). + accessibility_role: Override the default ``"button"`` role. accessible: Override whether the element is exposed to AT. - ref: Optional ``use_ref()`` dict; the reconciler populates - ``ref["current"]`` with the underlying native view. + ref: Optional ``use_ref()`` dict. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Button"`. + An [`Element`][pythonnative.Element] of type ``"Button"``. """ - props: Dict[str, Any] = {"title": title} - if on_click is not None: - props["on_click"] = on_click - if not enabled: - props["enabled"] = False - props.update(resolve_style(style)) - # Buttons get accessibility_role="button" by default. - if accessibility_label is not None: - props["accessibility_label"] = accessibility_label - if accessibility_hint is not None: - props["accessibility_hint"] = accessibility_hint - if accessible is not None: - props["accessible"] = accessible - props.setdefault("accessibility_role", "button") - if ref is not None: - props["ref"] = ref - return Element("Button", props, [], key=key) + return _make_element( + "Button", + style=style, + ref=ref, + key=key, + title=title, + on_click=on_click, + enabled=enabled, + accessibility_label=accessibility_label, + accessibility_hint=accessibility_hint, + accessibility_role=accessibility_role, + accessible=accessible, + _defaults={"accessibility_role": "button"}, + ) def TextInput( *, value: str = "", - placeholder: str = "", + placeholder: Optional[str] = None, on_change: Optional[Callable[[str], None]] = None, on_submit: Optional[Callable[[str], None]] = None, secure: bool = False, @@ -196,30 +423,29 @@ def TextInput( ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: - """Display a text entry field (single-line by default, or `multiline`). + """Display a text-entry field (single-line by default, or ``multiline``). - Style properties: `font_size`, `color`, `background_color`, - `border_*`, plus the common layout props. + Style properties: ``font_size``, ``color``, ``background_color``, + ``border_*``, plus the common layout props. Args: value: Current text content (controlled-input pattern). - placeholder: Hint shown when `value` is empty. + placeholder: Hint shown when ``value`` is empty. on_change: Callback invoked with the new string each keystroke. on_submit: Callback invoked when the user submits (Return / Done / etc.). Receives the final text. - secure: When `True`, characters are masked (use for passwords). - multiline: When `True`, allows multiple lines of input. + secure: When ``True``, characters are masked (use for passwords). + multiline: When ``True``, allows multiple lines of input. keyboard_type: One of ``"default"``, ``"email_address"``, - ``"number_pad"``, ``"decimal_pad"``, ``"phone_pad"``, - ``"url"``. - auto_capitalize: One of ``"none"``, ``"sentences"``, - ``"words"``, ``"characters"``. + ``"number_pad"``, ``"decimal_pad"``, ``"phone_pad"``, ``"url"``. + auto_capitalize: One of ``"none"``, ``"sentences"``, ``"words"``, + ``"characters"``. auto_correct: Enable/disable autocorrection. auto_focus: Request focus on mount. return_key_type: One of ``"default"``, ``"done"``, ``"go"``, ``"next"``, ``"send"``, ``"search"``. max_length: Maximum number of characters allowed. - placeholder_color: Color to use for the placeholder string. + placeholder_color: Color used for the placeholder string. style: Style dict (or list of dicts). accessibility_label: Spoken description for screen readers. accessibility_hint: Spoken extra detail (iOS only). @@ -228,38 +454,30 @@ def TextInput( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"TextInput"`. + An [`Element`][pythonnative.Element] of type ``"TextInput"``. """ - props: Dict[str, Any] = {"value": value} - if placeholder: - props["placeholder"] = placeholder - if on_change is not None: - props["on_change"] = on_change - if on_submit is not None: - props["on_submit"] = on_submit - if secure: - props["secure"] = True - if multiline: - props["multiline"] = True - if keyboard_type is not None: - props["keyboard_type"] = keyboard_type - if auto_capitalize is not None: - props["auto_capitalize"] = auto_capitalize - if auto_correct is not None: - props["auto_correct"] = auto_correct - if auto_focus: - props["auto_focus"] = True - if return_key_type is not None: - props["return_key_type"] = return_key_type - if max_length is not None: - props["max_length"] = max_length - if placeholder_color is not None: - props["placeholder_color"] = placeholder_color - props.update(resolve_style(style)) - props.update(_accessibility_props(accessibility_label, accessibility_hint, None, accessible)) - if ref is not None: - props["ref"] = ref - return Element("TextInput", props, [], key=key) + return _make_element( + "TextInput", + style=style, + ref=ref, + key=key, + value=value, + placeholder=placeholder, + on_change=on_change, + on_submit=on_submit, + secure=secure or None, + multiline=multiline or None, + keyboard_type=keyboard_type, + auto_capitalize=auto_capitalize, + auto_correct=auto_correct, + auto_focus=auto_focus or None, + return_key_type=return_key_type, + max_length=max_length, + placeholder_color=placeholder_color, + accessibility_label=accessibility_label, + accessibility_hint=accessibility_hint, + accessible=accessible, + ) def Image( @@ -269,46 +487,50 @@ def Image( tint_color: Optional[Color] = None, style: StyleProp = None, accessibility_label: Optional[str] = None, + accessibility_role: Optional[str] = None, accessible: Optional[bool] = None, ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: """Display an image from a resource path or URL. - Style properties: `background_color`, `border_*`, `opacity`, - `transform`, plus the common layout props. + Style properties: ``background_color``, ``border_*``, ``opacity``, + ``transform``, plus the common layout props. Network images (``http://`` / ``https://``) are loaded - asynchronously off the main thread on both iOS (via NSURLSession) - and Android (via a worker thread + `BitmapFactory`). + asynchronously off the main thread on both iOS (via + ``NSURLSession``) and Android (via a worker thread plus + ``BitmapFactory``). Args: source: Image resource name or URL. - scale_type: Fit mode: `"cover"`, `"contain"`, `"stretch"`, - `"center"`. + scale_type: Fit mode: ``"cover"``, ``"contain"``, ``"stretch"``, + ``"center"``. tint_color: Color overlay applied to template images (monochrome icons). style: Style dict (or list of dicts). accessibility_label: Spoken description for screen readers. + accessibility_role: Override the default ``"image"`` role. accessible: Override whether the element is exposed to AT. ref: Optional ``use_ref()`` dict. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Image"`. + An [`Element`][pythonnative.Element] of type ``"Image"``. """ - props: Dict[str, Any] = {} - if source: - props["source"] = source - if scale_type is not None: - props["scale_type"] = scale_type - if tint_color is not None: - props["tint_color"] = tint_color - props.update(resolve_style(style)) - props.update(_accessibility_props(accessibility_label, None, "image", accessible)) - if ref is not None: - props["ref"] = ref - return Element("Image", props, [], key=key) + return _make_element( + "Image", + style=style, + ref=ref, + key=key, + source=source or None, + scale_type=scale_type, + tint_color=tint_color, + accessibility_label=accessibility_label, + accessibility_role=accessibility_role, + accessible=accessible, + _defaults={"accessibility_role": "image"}, + ) def Switch( @@ -327,13 +549,15 @@ def Switch( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Switch"`. + An [`Element`][pythonnative.Element] of type ``"Switch"``. """ - props: Dict[str, Any] = {"value": value} - if on_change is not None: - props["on_change"] = on_change - props.update(resolve_style(style)) - return Element("Switch", props, [], key=key) + return _make_element( + "Switch", + style=style, + key=key, + value=value, + on_change=on_change, + ) def ProgressBar( @@ -342,23 +566,26 @@ def ProgressBar( style: StyleProp = None, key: Optional[str] = None, ) -> Element: - """Show determinate progress as a value between 0.0 and 1.0. + """Show determinate progress as a value between ``0.0`` and ``1.0``. For indeterminate progress, use [`ActivityIndicator`][pythonnative.ActivityIndicator] instead. Args: - value: Fraction complete (clamped to `[0.0, 1.0]` by the + value: Fraction complete (clamped to ``[0.0, 1.0]`` by the platform handler). style: Style dict (or list of dicts). key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"ProgressBar"`. + An [`Element`][pythonnative.Element] of type ``"ProgressBar"``. """ - props: Dict[str, Any] = {"value": value} - props.update(resolve_style(style)) - return Element("ProgressBar", props, [], key=key) + return _make_element( + "ProgressBar", + style=style, + key=key, + value=value, + ) def ActivityIndicator( @@ -370,17 +597,20 @@ def ActivityIndicator( """Show an indeterminate loading spinner. Args: - animating: When `False`, the spinner is hidden. + animating: When ``False``, the spinner is hidden. style: Style dict (or list of dicts). key: Stable identity for keyed reconciliation. Returns: An [`Element`][pythonnative.Element] of type - `"ActivityIndicator"`. + ``"ActivityIndicator"``. """ - props: Dict[str, Any] = {"animating": animating} - props.update(resolve_style(style)) - return Element("ActivityIndicator", props, [], key=key) + return _make_element( + "ActivityIndicator", + style=style, + key=key, + animating=animating, + ) def WebView( @@ -397,13 +627,14 @@ def WebView( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"WebView"`. + An [`Element`][pythonnative.Element] of type ``"WebView"``. """ - props: Dict[str, Any] = {} - if url: - props["url"] = url - props.update(resolve_style(style)) - return Element("WebView", props, [], key=key) + return _make_element( + "WebView", + style=style, + key=key, + url=url or None, + ) def Spacer( @@ -414,32 +645,31 @@ def Spacer( ) -> Element: """Insert empty space inside a flex container. - Pass `size` for a fixed gap, or `flex` to expand and absorb + Pass ``size`` for a fixed gap, or ``flex`` to expand and absorb remaining space. Args: - size: Fixed gap in dp/pt along the parent's main axis. + size: Fixed gap in dp/pt along the parent's main axis. Mirrored + on both axes — whichever axis the parent's + ``flex_direction`` chooses as main becomes the actual gap. flex: Flex-grow weight; useful for pushing siblings to the opposite end of a [`Row`][pythonnative.Row] or [`Column`][pythonnative.Column]. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Spacer"`. + An [`Element`][pythonnative.Element] of type ``"Spacer"``. """ - props: Dict[str, Any] = {} - if size is not None: - # The layout engine sees ``width`` / ``height`` only, so a fixed - # ``size`` is mirrored on both axes. Whichever axis the parent - # container's ``flex_direction`` chooses as main becomes the - # actual gap; the cross axis is constrained by the parent's - # ``align_items`` (typically ``stretch``) anyway. - props["size"] = size - props["width"] = size - props["height"] = size - if flex is not None: - props["flex"] = flex - return Element("Spacer", props, [], key=key) + width = size if size is not None else None + height = size if size is not None else None + return _make_element( + "Spacer", + key=key, + size=size, + width=width, + height=height, + flex=flex, + ) def Slider( @@ -451,7 +681,7 @@ def Slider( style: StyleProp = None, key: Optional[str] = None, ) -> Element: - """Continuous-value slider between `min_value` and `max_value`. + """Continuous-value slider between ``min_value`` and ``max_value``. Args: value: Current slider value. @@ -463,21 +693,21 @@ def Slider( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Slider"`. + An [`Element`][pythonnative.Element] of type ``"Slider"``. """ - props: Dict[str, Any] = { - "value": value, - "min_value": min_value, - "max_value": max_value, - } - if on_change is not None: - props["on_change"] = on_change - props.update(resolve_style(style)) - return Element("Slider", props, [], key=key) + return _make_element( + "Slider", + style=style, + key=key, + value=value, + min_value=min_value, + max_value=max_value, + on_change=on_change, + ) # ====================================================================== -# Container components +# Container factories # ====================================================================== @@ -491,28 +721,24 @@ def View( ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: - """Universal flex container (like React Native's `View`). + """Universal flex container (like React Native's ``View``). - Defaults to `flex_direction: "column"`. Override via `style`: + Defaults to ``flex_direction: "column"`` (override via ``style``). - ```python - pn.View(child_a, child_b, style={"flex_direction": "row"}) - ``` + Flex container properties (passed via ``style``): - Flex container properties (inside `style`): - - - `flex_direction`: `"column"` (default), `"row"`, - `"column_reverse"`, `"row_reverse"`. - - `justify_content`: main-axis distribution. Accepts `"flex_start"` - (default), `"center"`, `"flex_end"`, `"space_between"`, - `"space_around"`, `"space_evenly"`. - - `align_items`: cross-axis alignment. Accepts `"stretch"` (default), - `"flex_start"`, `"center"`, `"flex_end"`. - - `overflow`: `"visible"` (default) or `"hidden"`. - - `spacing`, `padding`, `background_color`, `border_radius`, - `border_width`, `border_color`, `shadow_color`, `shadow_offset`, - `shadow_opacity`, `shadow_radius`, `elevation`, `opacity`, - `transform`. + - ``flex_direction``: ``"column"`` (default), ``"row"``, + ``"column_reverse"``, ``"row_reverse"``. + - ``justify_content``: main-axis distribution. Accepts + ``"flex_start"`` (default), ``"center"``, ``"flex_end"``, + ``"space_between"``, ``"space_around"``, ``"space_evenly"``. + - ``align_items``: cross-axis alignment. Accepts ``"stretch"`` + (default), ``"flex_start"``, ``"center"``, ``"flex_end"``. + - ``overflow``: ``"visible"`` (default) or ``"hidden"``. + - ``spacing``, ``padding``, ``background_color``, ``border_radius``, + ``border_width``, ``border_color``, ``shadow_color``, + ``shadow_offset``, ``shadow_opacity``, ``shadow_radius``, + ``elevation``, ``opacity``, ``transform``. Args: *children: Child elements rendered inside the container. @@ -521,19 +747,24 @@ def View( accessibility_hint: Spoken extra detail (iOS only). accessibility_role: Semantic role for assistive tech. accessible: Override whether the element is exposed to AT. - ref: Optional ``use_ref()`` dict; the reconciler populates - ``ref["current"]`` with the underlying native view. + ref: Optional ``use_ref()`` dict. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"View"`. + An [`Element`][pythonnative.Element] of type ``"View"``. """ - props: Dict[str, Any] = {"flex_direction": "column"} - props.update(resolve_style(style)) - props.update(_accessibility_props(accessibility_label, accessibility_hint, accessibility_role, accessible)) - if ref is not None: - props["ref"] = ref - return Element("View", props, list(children), key=key) + return _make_element( + "View", + *children, + style=style, + ref=ref, + key=key, + accessibility_label=accessibility_label, + accessibility_hint=accessibility_hint, + accessibility_role=accessibility_role, + accessible=accessible, + _defaults={"flex_direction": "column"}, + ) def Column( @@ -545,20 +776,8 @@ def Column( """Arrange children vertically. Convenience wrapper around [`View`][pythonnative.View] with - `flex_direction` fixed to `"column"`. Use `View` directly if you - need to switch between row and column at runtime. - - Style properties: `spacing`, `padding`, `align_items`, - `justify_content`, `background_color`, `overflow`, plus the common - layout props. - - `align_items` controls cross-axis (horizontal) alignment: - `"stretch"` (default), `"flex_start"` / `"leading"`, `"center"`, or - `"flex_end"` / `"trailing"`. - - `justify_content` controls main-axis (vertical) distribution: - `"flex_start"` (default), `"center"`, `"flex_end"`, - `"space_between"`, `"space_around"`, `"space_evenly"`. + ``flex_direction`` locked to ``"column"``. Use ``View`` directly if + you need to switch between row and column at runtime. Args: *children: Child elements stacked top to bottom. @@ -567,14 +786,16 @@ def Column( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Column"`. + An [`Element`][pythonnative.Element] of type ``"Column"``. """ - props: Dict[str, Any] = {"flex_direction": "column"} - props.update(resolve_style(style)) - props["flex_direction"] = "column" - if ref is not None: - props["ref"] = ref - return Element("Column", props, list(children), key=key) + return _make_element( + "Column", + *children, + style=style, + ref=ref, + key=key, + _forced={"flex_direction": "column"}, + ) def Row( @@ -586,20 +807,8 @@ def Row( """Arrange children horizontally. Convenience wrapper around [`View`][pythonnative.View] with - `flex_direction` fixed to `"row"`. Use `View` directly if you need - to switch between row and column at runtime. - - Style properties: `spacing`, `padding`, `align_items`, - `justify_content`, `background_color`, `overflow`, plus the common - layout props. - - `align_items` controls cross-axis (vertical) alignment: - `"stretch"` (default), `"flex_start"` / `"top"`, `"center"`, or - `"flex_end"` / `"bottom"`. - - `justify_content` controls main-axis (horizontal) distribution: - `"flex_start"` (default), `"center"`, `"flex_end"`, - `"space_between"`, `"space_around"`, `"space_evenly"`. + ``flex_direction`` locked to ``"row"``. Use ``View`` directly if you + need to switch between row and column at runtime. Args: *children: Child elements arranged left to right. @@ -608,49 +817,57 @@ def Row( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Row"`. + An [`Element`][pythonnative.Element] of type ``"Row"``. """ - props: Dict[str, Any] = {"flex_direction": "row"} - props.update(resolve_style(style)) - props["flex_direction"] = "row" - if ref is not None: - props["ref"] = ref - return Element("Row", props, list(children), key=key) + return _make_element( + "Row", + *children, + style=style, + ref=ref, + key=key, + _forced={"flex_direction": "row"}, + ) def ScrollView( - child: Optional[Element] = None, - *, + *children: Element, refresh_control: Optional[Dict[str, Any]] = None, + scroll_axis: Optional[Literal["vertical", "horizontal"]] = None, style: StyleProp = None, ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: - """Wrap a single child in a scrollable container. + """Wrap children in a scrollable container. + + ``ScrollView`` typically takes a single child (a ``Column`` or + ``Row`` aggregating the scrollable content). It accepts ``*children`` + for ergonomic call sites; the underlying native scroll view stacks + them on its content axis. Args: - child: The single child to scroll. Wrap multiple elements in a - [`Column`][pythonnative.Column] or - [`Row`][pythonnative.Row] first. + *children: Child elements to scroll. refresh_control: Optional pull-to-refresh spec, typically constructed via [`RefreshControl`][pythonnative.RefreshControl]. The dict - must have ``refreshing`` (bool) and ``on_refresh`` (callable). + must have ``refreshing`` (bool) and ``on_refresh`` + (callable). + scroll_axis: ``"vertical"`` (default) or ``"horizontal"``. style: Style dict (or list of dicts). ref: Optional ``use_ref()`` dict. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"ScrollView"`. + An [`Element`][pythonnative.Element] of type ``"ScrollView"``. """ - children = [child] if child is not None else [] - props: Dict[str, Any] = {} - if refresh_control is not None: - props["refresh_control"] = refresh_control - props.update(resolve_style(style)) - if ref is not None: - props["ref"] = ref - return Element("ScrollView", props, children, key=key) + return _make_element( + "ScrollView", + *children, + style=style, + ref=ref, + key=key, + refresh_control=refresh_control, + scroll_axis=scroll_axis, + ) def SafeAreaView( @@ -666,11 +883,14 @@ def SafeAreaView( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"SafeAreaView"`. + An [`Element`][pythonnative.Element] of type ``"SafeAreaView"``. """ - props: Dict[str, Any] = {} - props.update(resolve_style(style)) - return Element("SafeAreaView", props, list(children), key=key) + return _make_element( + "SafeAreaView", + *children, + style=style, + key=key, + ) def Modal( @@ -685,21 +905,21 @@ def Modal( ) -> Element: """Overlay modal dialog backed by a real native presentation. - The modal is shown when `visible=True` and hidden when `False`. - Drive `visible` from a hook so the parent component can dismiss + The modal is shown when ``visible=True`` and hidden when ``False``. + Drive ``visible`` from a hook so the parent component can dismiss the modal in response to user actions. On iOS this presents a - `UIViewController`; on Android it shows an `android.app.Dialog`. + ``UIViewController``; on Android it shows an ``android.app.Dialog``. Children are mounted as the modal's content view, not into the - on-tree placeholder, so they appear above all other native - content and don't influence the underlying layout. + on-tree placeholder, so they appear above all other native content + and don't influence the underlying layout. Args: *children: Modal content. visible: Controls whether the modal is presented. on_dismiss: Callback invoked when the user dismisses the modal - via system gesture (e.g., backdrop tap or back button). - title: Optional title bar text. + via system gesture. + title: Optional title-bar text. animation_type: ``"slide"`` (default), ``"fade"``, or ``"none"``. transparent: When ``True``, the underlying view is dimmed instead of fully covered. @@ -707,91 +927,152 @@ def Modal( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Modal"`. + An [`Element`][pythonnative.Element] of type ``"Modal"``. """ - props: Dict[str, Any] = { - "visible": visible, - "animation_type": animation_type, - "transparent": transparent, - } - if on_dismiss is not None: - props["on_dismiss"] = on_dismiss - if title is not None: - props["title"] = title - props.update(resolve_style(style)) - return Element("Modal", props, list(children), key=key) + return _make_element( + "Modal", + *children, + style=style, + key=key, + visible=visible, + animation_type=animation_type, + transparent=transparent, + on_dismiss=on_dismiss, + title=title, + ) def Pressable( - child: Optional[Element] = None, - *, + *children: Element, on_press: Optional[Callable[[], None]] = None, on_long_press: Optional[Callable[[], None]] = None, pressed_opacity: float = 0.6, style: StyleProp = None, accessibility_label: Optional[str] = None, accessibility_hint: Optional[str] = None, + accessibility_role: Optional[str] = None, accessible: Optional[bool] = None, key: Optional[str] = None, ) -> Element: - """Wrap any child element with tap and long-press handlers. + """Wrap children with tap and long-press handlers. Useful for making non-button elements (text, images, custom views) respond to user taps. The wrapper view fades to ``pressed_opacity`` - on touch-down and back to full opacity on touch-up, providing - subtle visual feedback (matches React Native's `Pressable` default). + on touch-down and back to full opacity on touch-up. + + Pressable gets ``accessibility_role="button"`` by default. Args: - child: The single element to make pressable. + *children: Elements to make pressable. on_press: Callback invoked on a normal tap. on_long_press: Callback invoked on a sustained press. - pressed_opacity: Opacity (0-1) applied to the wrapper while - the user's finger is down. Set to ``1.0`` for no visual - feedback. + pressed_opacity: Opacity (0–1) applied while the user's finger + is down. Set to ``1.0`` for no visual feedback. style: Style dict applied to the wrapper. accessibility_label: Spoken description for screen readers. accessibility_hint: Spoken extra detail (iOS only). + accessibility_role: Override the default ``"button"`` role. accessible: Override whether the element is exposed to AT. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Pressable"`. + An [`Element`][pythonnative.Element] of type ``"Pressable"``. """ - props: Dict[str, Any] = {} - if on_press is not None: - props["on_press"] = on_press - if on_long_press is not None: - props["on_long_press"] = on_long_press - if pressed_opacity != 0.6: - props["pressed_opacity"] = pressed_opacity - else: - props.setdefault("pressed_opacity", 0.6) - props.update(resolve_style(style)) - props.update(_accessibility_props(accessibility_label, accessibility_hint, "button", accessible)) - children = [child] if child is not None else [] - return Element("Pressable", props, children, key=key) + return _make_element( + "Pressable", + *children, + style=style, + key=key, + on_press=on_press, + on_long_press=on_long_press, + pressed_opacity=pressed_opacity, + accessibility_label=accessibility_label, + accessibility_hint=accessibility_hint, + accessibility_role=accessibility_role, + accessible=accessible, + _defaults={"accessibility_role": "button"}, + ) + + +# ====================================================================== +# Fragment +# ====================================================================== + + +def Fragment(*children: Optional[Element], key: Optional[str] = None) -> Element: + """Group children without adding a wrapping native view. + + Like React's ``<>``: returns multiple elements from a component + without introducing an extra container. The reconciler flattens + Fragment elements at the children-list level, so each child appears + as a direct sibling of the Fragment's parent in the native tree. + + Useful inside [`Provider`][pythonnative.Provider] / + [`memo`][pythonnative.memo] / conditional logic when grouping + siblings inside another component's child list: + + ```python + pn.Column( + pn.Text("Top"), + pn.Fragment( + pn.Text("Middle A"), + pn.Text("Middle B"), + ), + pn.Text("Bottom"), + ) + ``` + + Args: + *children: Child elements to expose at the parent level. ``None`` + children are dropped, which makes conditional rendering with + ``cond and pn.Text(...)`` ergonomic. + key: Optional key for the Fragment itself (rarely useful since + Fragment doesn't appear in the native tree). + + Returns: + An [`Element`][pythonnative.Element] of type ``"__Fragment__"``. + + Note: + Today, returning a Fragment from a ``@pn.component`` function + only mounts its first child as the component's root. To return + multiple top-level elements from a function component, use a + container such as [`Column`][pythonnative.Column] or + [`Row`][pythonnative.Row] instead. + """ + filtered = [c for c in children if c is not None] + return Element("__Fragment__", {}, filtered, key=key) + + +# ====================================================================== +# Error boundary +# ====================================================================== def ErrorBoundary( - child: Optional[Element] = None, - *, + *children: Element, fallback: Optional[Any] = None, key: Optional[str] = None, ) -> Element: - """Catch render errors in `child` and display `fallback` instead. + """Catch render errors in the wrapped subtree and display ``fallback`` instead. + + ``fallback`` may be an [`Element`][pythonnative.Element] or a + callable that receives the exception and returns an ``Element``. + Useful for isolating risky subtrees so a single failure doesn't + crash the page. - `fallback` may be an [`Element`][pythonnative.Element] or a callable - that receives the exception and returns an `Element`. Useful for - isolating risky subtrees so a single failure doesn't crash the page. + When multiple children are passed they're grouped under a + [`Fragment`][pythonnative.Fragment] so the boundary still wraps a + single logical subtree. Args: - child: Subtree to wrap. - fallback: Element to render when `child` raises during render, - or a callable `fallback(err) -> Element`. + *children: Subtree to wrap. + fallback: Element rendered when the subtree raises during + render, or a callable ``fallback(err) -> Element``. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"__ErrorBoundary__"`. + An [`Element`][pythonnative.Element] of type + ``"__ErrorBoundary__"``. Example: ```python @@ -806,8 +1087,16 @@ def ErrorBoundary( 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) + if len(children) <= 1: + kids = list(children) + else: + kids = [Fragment(*children)] + return Element("__ErrorBoundary__", props, kids, key=key) + + +# ====================================================================== +# Lists +# ====================================================================== def FlatList( @@ -822,17 +1111,18 @@ def FlatList( style: StyleProp = None, key: Optional[str] = None, ) -> Element: - """Virtualized scrollable list that renders items from `data` lazily. + """Virtualized scrollable list that renders items from ``data`` lazily. - Backed by `UITableView` on iOS and `RecyclerView` on Android via the - `VirtualList` element. Each visible row is mounted on demand by a - nested [`Reconciler`][pythonnative.reconciler.Reconciler] when + Backed by ``UITableView`` on iOS and ``RecyclerView`` on Android via + the ``VirtualList`` element. Each visible row is mounted on demand + by a nested + [`Reconciler`][pythonnative.reconciler.Reconciler] when ``item_height`` is specified. When ``item_height`` is omitted the implementation falls back to an eager (non-virtualized) ``ScrollView`` of every row — keep the data - set small in that mode (the fallback is convenient for short - lists where virtualization overhead would dominate). + set small in that mode (the fallback is convenient for short lists + where virtualization overhead would dominate). Args: data: Iterable of arbitrary item values. @@ -852,11 +1142,12 @@ def FlatList( on_item_press: Callback invoked with the row index when the user taps a row (virtualized backend only). style: Style dict (or list of dicts). - key: Stable identity for keyed reconciliation of the list itself. + key: Stable identity for keyed reconciliation of the list + itself. Returns: - An [`Element`][pythonnative.Element] of type `"VirtualList"` - (virtualized) or `"ScrollView"` (eager fallback). + An [`Element`][pythonnative.Element] of type ``"VirtualList"`` + (virtualized) or ``"ScrollView"`` (eager fallback). Example: ```python @@ -883,11 +1174,7 @@ def FlatList( el = Element(el.type, el.props, el.children, key=key_extractor(item, i)) items_eager.append(el) inner = Column(*items_eager, style={"spacing": separator_height} if separator_height else None) - sv_props: Dict[str, Any] = {} - if refresh_control is not None: - sv_props["refresh_control"] = refresh_control - sv_props.update(resolve_style(style)) - return Element("ScrollView", sv_props, [inner], key=key) + return ScrollView(inner, refresh_control=refresh_control, style=style, key=key) # Virtualized path: render_item is invoked lazily by the native # cell mount callback when each row scrolls into view. @@ -928,17 +1215,16 @@ def _mount_row( backend.add_child(content_view, native_root, "View") - list_props: Dict[str, Any] = { - "count": len(items_list), - "row_height": row_h, - "mount_row": _mount_row, - } - if on_item_press is not None: - list_props["on_row_press"] = on_item_press - if refresh_control is not None: - list_props["refresh_control"] = refresh_control - list_props.update(resolve_style(style)) - return Element("VirtualList", list_props, [], key=key) + return _make_element( + "VirtualList", + style=style, + key=key, + count=len(items_list), + row_height=row_h, + mount_row=_mount_row, + on_row_press=on_item_press, + refresh_control=refresh_control, + ) def SectionList( @@ -961,8 +1247,10 @@ def SectionList( Args: sections: Each section is ``{"title": ..., "data": [...]}``. - render_item: ``render_item(item, item_index, section_index) -> Element``. - render_section_header: ``render_section_header(section, section_index) -> Element``. + render_item: ``render_item(item, item_index, section_index) -> + Element``. + render_section_header: ``render_section_header(section, + section_index) -> Element``. item_height: Fixed row height for items, in layout units. section_header_height: Fixed header height in layout units. separator_height: Gap appended below each item, in layout units. @@ -970,9 +1258,9 @@ def SectionList( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"VirtualList"` - (virtualized). When ``item_height`` is omitted the layout - falls back to an eager column. + An [`Element`][pythonnative.Element] of type ``"VirtualList"`` + (virtualized). When ``item_height`` is omitted the layout falls + back to an eager column. """ sections_list = list(sections or []) @@ -997,9 +1285,7 @@ def SectionList( else: children.append(Text(str(entry["item"]))) inner = Column(*children, style={"spacing": separator_height} if separator_height else None) - sv_props: Dict[str, Any] = {} - sv_props.update(resolve_style(style)) - return Element("ScrollView", sv_props, [inner], key=key) + return ScrollView(inner, style=style, key=key) # Virtualized: mixed row heights aren't supported in v1, so we # use the larger of section_header_height and item_height + sep. @@ -1032,23 +1318,24 @@ def _mount_row(index: int, content_view: Any) -> None: except Exception: pass - list_props: Dict[str, Any] = { - "count": len(flat), - "row_height": row_h, - "mount_row": _mount_row, - } - list_props.update(resolve_style(style)) - return Element("VirtualList", list_props, [], key=key) + return _make_element( + "VirtualList", + style=style, + key=key, + count=len(flat), + row_height=row_h, + mount_row=_mount_row, + ) # ====================================================================== -# Status bar / keyboard / refresh / alert / picker +# StatusBar / KeyboardAvoidingView / RefreshControl / Picker # ====================================================================== def StatusBar( *, - style: Optional[Literal["light", "dark", "default"]] = None, + bar_style: Optional[Literal["light", "dark", "default"]] = None, background_color: Optional[Color] = None, hidden: Optional[bool] = None, key: Optional[str] = None, @@ -1059,8 +1346,13 @@ def StatusBar( content but applies its props to the host platform's status bar. Mount one near the top of your tree. + The ``bar_style`` parameter is named separately from the universal + ``style`` kwarg (which is unused here) to avoid the conflict that + ``style="light"`` would create with the visual-style dict used + elsewhere. + Args: - style: ``"light"`` (light icons over dark backgrounds), + bar_style: ``"light"`` (light icons over dark backgrounds), ``"dark"`` (dark icons over light backgrounds), or ``"default"`` (system default). background_color: Color of the status-bar background (Android @@ -1069,11 +1361,11 @@ def StatusBar( key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"StatusBar"`. + An [`Element`][pythonnative.Element] of type ``"StatusBar"``. """ props: Dict[str, Any] = {} - if style is not None: - props["style"] = style + if bar_style is not None: + props["bar_style"] = bar_style if background_color is not None: props["background_color"] = background_color if hidden is not None: @@ -1091,8 +1383,8 @@ def KeyboardAvoidingView( Subscribes to the platform-reported keyboard height (via [`use_keyboard_height`][pythonnative.use_keyboard_height] - internally) and applies it as bottom padding so the focused - text input stays visible. + internally) and applies it as bottom padding so the focused text + input stays visible. Args: *children: Children rendered inside the avoiding container. @@ -1103,11 +1395,15 @@ def KeyboardAvoidingView( Returns: An [`Element`][pythonnative.Element] of type - `"KeyboardAvoidingView"`. + ``"KeyboardAvoidingView"``. """ - props: Dict[str, Any] = {"behavior": behavior} - props.update(resolve_style(style)) - return Element("KeyboardAvoidingView", props, list(children), key=key) + return _make_element( + "KeyboardAvoidingView", + *children, + style=style, + key=key, + behavior=behavior, + ) def RefreshControl( @@ -1119,16 +1415,16 @@ def RefreshControl( """Pull-to-refresh spec for [`ScrollView`][pythonnative.ScrollView] / [`FlatList`][pythonnative.FlatList]. Returns a plain dict that should be passed as the - ``refresh_control=`` prop. Modeled as a dict (not an Element) so - the host scroll container can hold one without it appearing as a - child node. + ``refresh_control=`` prop. Modeled as a dict (not an + [`Element`][pythonnative.Element]) so the host scroll container can + hold one without it appearing as a child node. Args: refreshing: Drive the spinner's visibility from a use_state value. - on_refresh: Callback invoked when the user pulls down past - the threshold. Set ``refreshing`` to True for the - duration of the work, then back to False on completion. + on_refresh: Callback invoked when the user pulls down past the + threshold. Set ``refreshing`` to ``True`` for the duration + of the work, then back to ``False`` on completion. tint_color: Color of the spinner. Returns: @@ -1171,53 +1467,49 @@ def Picker( on_change: Optional[Callable[[Any], None]] = None, placeholder: str = "Select…", style: StyleProp = None, + accessibility_label: Optional[str] = None, + accessibility_hint: Optional[str] = None, + accessible: Optional[bool] = None, + ref: Optional[Dict[str, Any]] = None, key: Optional[str] = None, ) -> Element: - """A select / dropdown widget. + """A real native dropdown / select widget. + + Renders a tappable trigger labelled with the selected item; the + iOS handler attaches a ``UIMenu`` (system dropdown) and the Android + handler uses a native ``Spinner``. Selecting an item fires + ``on_change(value)``. - Implemented as a plain - [`Pressable`][pythonnative.Pressable] that, on tap, presents an - [`Alert`][pythonnative.Alert]-style action sheet listing the - options. Selecting an option fires ``on_change(value)``. + ``items`` is an ordered list of ``{"value": Any, "label": str}`` + entries (``label`` defaults to ``str(value)`` when omitted). Args: value: Currently selected value (matched against ``items[i]["value"]``). - items: Each item is ``{"value": ..., "label": ...}``. - on_change: Callback invoked with the selected value. - placeholder: Label shown when nothing is selected. - style: Style dict applied to the trigger pressable. + items: Selectable options. + on_change: Callback invoked with the new value. + placeholder: Label shown when no item matches ``value``. + style: Style dict applied to the trigger. + accessibility_label: Spoken description for screen readers. + accessibility_hint: Spoken extra detail (iOS only). + accessible: Override whether the element is exposed to AT. + ref: Optional ``use_ref()`` dict. key: Stable identity for keyed reconciliation. Returns: - An [`Element`][pythonnative.Element] of type `"Pressable"`. + An [`Element`][pythonnative.Element] of type ``"Picker"``. """ - items_list = list(items or []) - selected_label = placeholder - for it in items_list: - if it.get("value") == value: - selected_label = str(it.get("label", value)) - break - - def _open() -> None: - try: - from .alerts import Alert - except Exception: - return - - def _make_btn(item: Dict[str, Any]) -> Dict[str, Any]: - def _press() -> None: - if on_change is not None: - try: - on_change(item.get("value")) - except Exception: - pass - - return {"label": str(item.get("label", item.get("value"))), "on_press": _press} - - buttons = [_make_btn(it) for it in items_list] - buttons.append({"label": "Cancel", "style": "cancel"}) - Alert.show(title=placeholder, buttons=buttons, style="action_sheet") - - label_text = Text(selected_label) - return Pressable(label_text, on_press=_open, style=style, key=key) + return _make_element( + "Picker", + style=style, + ref=ref, + key=key, + value=value, + items=list(items) if items is not None else [], + on_change=on_change, + placeholder=placeholder, + accessibility_label=accessibility_label, + accessibility_hint=accessibility_hint, + accessible=accessible, + _defaults={"accessibility_role": "button"}, + ) diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py index 0b7401b..0f00564 100644 --- a/src/pythonnative/hooks.py +++ b/src/pythonnative/hooks.py @@ -60,7 +60,6 @@ class HookState: effects: One `(deps, cleanup)` tuple per `use_effect` call. memos: One `(deps, value)` tuple per `use_memo` / `use_callback`. refs: One mutable dict per `use_ref` call. - hook_index: Cursor reset to 0 at the start of every render. """ __slots__ = ( @@ -68,13 +67,13 @@ class HookState: "effects", "memos", "refs", - "hook_index", "state_index", "effect_index", "memo_index", "ref_index", "_trigger_render", "_pending_effects", + "_dirty", ) def __init__(self) -> None: @@ -82,18 +81,18 @@ def __init__(self) -> None: self.effects: List[Tuple[Any, Any]] = [] self.memos: List[Tuple[Any, Any]] = [] self.refs: List[dict] = [] - # ``hook_index`` is retained for backwards compatibility with - # any external code that still reads it; the per-hook cursors - # below are what each hook function actually uses so that - # ``use_state`` and ``use_effect`` can coexist in the same - # component without colliding on a shared counter. - self.hook_index: int = 0 self.state_index: int = 0 self.effect_index: int = 0 self.memo_index: int = 0 self.ref_index: int = 0 self._trigger_render: Optional[Callable[[], None]] = None self._pending_effects: List[Tuple[int, Callable, Any]] = [] + # Cleared by the reconciler after each successful render. + # ``use_state`` / ``use_reducer`` setters flip it to ``True`` + # whenever they actually mutate state, so [`memo`][pythonnative.memo] + # knows that a memoized component still needs to re-render even + # when its props didn't change. + self._dirty: bool = False def reset_index(self) -> None: """Reset every per-hook cursor to ``0``. @@ -102,7 +101,6 @@ def reset_index(self) -> None: the next render reads slots in the same order they were written. """ - self.hook_index = 0 self.state_index = 0 self.effect_index = 0 self.memo_index = 0 @@ -260,7 +258,6 @@ def Counter(): idx = ctx.state_index ctx.state_index += 1 - ctx.hook_index += 1 if idx >= len(ctx.states): val = initial() if callable(initial) else initial @@ -273,6 +270,7 @@ def setter(new_value: Any) -> None: new_value = new_value(ctx.states[idx]) if ctx.states[idx] is not new_value and ctx.states[idx] != new_value: ctx.states[idx] = new_value + ctx._dirty = True if ctx._trigger_render: _schedule_trigger(ctx._trigger_render) @@ -328,7 +326,6 @@ def Counter(): idx = ctx.state_index ctx.state_index += 1 - ctx.hook_index += 1 if idx >= len(ctx.states): val = initial_state() if callable(initial_state) else initial_state @@ -340,6 +337,7 @@ 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 + ctx._dirty = True if ctx._trigger_render: _schedule_trigger(ctx._trigger_render) @@ -395,7 +393,6 @@ def tick(): idx = ctx.effect_index ctx.effect_index += 1 - ctx.hook_index += 1 if idx >= len(ctx.effects): ctx.effects.append((_SENTINEL, None)) @@ -431,7 +428,6 @@ def use_memo(factory: Callable[[], T], deps: list) -> T: idx = ctx.memo_index ctx.memo_index += 1 - ctx.hook_index += 1 if idx >= len(ctx.memos): value = factory() @@ -492,7 +488,6 @@ def use_ref(initial: Any = None) -> dict: idx = ctx.ref_index ctx.ref_index += 1 - ctx.hook_index += 1 if idx >= len(ctx.refs): ref: dict = {"current": initial} @@ -680,14 +675,19 @@ def use_context(context: Context) -> Any: # ====================================================================== -def Provider(context: Context, value: Any, child: Element) -> Element: - """Provide `value` for `context` to all descendants of `child`. +def Provider(context: "Context", value: Any, *children: Element) -> Element: + """Provide ``value`` for ``context`` to all descendants of ``children``. + + Accepts any number of children (varargs). Multiple children are + grouped under an internal [`Fragment`][pythonnative.Fragment] so + they all share the same provided value without an extra wrapping + native view. Args: - context: The `Context` to set. + context: The [`Context`][pythonnative.hooks.Context] to set. value: Value made available to descendants via [`use_context`][pythonnative.use_context]. - child: Subtree under which the provider applies. + *children: Subtree(s) under which the provider applies. Returns: An [`Element`][pythonnative.Element] that the reconciler treats @@ -701,10 +701,64 @@ def Provider(context: Context, value: Any, child: Element) -> Element: @pn.component def App(): - return pn.Provider(ThemeContext, {"primary": "#FF0000"}, MyView()) + return pn.Provider( + ThemeContext, + {"primary": "#FF0000"}, + Header(), + Body(), + ) + ``` + """ + if not children: + kids: List[Element] = [] + elif len(children) == 1: + kids = [children[0]] + else: + kids = [Element("__Fragment__", {}, list(children))] + return Element("__Provider__", {"__context__": context, "__value__": value}, kids) + + +def memo(component_fn: Callable[..., Element]) -> Callable[..., Element]: + """Skip a function component's render when its props haven't changed. + + Decorate a ``@component``-wrapped function to opt into shallow-prop + memoization. When the reconciler re-renders the parent tree, a + memoized child is skipped (its previously-rendered subtree is + reused) iff: + + - Its props are shallowly equal to the previous render's props + (callables compared by identity, scalars by ``==``). + - None of its internal ``use_state`` / ``use_reducer`` setters fired + since the last render. + + Pair with [`use_callback`][pythonnative.use_callback] when passing + callbacks as props, otherwise a fresh closure will defeat the memo. + + Args: + component_fn: A function previously decorated with + [`component`][pythonnative.component]. + + Returns: + The same function, marked for memoization. + + Example: + ```python + import pythonnative as pn + + @pn.memo + @pn.component + def ExpensiveRow(label: str): + ... ``` """ - return Element("__Provider__", {"__context__": context, "__value__": value}, [child]) + component_fn._pn_memo = True + # ``@component`` builds a wrapper that emits an ``Element`` whose + # ``type`` is the underlying function, so propagate the marker to + # ``__wrapped__`` so the reconciler can find it via ``Element.type``. + wrapped = getattr(component_fn, "__wrapped__", None) + if wrapped is not None: + wrapped._pn_memo = True + return component_fn # ====================================================================== diff --git a/src/pythonnative/native_views/android.py b/src/pythonnative/native_views/android.py index 594c5d1..583addd 100644 --- a/src/pythonnative/native_views/android.py +++ b/src/pythonnative/native_views/android.py @@ -1315,14 +1315,14 @@ def _apply(self, props: Dict[str, Any]) -> None: return if "background_color" in props and props["background_color"] is not None: window.setStatusBarColor(parse_color_int(props["background_color"])) - if "style" in props and props["style"] is not None: + if "bar_style" in props and props["bar_style"] is not None: # API 23+: setSystemUiVisibility with SYSTEM_UI_FLAG_LIGHT_STATUS_BAR # for dark-content (light backgrounds), 0 for light-content. View = jclass("android.view.View") - style = props["style"] + bar_style = props["bar_style"] decor = window.getDecorView() flags = decor.getSystemUiVisibility() - if style in ("dark", "default"): + if bar_style in ("dark", "default"): flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR else: flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR @@ -1577,6 +1577,97 @@ def onClick(self, dialog: Any, which: int) -> None: pass +# ====================================================================== +# Picker — native dropdown / select widget +# ====================================================================== +# +# Renders the PythonNative `Picker` element as an Android ``Spinner``, +# which is the platform's standard dropdown widget. The selected item is +# pushed to the user's callback via ``OnItemSelectedListener``. + + +class PickerHandler(AndroidViewHandler): + """``Picker`` element handler — native ``Spinner`` dropdown.""" + + def create(self, props: Dict[str, Any]) -> Any: + Spinner = jclass("android.widget.Spinner") + sp = Spinner(_ctx()) + self._state: Dict[int, Dict[str, Any]] = getattr(self, "_state", {}) + self._state[id(sp)] = {"items": [], "on_change": None, "suppress": False} + self._apply(sp, props, initial=True) + return sp + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + self._apply(native_view, changed, initial=False) + + def _apply(self, sp: Any, props: Dict[str, Any], initial: bool) -> None: + state = self._state.setdefault(id(sp), {"items": [], "on_change": None, "suppress": False}) + + if "items" in props or initial: + items = list(props.get("items") or state.get("items") or []) + labels = [] + for item in items: + if isinstance(item, dict): + labels.append(str(item.get("label", item.get("value", "")))) + else: + labels.append(str(item)) + ArrayAdapter = jclass("android.widget.ArrayAdapter") + R = jclass("android.R") + adapter = ArrayAdapter(_ctx(), R.layout.simple_spinner_item, labels) + adapter.setDropDownViewResource(R.layout.simple_spinner_dropdown_item) + state["suppress"] = True + sp.setAdapter(adapter) + state["suppress"] = False + state["items"] = items + + if "value" in props or initial: + items = state["items"] + value = props.get("value") if "value" in props else None + target_index = -1 + for i, item in enumerate(items): + v = item.get("value") if isinstance(item, dict) else item + if v == value: + target_index = i + break + if target_index >= 0 and sp.getSelectedItemPosition() != target_index: + state["suppress"] = True + sp.setSelection(target_index, False) + state["suppress"] = False + + if "on_change" in props or initial: + state["on_change"] = props.get("on_change") if "on_change" in props else state.get("on_change") + + class _PickerListener(dynamic_proxy(jclass("android.widget.AdapterView").OnItemSelectedListener)): + def __init__(self, owner_state: Dict[str, Any]) -> None: + super().__init__() + self._owner_state = owner_state + + def onItemSelected( + self, + parent: Any, + view: Any, # noqa: ARG002 + position: int, + id_: int, # noqa: ARG002 + ) -> None: + if self._owner_state.get("suppress"): + return + items = self._owner_state.get("items") or [] + if 0 <= position < len(items): + item = items[position] + v = item.get("value") if isinstance(item, dict) else item + cb = self._owner_state.get("on_change") + if cb is not None: + try: + cb(v) + except Exception: + pass + + def onNothingSelected(self, parent: Any) -> None: # noqa: ARG002 + pass + + sp.setOnItemSelectedListener(_PickerListener(state)) + + # ====================================================================== # Registration # ====================================================================== @@ -1606,6 +1697,7 @@ def register_handlers(registry: Any) -> None: registry.register("StatusBar", StatusBarHandler()) registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler()) registry.register("VirtualList", VirtualListHandler()) + registry.register("Picker", PickerHandler()) __all__ = [ @@ -1629,5 +1721,6 @@ def register_handlers(registry: Any) -> None: "StatusBarHandler", "KeyboardAvoidingViewHandler", "VirtualListHandler", + "PickerHandler", "register_handlers", ] diff --git a/src/pythonnative/native_views/base.py b/src/pythonnative/native_views/base.py index 3c2ae87..e51eb67 100644 --- a/src/pythonnative/native_views/base.py +++ b/src/pythonnative/native_views/base.py @@ -1,8 +1,9 @@ """Shared base classes and utilities for native-view handlers. Provides the [`ViewHandler`][pythonnative.native_views.base.ViewHandler] -protocol implemented by Android and iOS handlers, plus common helpers -for color parsing and padding normalization shared across platforms. +protocol implemented by Android and iOS handlers, plus the +[`parse_color_int`][pythonnative.native_views.base.parse_color_int] +helper shared across platforms. Layout itself is *not* a handler responsibility. The pure-Python flex engine in ``pythonnative.layout`` owns sizing and positioning; @@ -150,96 +151,6 @@ def parse_color_int(color: Union[str, int]) -> int: return val -# ====================================================================== -# Padding helper (kept for backwards-compat; now mostly used by -# handlers that apply padding to native widgets, e.g., text inset). -# ====================================================================== - - -def resolve_padding(padding: Any) -> Tuple[int, int, int, int]: - """Normalize a padding value to ``(left, top, right, bottom)``. - - Accepts: - - - `None`: returns `(0, 0, 0, 0)`. - - A scalar int/float: same value on all sides. - - A dict with any of `horizontal`, `vertical`, `left`, `right`, - `top`, `bottom`, `all` keys. - - Args: - padding: One of the forms above. - - Returns: - A 4-tuple of `(left, top, right, bottom)` ints. - """ - 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) - - -# ====================================================================== -# Backwards-compat constants (re-exports of layout engine constants). -# Kept here so legacy imports of pythonnative.native_views.base still -# resolve without modification. -# ====================================================================== - -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 whether `direction` represents a vertical (column) axis.""" - return direction in (FLEX_DIRECTION_COLUMN, FLEX_DIRECTION_COLUMN_REVERSE) - - -# Visual prop keys handled by container handlers (subset of all props -# they care about; layout-related keys are owned by the layout engine). -CONTAINER_VISUAL_KEYS = frozenset( - { - "background_color", - "overflow", - } -) - - # ====================================================================== # Helpers shared by Android and iOS measure callbacks # ====================================================================== diff --git a/src/pythonnative/native_views/ios.py b/src/pythonnative/native_views/ios.py index a388e63..c0f8d10 100644 --- a/src/pythonnative/native_views/ios.py +++ b/src/pythonnative/native_views/ios.py @@ -1802,11 +1802,11 @@ def _apply(self, props: Dict[str, Any]) -> None: app = UIApplication.sharedApplication if "hidden" in props and props["hidden"] is not None: app.setStatusBarHidden_animated_(bool(props["hidden"]), True) - if "style" in props and props["style"] is not None: + if "bar_style" in props and props["bar_style"] is not None: # 0 = default (dark content on iOS 12-), 1 = lightContent, # 3 = darkContent (iOS 13+). mapping = {"default": 3, "light": 1, "dark": 3} - app.setStatusBarStyle_animated_(mapping.get(props["style"], 0), True) + app.setStatusBarStyle_animated_(mapping.get(props["bar_style"], 0), True) except Exception: pass @@ -2505,6 +2505,123 @@ def _on_action(action: _ct.c_void_p) -> None: # noqa: ARG001 pass +# ====================================================================== +# Picker — native dropdown / select widget +# ====================================================================== +# +# The PythonNative `Picker` element renders as a `UIButton` whose tap +# presents a native action sheet (``UIAlertController``) listing the +# options. Selecting a row fires ``on_change(value)``. Action sheets +# are the standard iOS dropdown pattern for a small-to-medium set of +# choices; for very large lists, paginate or use a custom navigator. + + +_pn_picker_state: dict = {} +# Maps ``id(target)`` -> ``id(button)`` so the single shared +# ``_PNPickerTarget`` class can look up per-instance picker state on tap. +_pn_picker_target_to_button: dict = {} + + +def _picker_button_title(props: Dict[str, Any]) -> str: + """Render the selected label, falling back to the placeholder.""" + items = props.get("items") or [] + selected = props.get("value") + for item in items: + if isinstance(item, dict) and item.get("value") == selected: + return str(item.get("label", item.get("value", ""))) + return str(props.get("placeholder") or "Select…") + + +class _PNPickerTarget(NSObject): # type: ignore[valid-type] + """Shared ObjC target for every Picker button. + + Defined exactly once at module load. ``UIButton`` instances each + retain their own ``_PNPickerTarget.new()`` instance, and the per- + instance picker state is looked up in + :data:`_pn_picker_target_to_button` / :data:`_pn_picker_state`. + """ + + @objc_method + def onTap_(self, sender: object) -> None: # noqa: ARG002 + bid = _pn_picker_target_to_button.get(id(self)) + if bid is None: + return + state = _pn_picker_state.get(bid) + if not state: + return + items = list(state.get("items") or []) + on_change = state.get("on_change") + placeholder = state.get("placeholder") or "Select…" + + def _make_press(value: Any) -> Callable[[], None]: + def _press() -> None: + if on_change is not None: + try: + on_change(value) + except Exception: + pass + + return _press + + buttons: List[Dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + label = str(item.get("label", item.get("value", ""))) + buttons.append({"label": label, "on_press": _make_press(item.get("value"))}) + buttons.append({"label": "Cancel", "style": "cancel"}) + _present_alert(title=str(placeholder), message=None, buttons=buttons, style="action_sheet") + + +def _picker_make_target(button_id: int) -> Any: + """Build a retained ObjC target wired to ``button_id``'s picker state.""" + target = _PNPickerTarget.new() + target.retain() + _pn_retained_views.append(target) + _pn_picker_target_to_button[id(target)] = button_id + return target + + +class PickerHandler(IOSViewHandler): + """``Picker`` element handler — native action-sheet dropdown.""" + + def create(self, props: Dict[str, Any]) -> Any: + UIButton = ObjCClass("UIButton") + btn = UIButton.buttonWithType_(1) # UIButtonTypeSystem + btn.setTranslatesAutoresizingMaskIntoConstraints_(True) + bid = id(btn) + _pn_picker_state[bid] = { + "items": list(props.get("items") or []), + "on_change": props.get("on_change"), + "placeholder": props.get("placeholder") or "Select…", + "value": props.get("value"), + } + target = _picker_make_target(bid) + _pn_picker_state[bid]["target"] = target + btn.addTarget_action_forControlEvents_(target, SEL("onTap:"), 1 << 6) # touchUpInside + btn.setTitle_forState_(_picker_button_title(props), 0) + _apply_accessibility(btn, props) + return btn + + def update(self, native_view: Any, changed: Dict[str, Any]) -> None: + bid = id(native_view) + state = _pn_picker_state.setdefault(bid, {}) + for key in ("items", "on_change", "placeholder", "value"): + if key in changed: + state[key] = changed[key] + native_view.setTitle_forState_(_picker_button_title(state), 0) + _apply_accessibility(native_view, changed) + + def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]: + try: + mw = _safe_max(max_width, fallback=10000.0) + mh = _safe_max(max_height, fallback=10000.0) + size = native_view.sizeThatFits_((mw, mh)) + return (float(size.width) + 16.0, float(size.height) + 8.0) + except Exception: + return (120.0, 36.0) + + # ====================================================================== # Registration # ====================================================================== @@ -2534,6 +2651,7 @@ def register_handlers(registry: Any) -> None: registry.register("StatusBar", StatusBarHandler()) registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler()) registry.register("VirtualList", VirtualListHandler()) + registry.register("Picker", PickerHandler()) __all__ = [ @@ -2557,6 +2675,7 @@ def register_handlers(registry: Any) -> None: "StatusBarHandler", "KeyboardAvoidingViewHandler", "VirtualListHandler", + "PickerHandler", "register_handlers", ] diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index 2334e7d..da39949 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -42,6 +42,58 @@ _RECONCILER_OWNED_PROPS = frozenset({"ref"}) +def _shallow_equal_props(old: dict, new: dict) -> bool: + """Return whether two prop dicts are equal under shallow comparison. + + Used by [`memo`][pythonnative.memo] to skip re-rendering when none + of a component's props changed identity. Callables only count as + equal if they're the *same object* — fresh closures always invalidate + the memo (matching React's behavior; pair with + [`use_callback`][pythonnative.use_callback] when stability matters). + """ + if old is new: + return True + if set(old.keys()) != set(new.keys()): + return False + for key, ov in old.items(): + nv = new[key] + if ov is nv: + continue + if callable(ov) or callable(nv): + return False + try: + if ov != nv: + return False + except Exception: + return False + return True + + +def _flatten_children(children: List[Element]) -> List[Element]: + """Expand [`Fragment`][pythonnative.Fragment] elements inline. + + The reconciler treats Fragments as transparent: when one appears in + a child list, its own children become direct siblings of the + Fragment's location in the parent's child list. This keeps the + Fragment element out of the native tree entirely. + + Args: + children: An ordered child list possibly containing Fragments. + + Returns: + A new list with every Fragment recursively expanded in place. + """ + if not children: + return list(children) + out: List[Element] = [] + for el in children: + if isinstance(el.type, str) and el.type == "__Fragment__": + out.extend(_flatten_children(el.children)) + else: + out.append(el) + return out + + class VNode: """A mounted [`Element`][pythonnative.Element] plus its native view. @@ -251,12 +303,13 @@ def _flush_tree_effects(self, node: VNode) -> None: # ------------------------------------------------------------------ def _create_tree(self, element: Element) -> VNode: - # Provider: push context, create child, pop context + # Provider: push context, create children, pop context if element.type == "__Provider__": context = element.props["__context__"] context._stack.append(element.props["__value__"]) try: - child_node = self._create_tree(element.children[0]) if element.children else None + provider_children = _flatten_children(element.children) + child_node = self._create_tree(provider_children[0]) if provider_children else None finally: context._stack.pop() native_view = child_node.native_view if child_node else None @@ -267,6 +320,16 @@ def _create_tree(self, element: Element) -> VNode: if element.type == "__ErrorBoundary__": return self._create_error_boundary(element) + # Fragment elements should never reach here directly (the parent + # flattens them out of its child list). If we somehow get one as + # a root element, mount its first child. + if element.type == "__Fragment__": + kids = _flatten_children(element.children) + if not kids: + return VNode(element, None, []) + child_node = self._create_tree(kids[0]) + return VNode(element, child_node.native_view, [child_node]) + # Function component: call with hook context if callable(element.type): from .hooks import HookState, _set_hook_state @@ -278,6 +341,7 @@ def _create_tree(self, element: Element) -> VNode: rendered = element.type(**element.props) finally: _set_hook_state(None) + hook_state._dirty = False child_node = self._create_tree(rendered) vnode = VNode(element, child_node.native_view, [child_node]) @@ -299,7 +363,8 @@ def _create_tree(self, element: Element) -> VNode: self._log_viewport(f"_create_tree: native created type={element.type!r} view={self._obj_debug(native_view)}") self._attach_ref(element, native_view) children: List[VNode] = [] - for i, child_el in enumerate(element.children): + flat_children = _flatten_children(element.children) + for i, child_el in enumerate(flat_children): child_type = self._type_label(child_el.type) self._log_viewport(f"_create_tree: creating child[{i}] type={child_type!r} of {element.type!r}") try: @@ -329,8 +394,9 @@ def _create_tree(self, element: Element) -> VNode: def _create_error_boundary(self, element: Element) -> VNode: fallback_fn = element.props.get("__fallback__") + eb_children = _flatten_children(element.children) try: - child_node = self._create_tree(element.children[0]) if element.children else None + child_node = self._create_tree(eb_children[0]) if eb_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 @@ -358,12 +424,13 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: context = new_el.props["__context__"] context._stack.append(new_el.props["__value__"]) try: - if old.children and new_el.children: - child = self._reconcile_node(old.children[0], new_el.children[0]) + provider_kids = _flatten_children(new_el.children) + if old.children and provider_kids: + child = self._reconcile_node(old.children[0], provider_kids[0]) old.children = [child] old.native_view = child.native_view - elif new_el.children: - child = self._create_tree(new_el.children[0]) + elif provider_kids: + child = self._create_tree(provider_kids[0]) old.children = [child] old.native_view = child.native_view finally: @@ -379,6 +446,15 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: if callable(new_el.type): from .hooks import _set_hook_state + # ``@memo`` skip: if the props haven't changed shallowly and + # the component's own hook state is clean (no setter fired + # while we were rebuilding the parent tree), reuse the + # previously-rendered subtree without invoking the body. + if self._can_skip_memoized(old, new_el): + old.element = new_el + self._log_viewport(f"_reconcile_node: memo skip type={self._type_label(new_el.type)!r}") + return old + hook_state = old.hook_state if hook_state is None: from .hooks import HookState @@ -391,6 +467,7 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: rendered = new_el.type(**new_el.props) finally: _set_hook_state(None) + hook_state._dirty = False if old.children: child = self._reconcile_node(old.children[0], rendered) @@ -439,13 +516,14 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode: fallback_fn = new_el.props.get("__fallback__") + eb_kids = _flatten_children(new_el.children) try: - if old.children and new_el.children: - child = self._reconcile_node(old.children[0], new_el.children[0]) + if old.children and eb_kids: + child = self._reconcile_node(old.children[0], eb_kids[0]) old.children = [child] old.native_view = child.native_view - elif new_el.children: - child = self._create_tree(new_el.children[0]) + elif eb_kids: + child = self._create_tree(eb_kids[0]) old.children = [child] old.native_view = child.native_view except Exception as exc: @@ -461,10 +539,40 @@ def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode: old.element = new_el return old + @staticmethod + def _can_skip_memoized(old: VNode, new_el: Element) -> bool: + """Return whether a memo'd function component can skip its body. + + A component is skippable iff: + + 1. Its type has the ``_pn_memo`` marker set by + [`memo`][pythonnative.memo]. + 2. It has been rendered before (``old._rendered`` is populated). + 3. None of its internal state setters fired since the last + render (``hook_state._dirty`` is ``False``). + 4. The new props are shallowly equal to the old props. + """ + fn = new_el.type + if not getattr(fn, "_pn_memo", False): + return False + if old._rendered is None: + return False + hook_state = old.hook_state + if hook_state is None: + return False + if hook_state._dirty: + return False + return _shallow_equal_props(old.element.props, new_el.props) + def _reconcile_children(self, parent: VNode, new_children: List[Element]) -> None: + new_children = _flatten_children(new_children) old_children = parent.children parent_type = parent.element.type - is_native = isinstance(parent_type, str) and parent_type not in ("__Provider__", "__ErrorBoundary__") + is_native = isinstance(parent_type, str) and parent_type not in ( + "__Provider__", + "__ErrorBoundary__", + "__Fragment__", + ) old_by_key: dict = {} old_unkeyed: list = [] @@ -714,7 +822,7 @@ def _build_layout_tree(self, vnode: VNode) -> Optional[LayoutNode]: element = vnode.element if not isinstance(element.type, str): return self._build_layout_tree(vnode.children[0]) if vnode.children else None - if element.type in ("__Provider__", "__ErrorBoundary__"): + if element.type in ("__Provider__", "__ErrorBoundary__", "__Fragment__"): return self._build_layout_tree(vnode.children[0]) if vnode.children else None if element.type == "Modal": return None # Off-screen placeholder; not part of the visible flow. @@ -773,6 +881,7 @@ def _wrap_scroll_axis(child: LayoutNode, axis: str) -> LayoutNode: "ProgressBar", "ActivityIndicator", "TabBar", + "Picker", } ) diff --git a/src/pythonnative/sdk/__init__.py b/src/pythonnative/sdk/__init__.py index 1b4b30d..2c4fa1e 100644 --- a/src/pythonnative/sdk/__init__.py +++ b/src/pythonnative/sdk/__init__.py @@ -72,7 +72,7 @@ def App(): """ from ..element import Element -from ..native_views.base import ViewHandler, parse_color_int, resolve_padding +from ..native_views.base import ViewHandler, parse_color_int from ..style import ( Color, Dimension, @@ -118,7 +118,6 @@ def App(): "style", # SDK helpers (re-exported so users only import from one place) "parse_color_int", - "resolve_padding", # Native-component SDK "ENTRY_POINT_GROUP", "Props", diff --git a/tests/test_animated.py b/tests/test_animated.py index c12b58b..11d8213 100644 --- a/tests/test_animated.py +++ b/tests/test_animated.py @@ -6,7 +6,10 @@ import time from typing import Any -from pythonnative.animated import Animated, AnimatedValue +from pythonnative.animated import Animated, AnimatedValue, use_animated_value +from pythonnative.element import Element +from pythonnative.hooks import component +from pythonnative.reconciler import Reconciler # ====================================================================== # AnimatedValue @@ -166,3 +169,77 @@ def test_stop_freezes_value() -> None: # After stop, value should not advance further toward 10. assert abs(v.value - snapshot) < 0.1 assert v.value < 9.0 + + +# ====================================================================== +# use_animated_value +# ====================================================================== + + +class _Stub: + def __init__(self, type_name: str, props: dict) -> None: + self.type_name = type_name + self.props = props + self.children: list = [] + + +class _StubBackend: + def create_view(self, type_name: str, props: dict) -> _Stub: + return _Stub(type_name, props) + + def update_view(self, view: _Stub, type_name: str, changed: dict) -> None: + view.props.update(changed) + + def add_child(self, parent: _Stub, child: _Stub, parent_type: str) -> None: + parent.children.append(child) + + def remove_child(self, parent: _Stub, child: _Stub, parent_type: str) -> None: + parent.children = [c for c in parent.children if c is not child] + + def insert_child(self, parent: _Stub, child: _Stub, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + + +def test_use_animated_value_returns_animated_value() -> None: + captured: list = [] + + @component + def view() -> Element: + v = use_animated_value(0.5) + captured.append(v) + return Element("View", {"opacity": v}, []) + + rec = Reconciler(_StubBackend()) + rec.mount(view()) + assert isinstance(captured[0], AnimatedValue) + assert captured[0].value == 0.5 + + +def test_use_animated_value_stable_across_renders() -> None: + captured: list = [] + + @component + def view() -> Element: + v = use_animated_value(0.0) + captured.append(v) + return Element("View", {"opacity": v}, []) + + rec = Reconciler(_StubBackend()) + rec.mount(view()) + rec.reconcile(view()) + rec.reconcile(view()) + assert captured[0] is captured[1] is captured[2] + + +def test_use_animated_value_default_initial_zero() -> None: + captured: list = [] + + @component + def view() -> Element: + v = use_animated_value() + captured.append(v) + return Element("View", {"opacity": v}, []) + + rec = Reconciler(_StubBackend()) + rec.mount(view()) + assert captured[0].value == 0.0 diff --git a/tests/test_components.py b/tests/test_components.py index eaac14e..92e30c5 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -6,6 +6,7 @@ Column, ErrorBoundary, FlatList, + Fragment, Image, Modal, Pressable, @@ -401,3 +402,36 @@ def test_error_boundary_no_child() -> None: def test_error_boundary_with_key() -> None: el = ErrorBoundary(Text("x"), fallback=Text("err"), key="eb1") assert el.key == "eb1" + + +# ====================================================================== +# Fragment +# ====================================================================== + + +def test_fragment_no_children() -> None: + el = Fragment() + assert el.type == "__Fragment__" + assert el.children == [] + assert el.props == {} + + +def test_fragment_with_children() -> None: + a = Text("a") + b = Text("b") + el = Fragment(a, b) + assert el.type == "__Fragment__" + assert el.children == [a, b] + + +def test_fragment_with_key() -> None: + el = Fragment(Text("a"), key="frag1") + assert el.key == "frag1" + assert "key" not in el.props + + +def test_fragment_drops_none_children() -> None: + a = Text("a") + c = Text("c") + el = Fragment(a, None, c, None) + assert el.children == [a, c] diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 93627bd..4c03fcb 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -12,6 +12,7 @@ batch_updates, component, create_context, + memo, use_callback, use_context, use_effect, @@ -722,3 +723,115 @@ def _get_nav_args(self) -> dict: assert len(popped) == 1 assert handle.get_params() == {"key": "value"} + + +# ====================================================================== +# Provider with *children +# ====================================================================== + + +def test_provider_with_multiple_children_wraps_in_fragment() -> None: + theme = create_context("light") + child_a = Element("Text", {"text": "a"}, []) + child_b = Element("Text", {"text": "b"}, []) + + el = Provider(theme, "dark", child_a, child_b) + assert el.type == "__Provider__" + inner = el.children[0] + assert inner.type == "__Fragment__" + assert inner.children == [child_a, child_b] + + +def test_provider_with_single_child_no_fragment_wrap() -> None: + theme = create_context("light") + child = Element("Text", {"text": "single"}, []) + + el = Provider(theme, "dark", child) + assert el.type == "__Provider__" + assert el.children == [child] + + +# ====================================================================== +# @memo +# ====================================================================== + + +def test_memo_marks_component() -> None: + @memo + @component + def my_comp(label: str = "x") -> Element: + return Element("Text", {"text": label}, []) + + assert getattr(my_comp, "_pn_memo", False) is True + + +def test_memo_skips_rerender_with_same_props() -> None: + render_count = [0] + + @memo + @component + def my_comp(label: str = "x") -> Element: + render_count[0] += 1 + return Element("Text", {"text": label}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(label="A")) + assert render_count[0] == 1 + + rec.reconcile(my_comp(label="A")) + assert render_count[0] == 1, "memoized component should not re-render when props are unchanged" + + +def test_memo_rerenders_when_props_change() -> None: + render_count = [0] + + @memo + @component + def my_comp(label: str = "x") -> Element: + render_count[0] += 1 + return Element("Text", {"text": label}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(my_comp(label="A")) + rec.reconcile(my_comp(label="B")) + assert render_count[0] == 2 + + +def test_memo_rerenders_when_internal_state_changes() -> None: + render_count = [0] + captured_setter: list = [None] + + @memo + @component + def stateful() -> Element: + render_count[0] += 1 + value, set_value = use_state(0) + captured_setter[0] = set_value + return Element("Text", {"text": str(value)}, []) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(stateful()) + assert render_count[0] == 1 + + setter_fn = captured_setter[0] + assert setter_fn is not None + setter_fn(5) + + rec.reconcile(stateful()) + assert render_count[0] == 2, "memo should still re-render when internal state changed" + + +def test_memo_can_be_called_with_explicit_argument() -> None: + """``memo`` works as a plain function as well as a decorator.""" + + @component + def my_comp(label: str = "x") -> Element: + return Element("Text", {"text": label}, []) + + wrapped = memo(my_comp) + assert getattr(wrapped, "_pn_memo", False) is True + el = wrapped(label="hi") + assert isinstance(el, Element) diff --git a/tests/test_native_views.py b/tests/test_native_views.py index 401a765..3517e00 100644 --- a/tests/test_native_views.py +++ b/tests/test_native_views.py @@ -14,9 +14,7 @@ from pythonnative.native_views import NativeViewRegistry, set_registry from pythonnative.native_views.base import ( ViewHandler, - is_vertical, parse_color_int, - resolve_padding, ) # ====================================================================== @@ -53,63 +51,6 @@ def test_parse_color_with_whitespace() -> None: assert parse_color_int(" #FF0000 ") == parse_color_int("#FF0000") -# ====================================================================== -# resolve_padding (legacy helper retained for handlers that still need it) -# ====================================================================== - - -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-engine ownership # ====================================================================== diff --git a/tests/test_new_components.py b/tests/test_new_components.py index e337eec..c2f70f5 100644 --- a/tests/test_new_components.py +++ b/tests/test_new_components.py @@ -25,8 +25,8 @@ def test_status_bar_default() -> None: def test_status_bar_style_and_hidden() -> None: - el = StatusBar(style="dark", background_color="#FFFFFF", hidden=False) - assert el.props["style"] == "dark" + el = StatusBar(bar_style="dark", background_color="#FFFFFF", hidden=False) + assert el.props["bar_style"] == "dark" assert el.props["background_color"] == "#FFFFFF" assert el.props["hidden"] is False @@ -72,29 +72,34 @@ def test_refresh_control_minimal() -> None: # ====================================================================== -def test_picker_renders_pressable_with_label() -> None: +def test_picker_creates_native_picker_element() -> None: + cb = lambda _: None # noqa: E731 el = Picker( value="b", items=[ {"value": "a", "label": "Apple"}, {"value": "b", "label": "Banana"}, ], - on_change=lambda _: None, + on_change=cb, ) - assert el.type == "Pressable" - assert el.props.get("on_press") is not None - # The child should be a Text with the selected label. - assert el.children[0].type == "Text" - assert el.children[0].props["text"] == "Banana" + assert el.type == "Picker" + assert el.props["value"] == "b" + assert el.props["on_change"] is cb + assert el.props["items"] == [ + {"value": "a", "label": "Apple"}, + {"value": "b", "label": "Banana"}, + ] -def test_picker_placeholder_when_no_match() -> None: - el = Picker( - value="zzz", - items=[{"value": "a", "label": "Apple"}], - placeholder="Pick one", - ) - assert el.children[0].props["text"] == "Pick one" +def test_picker_default_placeholder_in_props() -> None: + el = Picker(items=[{"value": "a", "label": "Apple"}]) + assert el.props["placeholder"] == "Select…" + assert el.props["items"] == [{"value": "a", "label": "Apple"}] + + +def test_picker_empty_when_no_items() -> None: + el = Picker() + assert el.props["items"] == [] # ====================================================================== diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 58e8222..d43e2d8 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -846,3 +846,65 @@ def test_spacer_with_size_prop_takes_that_dimension() -> None: a, spacer, b = root.children assert spacer.frame[2] == 24.0, "Spacer(size=24) must take 24pt on the row's main axis" assert b.frame[0] == a.frame[2] + spacer.frame[2], "Sibling sits after spacer" + + +# ====================================================================== +# Fragment (transparent grouping element) +# ====================================================================== + + +def test_fragment_flattens_into_parent() -> None: + """A Fragment inside a parent contributes its children at the parent level.""" + import pythonnative as pn + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount( + Element( + "Column", + {}, + [ + Element("Text", {"text": "before"}, []), + pn.Fragment( + Element("Text", {"text": "frag-a"}, []), + Element("Text", {"text": "frag-b"}, []), + ), + Element("Text", {"text": "after"}, []), + ], + ) + ) + # Find the Column view and assert it received 4 direct children. + create_ops = [op for op in backend.ops if op[0] == "create"] + column_id = next(op[2] for op in create_ops if op[1] == "Column") + add_to_column = [op for op in backend.ops if op[0] == "add_child" and op[1] == column_id] + assert len(add_to_column) == 4, "Fragment should be transparent and contribute 2 children directly" + + +def test_fragment_reconciles_keyed_siblings() -> None: + """Reordering keyed children inside a Fragment moves rather than recreates.""" + import pythonnative as pn + + @component + def row(reversed_order: bool = False) -> Element: + items: list[Element] = [ + Element("Text", {"text": "A"}, [], key="a"), + Element("Text", {"text": "B"}, [], key="b"), + ] + if reversed_order: + items.reverse() + return Element("Column", {}, [pn.Fragment(*items)]) + + backend = MockBackend() + rec = Reconciler(backend) + rec.mount(row(reversed_order=False)) + + create_ops_before = [op for op in backend.ops if op[0] == "create"] + backend.ops.clear() + + rec.reconcile(row(reversed_order=True)) + + create_ops_after = [op for op in backend.ops if op[0] == "create"] + assert create_ops_after == [], "Reordering keyed children inside Fragment must not recreate views" + assert any(op[0] in ("insert_child", "add_child", "remove_child") for op in backend.ops) + # Two Text views existed before; no new ones should have been added. + assert sum(1 for op in create_ops_before if op[1] == "Text") == 2 From 36edda3706cf338787de8f949e33bd28fc8355d9 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 19 May 2026 11:53:45 -0700 Subject: [PATCH 2/3] chore(scripts): add Android emulator launcher --- scripts/start-android-emulator.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100755 scripts/start-android-emulator.sh diff --git a/scripts/start-android-emulator.sh b/scripts/start-android-emulator.sh new file mode 100755 index 0000000..017a780 --- /dev/null +++ b/scripts/start-android-emulator.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Start the local Android emulator used for testing pythonnative apps. +# +# Usage: +# ./scripts/start-android-emulator.sh [avd-name] +# +# Defaults to the "Medium_Phone" AVD. List available AVDs with: +# ~/Library/Android/sdk/emulator/emulator -list-avds + +set -euo pipefail + +AVD_NAME="${1:-Medium_Phone}" +EMULATOR_BIN="${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}/emulator/emulator" + +if [[ ! -x "$EMULATOR_BIN" ]]; then + echo "Error: emulator binary not found at $EMULATOR_BIN" >&2 + echo "Set ANDROID_SDK_ROOT or install the Android SDK emulator." >&2 + exit 1 +fi + +exec "$EMULATOR_BIN" -avd "$AVD_NAME" From 2b42cbe473ca3084d86068abe99334363c8ab7de Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Tue, 19 May 2026 12:17:54 -0700 Subject: [PATCH 3/3] test: match full PythonNative version text in settings e2e flow --- tests/e2e/flows/settings_screen.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/flows/settings_screen.yaml b/tests/e2e/flows/settings_screen.yaml index ab692dc..51792aa 100644 --- a/tests/e2e/flows/settings_screen.yaml +++ b/tests/e2e/flows/settings_screen.yaml @@ -9,7 +9,7 @@ appId: ${APP_ID} - extendedWaitUntil: visible: "Settings" timeout: 10000 -- assertVisible: "PythonNative v" +- assertVisible: "PythonNative v.*" - assertVisible: "Show alert" - tapOn: "Show alert" - extendedWaitUntil: