From 8b54d9919f10a6c25b51192d87175c5118759db6 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 11 May 2026 17:58:13 -0700 Subject: [PATCH 1/6] feat(page,hot_reload)!: add native stack navigation and Fast Refresh --- docs/api/pythonnative.md | 22 ++ docs/concepts/architecture.md | 75 ++++-- docs/getting-started.md | 59 +++- docs/guides/hot-reload.md | 53 +++- docs/guides/navigation.md | 177 ++++++++---- docs/index.md | 10 +- examples/hello-world/app/main_page.py | 102 +++++-- examples/hello-world/app/second_page.py | 2 +- src/pythonnative/__init__.py | 46 ++++ src/pythonnative/app_registry.py | 63 +++++ src/pythonnative/cli/pn.py | 30 ++- src/pythonnative/hooks.py | 21 +- src/pythonnative/hot_reload.py | 180 ++++++++++++- src/pythonnative/navigation.py | 211 ++++++++++++--- src/pythonnative/page.py | 197 +++++++++++++- .../android_template/Navigator.kt | 17 ++ .../android_template/PageFragment.kt | 6 +- .../app/src/main/res/navigation/nav_graph.xml | 2 +- .../ios_template/ViewController.swift | 10 +- tests/test_app_registry.py | 145 ++++++++++ tests/test_cli.py | 4 +- tests/test_hot_reload.py | 206 ++++++++++++++ tests/test_navigation.py | 255 ++++++++++++++++++ 23 files changed, 1716 insertions(+), 177 deletions(-) create mode 100644 src/pythonnative/app_registry.py create mode 100644 tests/test_app_registry.py diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index de5ab9b..cc21190 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -5,6 +5,28 @@ PythonNative re-exports a small public surface from in this overview; deeper internals (`reconciler`, `native_views`, `page`) are documented for contributors and integrators. +## Entry point + +Your app module exports a root component and registers it with +`pn.run`: + +```python +import pythonnative as pn + +@pn.component +def App(): + return pn.NavigationContainer(...) + +pn.run(App) +``` + +`pn.run` mirrors React Native's `AppRegistry.registerComponent`: +it stores the component for the native host to look up. The bundled +Android `PageFragment` and iOS `ViewController` load your app by +**module path** (`"app.main_page"`) and pick up the registered +component, so renaming your root component never requires touching +the native templates. + ::: pythonnative options: show_root_heading: false diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index cb20beb..56b38f6 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -50,6 +50,13 @@ platform APIs synchronously from Python. [`create_page`][pythonnative.create_page] internally to bootstrap your Python component, and the reconciler drives the UI from there. +10. **`pn.run(App)` entry point.** The user's app module exports a + root component and registers it with `pn.run(App)` — mirroring + React Native's `AppRegistry.registerComponent`. The native + templates load the app by **module path** (e.g. + `"app.main_page"`) and pick up whichever component was + registered, so a refactor that renames the root component never + requires touching the Kotlin/Swift templates. ## How it works @@ -269,11 +276,25 @@ See [Mental model](mental-model.md) for a wider comparison table. - State changes trigger re-render; the reconciler patches Android views in place. -## Hot reload +## Hot reload (Fast Refresh) During development, `pn run --hot-reload` watches `app/` for file changes and pushes updated Python files to the running app, enabling -near-instant UI updates without full rebuilds. See +near-instant UI updates without full rebuilds. + +PythonNative uses a **Fast Refresh** strategy: + +1. Reload the changed module(s) on the device. +2. For every active page host, walk the VNode tree and collect every + component function defined in a reloaded module. +3. Match each one to its replacement by `__module__` + + `__qualname__` and rewrite `Element.type` in place. +4. Trigger one reconcile pass. Because the VNode and its `HookState` + are reused, component state (`use_state`, `use_reducer`, refs) is + preserved across the edit. + +If Fast Refresh can't produce a clean swap, the host falls back to a +**full remount** of its root component. See [Hot reload guide](../guides/hot-reload.md). ## Native API modules @@ -293,28 +314,34 @@ See [Native modules guide](../guides/native-modules.md). ## Navigation -PythonNative provides two navigation approaches: - -- **Declarative navigators** (recommended): - [`NavigationContainer`][pythonnative.NavigationContainer] with - [`create_stack_navigator`][pythonnative.create_stack_navigator], - [`create_tab_navigator`][pythonnative.create_tab_navigator], and - [`create_drawer_navigator`][pythonnative.create_drawer_navigator]. - Navigation state is managed in Python as component state, and - navigators are composable; you can nest tabs inside stacks, and so - on. -- **Page-level navigation**: - [`use_navigation`][pythonnative.use_navigation] returns a - navigation handle with `.navigate()`, `.go_back()`, and - `.get_params()`, delegating to native platform navigation when - running on device. - -Both approaches are supported. The declarative system uses the -existing reconciler pipeline; navigators are function components that -render the active screen via `use_state`, and navigation context is -provided via [`Provider`][pythonnative.Provider]. - -See the [Navigation guide](../guides/navigation.md) for full details. +PythonNative navigation is **declarative** and **native-backed**: + +- The user describes their app as a tree of navigators + ([`create_stack_navigator`][pythonnative.create_stack_navigator], + [`create_tab_navigator`][pythonnative.create_tab_navigator], + [`create_drawer_navigator`][pythonnative.create_drawer_navigator]) + wrapped in + [`NavigationContainer`][pythonnative.NavigationContainer], then + registers the root with `pn.run(App)`. +- The outermost `Stack.Navigator` delegates `navigate(...)`, + `go_back()`, and `reset(...)` to the platform's native navigation + controller — `UINavigationController` on iOS and the AndroidX + Navigation Component on Android. Nested navigators (tabs inside a + stack, stacks inside tabs) stay in Python and reuse the existing + reconciler. +- Each pushed native screen is a fresh host with its own reconciler + and `_AppHost`. Initial routes are forwarded via host arguments + (`__pn_initial_route__` / `__pn_initial_params__`), so a pushed + screen knows which `Stack.Screen` to render on its first frame. +- Inside any screen, [`use_navigation`][pythonnative.use_navigation] + returns a `NavigationHandle`; [`use_route`][pythonnative.use_route] + returns the current route name and params. Both are the same + hooks regardless of whether the active navigator is native-backed + or pure-Python. + +See the [Navigation guide](../guides/navigation.md) for the full +walkthrough, including how `options={"title": ...}` flows into the +native navigation bar. - iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. diff --git a/docs/getting-started.md b/docs/getting-started.md index a328bdf..b0f0a63 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -23,27 +23,52 @@ A minimal `app/main_page.py` looks like: ```python import pythonnative as pn +Stack = pn.create_stack_navigator() + @pn.component -def App(): +def HomeScreen(): + nav = pn.use_navigation() count, set_count = pn.use_state(0) return pn.Column( pn.Text(f"Count: {count}", style={"font_size": 24}), - pn.Button( - "Tap me", - on_click=lambda: set_count(count + 1), - ), + pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + pn.Button("Open details", on_click=lambda: nav.navigate("Detail", {"count": count})), style={"spacing": 12, "padding": 16}, ) + + +@pn.component +def DetailScreen(): + route = pn.use_route() + return pn.Text(f"Count was {route.params.get('count', 0)}", style={"padding": 16}) + + +@pn.component +def App(): + return pn.NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", HomeScreen, options={"title": "Home"}), + Stack.Screen("Detail", DetailScreen, options={"title": "Detail"}), + initial_route="Home", + ) + ) + + +pn.run(App) ``` Key ideas: - **`@pn.component`** marks a function as a PythonNative component. The function returns an element tree describing the UI. PythonNative creates and updates native views automatically. - **`pn.use_state(initial)`** creates local component state. Call the setter to update it — the UI re-renders automatically. +- **`pn.create_stack_navigator()`** returns a `Stack` with `.Navigator` and `.Screen` factories. Wrap them in `pn.NavigationContainer` to enable [`pn.use_navigation()`][pythonnative.use_navigation] and [`pn.use_route()`][pythonnative.use_route] anywhere below. +- **`pn.run(App)`** registers `App` as the entry point for this Python process — analogous to `AppRegistry.registerComponent` in React Native. The Android and iOS templates load it automatically. - **`style={...}`** passes visual and layout properties as a dict (or list of dicts) to any component. - Element functions like `pn.Text(...)`, `pn.Button(...)`, `pn.Column(...)` create lightweight descriptions, not native objects. +When the root `Stack.Navigator` is rendered inside the host's first screen, `navigate(...)` and `go_back()` drive the **native** navigation controller (UINavigationController on iOS, AndroidX Navigation Component on Android). Each pushed screen runs in its own reconciler host, so state on the previous screen is preserved by the platform stack. + ## Run on a platform ```bash @@ -75,9 +100,20 @@ pn run ios --hot-reload The first run still builds and launches the native app. After that, edits under `app/` are copied into the running app's writable source -overlay and the active page is remounted without a full rebuild. This is -best for Python UI changes; native template changes still require a -normal rebuild. +overlay and the active page refreshes without a full rebuild. + +PythonNative prefers a **Fast Refresh** path: each +[`@pn.component`][pythonnative.component] function is matched by +qualified name across the reloaded module, the live VNode tree's +function references are swapped in place, and the next render reuses +the existing hook state. So edits to the body of a component preserve +in-memory state (counters, scroll positions, etc.). When Fast Refresh +cannot find a clean swap — for example, after deeper structural +edits — PythonNative falls back to a full remount of the active page +so you never get stuck with a stale tree. + +This works best for Python UI changes; native template changes +(Kotlin, Swift, manifests) still require a normal rebuild. ## Viewing logs @@ -90,13 +126,16 @@ import pythonnative as pn @pn.component -def MainPage(): +def App(): count, set_count = pn.use_state(0) - print(f"[MainPage] render count={count}") + print(f"[App] render count={count}") return pn.Column( pn.Text(f"Count: {count}"), pn.Button("Tap me", on_click=lambda: set_count(count + 1)), ) + + +pn.run(App) ``` - On Android, logs are streamed via `adb logcat` filtered to the diff --git a/docs/guides/hot-reload.md b/docs/guides/hot-reload.md index 438ffed..c39f229 100644 --- a/docs/guides/hot-reload.md +++ b/docs/guides/hot-reload.md @@ -52,12 +52,37 @@ page, and mounts the refreshed tree. PythonNative reloads any `.py` file under `app/`. The device-side [`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] resolves the file to a dotted module name (e.g., `app/pages/home.py` becomes -`app.pages.home`) and calls `importlib.reload` on it. - -After reloading, the page host remounts the active page. Hook state for -the affected page is **reset** on reload. This is intentionally more -conservative than React Native Fast Refresh, but it avoids stale Python -closures while the runtime is still young. +`app.pages.home`) and re-imports it from disk. + +After reloading, every active page host runs **Fast Refresh** in +place: + +1. Walk the live VNode tree and collect every component function + defined in a reloaded module. +2. Look up each function's replacement by `__module__` + + `__qualname__` in the freshly reloaded module (unwrapping the + `@pn.component` decorator). +3. Rewrite the `Element.type` references on every VNode in place — + the next reconcile sees the new function with the same + `HookState`, so state survives. + +The next render runs through +[`Reconciler.reconcile`][pythonnative.reconciler.Reconciler.reconcile] +just like a normal re-render, so layout and native views are +updated incrementally. Component state (`use_state`, `use_reducer`, +refs) is preserved across the swap. + +If Fast Refresh can't find a clean swap — for example, a +component's `__qualname__` changed, a new module was added that the +tree doesn't reference yet, or the swap raises — the host +**falls back** to a full remount of its root component so you never +get stuck with a stale tree. Hook state is reset in that case. + +Per-screen scope: each native screen (UIViewController on iOS, +PageFragment on Android) runs its own host, so Fast Refresh +operates independently per host. Two pushed screens both running +Fast Refresh for the same changed module each swap their own +references. ## What doesn't reload @@ -86,9 +111,19 @@ closures while the runtime is still young. !!! warning "Hook signature changes" Adding or removing a hook in a component changes the slot layout. - The reload picks up the new code, but existing component instances - can't safely keep their old state. Closing and reopening the - affected screen (or restarting the app) clears the slate. + Fast Refresh will swap the function in place but the next render + can read the wrong slots, so the host falls back to a full + remount when it detects the swap raises. If you see suspicious + state after a hook-shape edit, close and reopen the affected + screen (or restart the app) to clear the slate. + +!!! info "Renaming a component" + Fast Refresh keys on each function's `__qualname__`. Renaming a + component changes the key, so the live VNode keeps its old + function until the parent re-renders with the new name. In + practice this means you may need to trigger one navigation or + state change for the renamed component to take effect; closing + and reopening the screen always works. ## Working without `--hot-reload` diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 4fcad58..1cc627f 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -1,40 +1,77 @@ # Navigation -PythonNative offers two approaches to navigation: - -1. **Declarative navigators** (recommended): component-based, - inspired by React Navigation. -2. **Page-level push/pop**: imperative navigation via - [`use_navigation`][pythonnative.use_navigation] (for native page - transitions). +PythonNative navigation is **declarative** and **native-backed**: + +- You describe your screens once as a `Stack.Navigator` (or `Tab` / + `Drawer`) tree. +- At the root of your app, the stack delegates to the platform's + native navigation controller — `UINavigationController` on iOS and + the AndroidX Navigation Component on Android. +- Each pushed screen runs in its own reconciler host, so the screen + you came from is preserved by the platform (including scroll + offsets, animations, gesture-driven back transitions). + +Nested navigators (tabs inside a stack, or stacks inside tabs) are +managed entirely in Python — only the outermost stack delegates to +the host. The same `pn.use_navigation()` and `pn.use_route()` hooks +work everywhere. ## Declarative navigation -Declarative navigators manage screen state as components. Define your -screens once, and the navigator handles rendering, transitions, and -state. +Define a root component that wraps a navigator in +[`NavigationContainer`][pythonnative.NavigationContainer], then +register it with `pn.run`: + +```python +import pythonnative as pn + +Stack = pn.create_stack_navigator() + + +@pn.component +def App(): + return pn.NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", HomeScreen, options={"title": "Home"}), + Stack.Screen("Detail", DetailScreen, options={"title": "Detail"}), + initial_route="Home", + ) + ) + + +pn.run(App) +``` + +The native templates (Android `PageFragment`, iOS `ViewController`) +look up the component registered via `pn.run`, so no other wiring is +required. `options={"title": ...}` propagates to the native +navigation bar. ### Stack navigator -A stack navigator manages a stack of screens; push to go forward, -pop to go back. +A stack navigator manages a stack of screens; push to go forward, pop +to go back. At the root, push/pop run on the **native** navigation +controller; nested stacks manage their own state in Python. ```python import pythonnative as pn -from pythonnative.navigation import NavigationContainer, create_stack_navigator -Stack = create_stack_navigator() +Stack = pn.create_stack_navigator() + @pn.component def App(): - return NavigationContainer( + return pn.NavigationContainer( Stack.Navigator( - Stack.Screen("Home", component=HomeScreen), - Stack.Screen("Detail", component=DetailScreen), + Stack.Screen("Home", HomeScreen, options={"title": "Home"}), + Stack.Screen("Detail", DetailScreen, options={"title": "Detail"}), initial_route="Home", ) ) + +pn.run(App) + @pn.component def HomeScreen(): nav = pn.use_navigation() @@ -65,18 +102,20 @@ screens. On Android the tab bar is a `BottomNavigationView` from Material Components; on iOS it is a `UITabBar`. ```python -from pythonnative.navigation import create_tab_navigator +Tab = pn.create_tab_navigator() -Tab = create_tab_navigator() @pn.component def App(): - return NavigationContainer( + return pn.NavigationContainer( Tab.Navigator( - Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), - Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + Tab.Screen("Home", HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", SettingsScreen, options={"title": "Settings"}), ) ) + + +pn.run(App) ``` The tab bar emits a `TabBar` element that maps to platform-native views: @@ -91,19 +130,21 @@ The tab bar emits a `TabBar` element that maps to platform-native views: A drawer navigator provides a side menu for switching screens. ```python -from pythonnative.navigation import create_drawer_navigator +Drawer = pn.create_drawer_navigator() -Drawer = create_drawer_navigator() @pn.component def App(): - return NavigationContainer( + return pn.NavigationContainer( Drawer.Navigator( - Drawer.Screen("Home", component=HomeScreen, options={"title": "Home"}), - Drawer.Screen("Profile", component=ProfileScreen, options={"title": "Profile"}), + Drawer.Screen("Home", HomeScreen, options={"title": "Home"}), + Drawer.Screen("Profile", ProfileScreen, options={"title": "Profile"}), ) ) + +pn.run(App) + @pn.component def HomeScreen(): nav = pn.use_navigation() @@ -121,29 +162,42 @@ automatically **forwards** the request to its parent navigator. Similarly, `go_back()` at the root of a child stack forwards to the parent. +Nested navigators stay in Python — only the outermost stack delegates +to the native navigation controller. This is the right default: tab +switches should be cheap, in-process, and reuse already-mounted +screens, while top-level pushes deserve a native navigation +controller, swipe-to-go-back, and proper state restoration. + ```python -Stack = create_stack_navigator() -Tab = create_tab_navigator() +Stack = pn.create_stack_navigator() +Tab = pn.create_tab_navigator() + @pn.component -def HomeStack(): - return Stack.Navigator( - Stack.Screen("Feed", component=FeedScreen), - Stack.Screen("Post", component=PostScreen), +def MainTabs(): + return Tab.Navigator( + Tab.Screen("Home", HomeScreen, options={"title": "Home"}), + Tab.Screen("Settings", SettingsScreen, options={"title": "Settings"}), ) + @pn.component def App(): - return NavigationContainer( - Tab.Navigator( - Tab.Screen("Home", component=HomeStack, options={"title": "Home"}), - Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), + return pn.NavigationContainer( + Stack.Navigator( + Stack.Screen("Tabs", MainTabs, options={"title": "Home"}), + Stack.Screen("Detail", DetailScreen, options={"title": "Detail"}), ) ) + + +pn.run(App) ``` -Inside `FeedScreen`, calling `nav.navigate("Settings")` will forward to the -parent tab navigator and switch to the Settings tab. +Inside `HomeScreen`, calling `nav.navigate("Detail")` walks up to the +root `Stack.Navigator`, which pushes a fresh native screen. +`nav.navigate("Settings")` from inside `DetailScreen` walks up to the +`Tab.Navigator` (after popping back) and switches tabs. ## NavigationHandle API @@ -209,16 +263,45 @@ PythonNative forwards lifecycle events from the host: ## Platform specifics -### iOS (UIViewController per page) -- Each PythonNative screen is hosted by a Swift `ViewController` instance. -- Screens are pushed and popped on a root `UINavigationController`. -- Lifecycle is forwarded from Swift to the registered Python component. +### iOS (UIViewController per screen) +- Each pushed screen is a Swift `ViewController` instance with its + own Python `_AppHost` and reconciler. +- Screens are pushed and popped on a root `UINavigationController` + set up by the template's `SceneDelegate`. +- The declarative `Stack.Navigator` delegates to + `nav.pushViewController_animated_` / `popViewControllerAnimated_` + and the initial-route name is forwarded via the host's + `requestedPagePath` / `requestedPageArgsJSON` properties. +- Screen `options.title` is applied via `UIViewController.title`, + which the surrounding `UINavigationController` picks up. ### Android (single Activity, Fragment stack) -- Single host `MainActivity` sets a `NavHostFragment` containing a navigation graph. -- Each PythonNative screen is represented by a generic `PageFragment` which instantiates the Python component and attaches its root view. -- `push`/`pop` delegate to `NavController` (via a small `Navigator` helper). -- Arguments live in Fragment arguments and restore across configuration changes. +- The host `MainActivity` embeds a `NavHostFragment` containing a + navigation graph with a single generic `PageFragment` destination. +- Each pushed screen is a fresh `PageFragment` instance with its own + Python `_AppHost` and reconciler; arguments live in Fragment + arguments (`page_path` / `args_json`) and restore across + configuration changes. +- Push/pop delegate to `NavController` through a small `Navigator` + Kotlin helper, including `popToRoot` for `Stack.reset(...)`. +- Screen `options.title` is forwarded to `Activity.setTitle`. + +### Why per-screen hosts? + +Pushing onto a native stack is most useful when the new screen does +not have to re-bootstrap Python or re-run the whole tree. Each +pushed view-controller / fragment owns its own Python `_AppHost`, +so: + +- The previous screen's reconciler stays alive in memory; its hook + state and native views are preserved by the platform stack. +- The new screen's `_AppHost` resolves its initial route from the + arguments passed by the parent's `navigate(...)` call, so the + declarative `Stack.Navigator` always renders the right screen on + the first frame. +- Hot reload runs **per host**: each active screen swaps its + function references in place ("Fast Refresh") and only the screens + that cannot be refreshed cleanly fall back to a full remount. ## Comparison to other frameworks diff --git a/docs/index.md b/docs/index.md index 156d8cb..fe4d9c9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,8 +39,14 @@ produce identical frames on both platforms. - **No JS bridge, no transpiler.** The reconciler runs synchronously in Python on the platform's main thread; native API calls are direct method calls. -- **Hot reload built in.** `pn run --hot-reload` watches `app/` and - patches changes into the running app. +- **Native-backed navigation.** The root `Stack.Navigator` drives + the platform's real navigation controller — Android Navigation + Component fragments on Android, `UINavigationController` on iOS — + so transitions, back gestures, and state preservation are exactly + what users expect from a first-class native app. +- **Fast Refresh hot reload.** `pn run --hot-reload` watches `app/` + and patches the running app in place, preserving component state + across most edits. - **A small surface.** A handful of element factories, a handful of hooks, and one navigation primitive. diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main_page.py index 9b456c3..0cee7c3 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main_page.py @@ -1,15 +1,32 @@ +"""Hello-world demo with native-backed stack navigation. + +The app's root is an [`App`][app.main_page.App] component that returns +a [`Stack`][pythonnative.create_stack_navigator] navigator wrapping a +[`Tab`][pythonnative.create_tab_navigator] navigator. ``pn.run(App)`` at +module level registers the component so the templates can boot the app +just by importing this module. + +When the user taps "Go to Second Page" from inside a tab, the stack +navigator pushes a real ``UIViewController`` / ``Fragment`` so they get +system-grade slide transitions and swipe-back. Each push reuses this +Python interpreter — only the reconciler tree for the new screen is +created. +""" + from typing import Callable import emoji import pythonnative as pn -from pythonnative.navigation import NavigationContainer, create_tab_navigator +from app.second_page import SecondPage +from app.third_page import ThirdPage print("[hello-world] main_page module imported") MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] -Tab = create_tab_navigator() +Stack = pn.create_stack_navigator() +Tab = pn.create_tab_navigator() styles = pn.StyleSheet.create( title={"font_size": 24, "bold": True}, @@ -50,7 +67,12 @@ @pn.component def counter_badge(initial: int = 0) -> pn.Element: - """Reusable counter component with its own hook-based state.""" + """Reusable counter component with its own hook-based state. + + State is preserved across Fast Refresh: edit the medal list or + tweak this component, save, and the on-screen tap count stays + where you left it. + """ count, set_count = pn.use_state(initial) medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") @@ -78,7 +100,12 @@ def handle_reset() -> None: @pn.component def HomeTab() -> pn.Element: - """Home tab — counter demo and push-navigation to other pages.""" + """Home tab — counter demo and push-navigation to other pages. + + ``nav.navigate("Second", ...)`` goes through the inner Tab handle, + forwards to the outer Stack handle (root navigator), and pushes a + real native screen via the host's ``_push`` API. + """ nav = pn.use_navigation() def _on_mount() -> Callable[[], None]: @@ -88,18 +115,16 @@ def _on_mount() -> Callable[[], None]: pn.use_effect(_on_mount, []) def go_to_second() -> None: - print("[HomeTab] navigating to SecondPage") - nav.navigate( - "app.second_page.SecondPage", - params={"message": "Greetings from MainPage"}, - ) + print("[HomeTab] navigating to Second") + nav.navigate("Second", {"message": "Greetings from MainPage"}) return pn.ScrollView( pn.Column( pn.Text("Hello from PythonNative Demo!", style=styles["title"]), pn.Text( "Try `pn run android --hot-reload`, edit this text, and save. " - "The running app should update without a rebuild.", + "The running app should update without a rebuild, and the counter " + "below should preserve its value across the refresh.", style=styles["hint"], ), counter_badge(), @@ -113,10 +138,9 @@ def go_to_second() -> None: def LayoutTab() -> pn.Element: """Demonstrates the pure-Python flex layout engine. - Showcases features that only became possible after the layout - rewrite: ``flex: 1`` distribution between siblings, fixed-aspect - boxes, and ``position: "absolute"`` overlays anchored to all - four edges. + Showcases ``flex: 1`` distribution between siblings, fixed-aspect + boxes, and ``position: "absolute"`` overlays anchored to all four + edges. """ return pn.ScrollView( pn.Column( @@ -201,7 +225,7 @@ def LayoutTab() -> pn.Element: @pn.component def SettingsTab() -> pn.Element: - """Settings tab — Platform info, alerts, and a virtualized FlatList.""" + """Settings tab — Platform info, alerts, and a quick push to the showcase.""" nav = pn.use_navigation() dims = pn.use_window_dimensions() @@ -225,7 +249,7 @@ def _confirm_destructive() -> None: ) def _go_to_showcase() -> None: - nav.navigate("app.second_page.SecondPage", params={"message": "Visual showcase"}) + nav.navigate("Second", {"message": "Visual showcase"}) return pn.ScrollView( pn.Column( @@ -287,12 +311,44 @@ def render_row(item: dict, index: int) -> pn.Element: @pn.component -def MainPage() -> pn.Element: - return NavigationContainer( - Tab.Navigator( - Tab.Screen("Home", component=HomeTab, options={"title": "Home"}), - Tab.Screen("Layout", component=LayoutTab, options={"title": "Layout"}), - Tab.Screen("List", component=ListTab, options={"title": "List"}), - Tab.Screen("Settings", component=SettingsTab, options={"title": "Settings"}), +def MainTabs() -> pn.Element: + """Root screen of the Stack: a four-tab home, layout, list, settings UI.""" + return Tab.Navigator( + Tab.Screen("Home", component=HomeTab, options={"title": "Home"}), + Tab.Screen("Layout", component=LayoutTab, options={"title": "Layout"}), + Tab.Screen("List", component=ListTab, options={"title": "List"}), + Tab.Screen("Settings", component=SettingsTab, options={"title": "Settings"}), + ) + + +@pn.component +def App() -> pn.Element: + """Root component registered with ``pn.run``. + + A [`Stack`][pythonnative.create_stack_navigator] wraps the tabbed + home screen so the demo can push the showcase / forms pages onto + the native navigation stack. ``options["title"]`` is mirrored to + the platform navigation bar. + """ + return pn.NavigationContainer( + Stack.Navigator( + Stack.Screen("Main", component=MainTabs, options={"title": "Hello World"}), + Stack.Screen("Second", component=SecondPage, options={"title": "Second Page"}), + Stack.Screen("Third", component=ThirdPage, options={"title": "Third Page"}), ) ) + + +pn.run(App) + + +@pn.component +def MainPage() -> pn.Element: + """Backwards-compatible alias for templates that import ``MainPage``. + + The bundled iOS/Android templates default to ``app.main_page.App`` + after the navigation overhaul, but a few earlier templates + referenced ``MainPage`` directly. This shim keeps them working + until they are regenerated with the latest ``pn init`` output. + """ + return App() diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/second_page.py index 6cc8e3b..4e8e29f 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/second_page.py @@ -122,7 +122,7 @@ def _toggle_color() -> None: set_pressed_color("#10B981" if pressed_color == "#0EA5E9" else "#0EA5E9") def go_to_third() -> None: - nav.navigate("app.third_page.ThirdPage") + nav.navigate("Third") def go_back() -> None: nav.go_back() diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 0334f39..a9a33f8 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -43,6 +43,9 @@ def App(): __version__ = "0.12.0" +from typing import Any, Callable + +from . import app_registry as _app_registry from .alerts import Alert from .animated import Animated, AnimatedValue from .components import ( @@ -102,6 +105,48 @@ def App(): from .platform import Platform from .style import StyleSheet, ThemeContext + +def run(component: Callable[..., Any]) -> Callable[..., Any]: + """Register the App component as the root of the application. + + Mirrors React Native's + [`AppRegistry.registerComponent`](https://reactnative.dev/docs/appregistry): + the user's module declares an ``App`` function once and registers + it at import time. Native templates then load the app by importing + its module — they do not need to know the App component's name. + + Args: + component: A zero-argument ``@component`` function. Typically + returns a [`Stack.Navigator`][pythonnative.create_stack_navigator] + wrapped in a + [`NavigationContainer`][pythonnative.NavigationContainer]. + + Returns: + The same ``component`` (so ``run`` can be used as a decorator + in a pinch, though calling it directly is the conventional + form). + + Example: + ```python + import pythonnative as pn + + Stack = pn.create_stack_navigator() + + @pn.component + def App(): + return pn.NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + ) + ) + + pn.run(App) + ``` + """ + _app_registry.register(component) + return component + + __all__ = [ # Components "ActivityIndicator", @@ -131,6 +176,7 @@ def App(): # Core "Element", "create_page", + "run", # Hooks "batch_updates", "component", diff --git a/src/pythonnative/app_registry.py b/src/pythonnative/app_registry.py new file mode 100644 index 0000000..62d35d6 --- /dev/null +++ b/src/pythonnative/app_registry.py @@ -0,0 +1,63 @@ +"""App component registration for the `pn.run(App)` entry point. + +The ``pn.run`` convention mirrors ``AppRegistry.registerComponent`` in +React Native: the user's app module declares a top-level component +function and registers it once at import time. Native templates then +load the app via a single dotted module path (e.g. ``"app.main_page"``) +without needing to know the App component's exact name. + +Example: + ```python + import pythonnative as pn + + @pn.component + def App(): + return pn.NavigationContainer(...) + + pn.run(App) + ``` + +The Android (``PageFragment.kt``) and iOS (``ViewController.swift``) +templates pass the module path to +[`create_page`][pythonnative.create_page], which imports the module +(triggering this registration) and looks up the registered component. +""" + +from typing import Any, Callable, Optional + +_registered_app: Optional[Callable[..., Any]] = None +"""Module-level holder for the most recently registered App component. + +A single registration slot is intentional: real apps have exactly one +root component. Re-calling :func:`register` simply overwrites the +previous value — useful for tests and hot reloading. +""" + + +def register(component: Callable[..., Any]) -> None: + """Register the App component for this Python process. + + Args: + component: A zero-argument ``@component`` function that returns + an [`Element`][pythonnative.Element]. Typically wraps a + [`NavigationContainer`][pythonnative.NavigationContainer] + at the root. + + Raises: + TypeError: If ``component`` is not callable. + """ + global _registered_app + if not callable(component): + raise TypeError(f"pn.run expects a callable component, got {type(component).__name__}") + _registered_app = component + + +def get_registered_app() -> Optional[Callable[..., Any]]: + """Return the registered App component, or ``None`` if not set.""" + return _registered_app + + +def clear() -> None: + """Reset the registered App. Used by tests and full-host resets.""" + global _registered_app + _registered_app = None diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index b8abb45..c045a3a 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -73,18 +73,46 @@ def init_project(args: argparse.Namespace) -> None: with open(main_page_py, "w", encoding="utf-8") as f: f.write("""import pythonnative as pn +Stack = pn.create_stack_navigator() + @pn.component -def MainPage(): +def HomeScreen(): count, set_count = pn.use_state(0) + nav = pn.use_navigation() return pn.ScrollView( pn.Column( pn.Text("Hello from PythonNative!", style={"font_size": 24, "bold": True}), pn.Text(f"Tapped {count} times"), pn.Button("Tap me", on_click=lambda: set_count(count + 1)), + pn.Button("Open detail", on_click=lambda: nav.navigate("Detail", {"count": count})), style={"spacing": 12, "padding": 16, "align_items": "stretch"}, ) ) + + +@pn.component +def DetailScreen(): + nav = pn.use_navigation() + params = pn.use_route() + return pn.Column( + pn.Text(f"Detail: count was {params.get('count', 0)}", style={"font_size": 20}), + pn.Button("Back", on_click=nav.go_back), + style={"spacing": 12, "padding": 16}, + ) + + +@pn.component +def App(): + return pn.NavigationContainer( + Stack.Navigator( + Stack.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Stack.Screen("Detail", component=DetailScreen, options={"title": "Detail"}), + ) + ) + + +pn.run(App) """) # Create config diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py index a21866f..fc22e16 100644 --- a/src/pythonnative/hooks.py +++ b/src/pythonnative/hooks.py @@ -717,8 +717,13 @@ def App(): class NavigationHandle: """Handle returned by [`use_navigation`][pythonnative.use_navigation]. - Wraps the host's push/pop primitives so screens can navigate without - knowing the underlying native navigation stack. + Wraps the host's push/pop primitives so screens can navigate + without knowing the underlying native navigation stack. The + typical user-facing surface is the declarative handle returned by + a [`Stack`][pythonnative.create_stack_navigator] — this class is + the lower-level fallback used when no navigator is rendered (and + as the bridge that declarative navigators delegate to when they + need to push real native screens). Example: ```python @@ -729,7 +734,7 @@ def HomeScreen(): nav = pn.use_navigation() return pn.Button( "Open Detail", - on_click=lambda: nav.navigate(DetailScreen, params={"id": 42}), + on_click=lambda: nav.navigate("Detail", {"id": 42}), ) ``` """ @@ -738,11 +743,15 @@ def __init__(self, host: Any) -> None: self._host = host def navigate(self, page: Any, params: Optional[Dict[str, Any]] = None) -> None: - """Push `page` onto the navigation stack. + """Push ``page`` onto the navigation stack. Args: - page: Either a `@component` function or a dotted Python - path (e.g., `"app.detail.DetailScreen"`). + page: A ``@component`` function or a dotted Python path + (e.g. ``"app.detail.DetailScreen"``). When a Stack + navigator is the root of the app, prefer the + declarative ``nav.navigate("Detail", params)`` form + returned by ``use_navigation()`` — it pushes by route + name and the host re-uses its own ``App`` component. params: Optional dict of arguments serialized into the target screen. """ diff --git a/src/pythonnative/hot_reload.py b/src/pythonnative/hot_reload.py index 77850ba..a5c81d6 100644 --- a/src/pythonnative/hot_reload.py +++ b/src/pythonnative/hot_reload.py @@ -3,16 +3,30 @@ Two cooperating pieces: - **Host-side**: [`FileWatcher`][pythonnative.hot_reload.FileWatcher] - polls the developer's `app/` directory for `.py` changes and - triggers a callback (typically `adb push` on Android or a - `simctl` file copy on iOS). + polls the developer's ``app/`` directory for ``.py`` changes and + triggers a callback (typically ``adb push`` on Android or a + ``simctl`` file copy on iOS). - **Device-side**: [`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] reloads - changed Python modules using `importlib.reload` and asks the page - host to re-render the current tree. + changed Python modules using ``importlib`` and asks the page host + to re-render its current tree. + +Two strategies share the device-side surface: + +- **Fast Refresh** (default): after reloading the changed modules + the reconciler tree is walked and every component function whose + module was reloaded is swapped in place. Hook state, navigation + state, and even scroll positions survive because the underlying + ``VNode`` objects are reused — the next render simply calls the + new function bodies through the old slots. +- **Full remount**: when the in-place swap fails (e.g. the new + module raised at import time, or a render exception bubbled out + while running the new function), the host falls back to building + a brand-new reconciler tree. State is lost but the app keeps + running. Example: - Integrated into `pn run --hot-reload`: + Integrated into ``pn run --hot-reload``: ```python from pythonnative.hot_reload import FileWatcher @@ -33,7 +47,7 @@ def push(changed): import sys import threading import time -from typing import Any, Callable, Dict, List, Optional, Sequence +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set DEV_ROOT_DIR = "pythonnative_dev" """Name of the writable on-device directory that shadows bundled app code.""" @@ -292,6 +306,158 @@ def reload_page(page_instance: Any, module_names: Optional[Sequence[str]] = None if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None: _request_render(page_instance) + @staticmethod + def find_replacement_function(old_fn: Any) -> Optional[Any]: + """Locate a function's post-reload counterpart by qualname. + + Functions decorated with [`component`][pythonnative.component] + store the user's original function on the wrapper's + ``__wrapped__`` attribute and forward ``__module__`` / + ``__qualname__`` so that the reconciler's stored + ``element.type`` (the unwrapped function) still has the + information needed to re-resolve after a module reload. + + Args: + old_fn: The function captured in an + [`Element`][pythonnative.Element]'s ``type`` slot. + + Returns: + The reloaded module's matching function, ``None`` if no + replacement was found, or the original function itself + when the module has not been reloaded (so callers can + skip the swap). + """ + module_name = getattr(old_fn, "__module__", None) + qualname = getattr(old_fn, "__qualname__", None) or getattr(old_fn, "__name__", None) + if not module_name or not qualname: + return None + if "" in qualname: + return None # nested functions are not addressable from the module surface + + module = sys.modules.get(module_name) + if module is None: + return None + + obj: Any = module + for part in qualname.split("."): + obj = getattr(obj, part, None) + if obj is None: + return None + + if getattr(obj, "_pn_component", False): + obj = getattr(obj, "__wrapped__", obj) + + if obj is old_fn: + return None + return obj + + @staticmethod + def build_replacement_map(reconciler: Any, reloaded_modules: Iterable[str]) -> Dict[Any, Any]: + """Compute ``{old_function: new_function}`` for one tree. + + The reconciler's stored tree references the *pre-reload* + component functions through ``VNode.element.type``. This + method walks the tree, collects every callable type whose + ``__module__`` was just reloaded, and asks + [`find_replacement_function`][pythonnative.hot_reload.ModuleReloader.find_replacement_function] + for its successor. + + Args: + reconciler: The reconciler whose + ``_tree`` should be inspected. + reloaded_modules: Set of module names that were just + reloaded (only callables from these modules are + considered). + + Returns: + A mapping suitable for passing to + [`swap_components_in_tree`][pythonnative.hot_reload.ModuleReloader.swap_components_in_tree]. + """ + modules: Set[str] = {m for m in reloaded_modules if m} + if not modules or reconciler is None or getattr(reconciler, "_tree", None) is None: + return {} + + seen: Set[int] = set() + mapping: Dict[Any, Any] = {} + + def visit(vnode: Any) -> None: + if vnode is None: + return + elem = getattr(vnode, "element", None) + if elem is not None and callable(elem.type): + fn = elem.type + fn_id = id(fn) + if fn_id not in seen: + seen.add(fn_id) + if getattr(fn, "__module__", None) in modules: + replacement = ModuleReloader.find_replacement_function(fn) + if replacement is not None and replacement is not fn: + mapping[fn] = replacement + for child in getattr(vnode, "children", []) or []: + visit(child) + + visit(reconciler._tree) + return mapping + + @staticmethod + def swap_components_in_tree(reconciler: Any, replacement_map: Dict[Any, Any]) -> int: + """Apply a ``{old: new}`` map to every node in the reconciler tree. + + Mutates ``vnode.element.type`` directly so the NEXT diff sees + identical types and reuses VNodes (preserving hook state). + Pending ``Element`` trees stored on ``vnode._rendered`` are + rewritten too because the reconciler reads from them when + comparing keys across renders. + + Returns: + The number of element type references that were rewritten. + """ + if not replacement_map or reconciler is None or getattr(reconciler, "_tree", None) is None: + return 0 + + rewrites = 0 + + def rewrite_element_tree(element: Any) -> None: + nonlocal rewrites + if element is None: + return + new_type = replacement_map.get(element.type) + if new_type is not None: + element.type = new_type + rewrites += 1 + for child in element.children or []: + rewrite_element_tree(child) + + def visit(vnode: Any) -> None: + if vnode is None: + return + if getattr(vnode, "element", None) is not None: + rewrite_element_tree(vnode.element) + rendered = getattr(vnode, "_rendered", None) + if rendered is not None: + rewrite_element_tree(rendered) + for child in getattr(vnode, "children", []) or []: + visit(child) + + visit(reconciler._tree) + return rewrites + + @staticmethod + def refresh_in_place(reconciler: Any, reloaded_modules: Iterable[str]) -> bool: + """Try a state-preserving Fast Refresh for one reconciler. + + Returns: + ``True`` if any component function was replaced (callers + should then trigger a re-render). ``False`` means the + tree already references the latest functions (or has no + nodes from the reloaded modules at all). + """ + replacement_map = ModuleReloader.build_replacement_map(reconciler, reloaded_modules) + if not replacement_map: + return False + rewrites = ModuleReloader.swap_components_in_tree(reconciler, replacement_map) + return rewrites > 0 + @staticmethod def reload_from_manifest( page_instance: Any, diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py index adb0b43..bb12a79 100644 --- a/src/pythonnative/navigation.py +++ b/src/pythonnative/navigation.py @@ -1,39 +1,49 @@ """Declarative navigation for PythonNative. Provides a component-based navigation system inspired by React -Navigation. Navigators manage screen state in Python and render the -active screen's component through the standard reconciler pipeline, so -hooks ([`use_state`][pythonnative.use_state], -[`use_effect`][pythonnative.use_effect], etc.) and providers continue -to work inside navigated screens. +Navigation. Navigators manage screen state in Python and the +[`Stack`][pythonnative.create_stack_navigator] navigator pushes real +native screen containers (``UIViewController`` on iOS, +``Fragment`` on Android) so users get system-grade transitions and +swipe-back gestures for free. Three navigator factories are provided out of the box: - [`create_stack_navigator`][pythonnative.create_stack_navigator]: push - and pop screens. + and pop screens with native transitions when the navigator is mounted + at the root of an app host. - [`create_tab_navigator`][pythonnative.create_tab_navigator]: switch between sibling tabs (with a tab bar). - [`create_drawer_navigator`][pythonnative.create_drawer_navigator]: switch between sibling screens via a side drawer menu. Navigators may be nested arbitrarily; nested handles forward unknown -routes and root-level `go_back` calls to their parent. +routes and root-level ``go_back`` calls to their parent. + +Stack navigators rendered as the root of an app host (i.e. the parent +[`use_navigation`][pythonnative.use_navigation] handle is the host's +own handle) talk to the platform via that host's ``_push`` / ``_pop`` +methods, so the back stack matches what UIKit / AndroidX maintain. +Nested stacks (e.g. a stack inside a tab) fall back to in-Python +state — there is no second native navigation controller to push +onto in that case. Example: ```python import pythonnative as pn - from pythonnative.navigation import NavigationContainer, create_stack_navigator - Stack = create_stack_navigator() + Stack = pn.create_stack_navigator() @pn.component def App(): - return NavigationContainer( + return pn.NavigationContainer( Stack.Navigator( - Stack.Screen("Home", component=HomeScreen), - Stack.Screen("Detail", component=DetailScreen), + Stack.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Stack.Screen("Detail", component=DetailScreen, options={"title": "Detail"}), ) ) + + pn.run(App) ``` """ @@ -109,19 +119,40 @@ def __repr__(self) -> str: # ====================================================================== +_INITIAL_ROUTE_KEY = "__pn_initial_route__" +_INITIAL_PARAMS_KEY = "__pn_initial_params__" + + +def _parent_is_app_host(parent: Any) -> bool: + """Return True when ``parent`` is the host's own NavigationHandle. + + The host's + [`NavigationHandle`][pythonnative.hooks.NavigationHandle] sets a + ``_host`` attribute pointing at the owning app host. Declarative + navigators check for that marker to decide whether they should + push real native screens (root navigator) or fall back to + in-Python state (nested navigator). + """ + return parent is not None and hasattr(parent, "_host") and getattr(parent, "_host", None) is not None + + class _DeclarativeNavHandle: """Navigation handle provided by declarative navigators. Implements the same interface as - [`NavigationHandle`][pythonnative.NavigationHandle] so + [`NavigationHandle`][pythonnative.hooks.NavigationHandle] so [`use_navigation`][pythonnative.use_navigation] returns a compatible object regardless of whether the app uses page-based navigation or declarative navigators. - When `parent` is provided, unknown routes and root-level `go_back` - calls are forwarded to the parent handle. This enables nested - navigators (e.g., a stack inside a tab) to delegate navigation - actions that they cannot handle locally. + When ``parent`` is the host's own ``NavigationHandle`` (root + Stack), ``navigate`` / ``go_back`` / ``reset`` drive the native + navigation controller and the in-Python stack is bypassed — the + OS owns the back-stack source of truth. + + When ``parent`` is another declarative handle (nested navigator), + unknown routes and root-level ``go_back`` calls are forwarded to + the parent so a stack inside a tab still pops correctly. """ def __init__( @@ -135,22 +166,65 @@ def __init__( self._get_stack = get_stack self._set_stack = set_stack self._parent = parent + self._host_component_path: Optional[str] = None + + def _push_via_host(self, route_name: str, params: Optional[Dict[str, Any]]) -> bool: + """Try to push via the underlying app host. Returns True if it ran.""" + if not _parent_is_app_host(self._parent): + return False + host = self._parent._host + component_path = self._host_component_path or getattr(host, "_component_path", None) + if not component_path: + return False + push_args = { + _INITIAL_ROUTE_KEY: route_name, + _INITIAL_PARAMS_KEY: params or {}, + } + try: + host._push(component_path, push_args) + return True + except Exception: + return False + + def _pop_via_host(self) -> bool: + """Try to pop via the underlying app host. Returns True if it ran.""" + if not _parent_is_app_host(self._parent): + return False + try: + self._parent._host._pop() + return True + except Exception: + return False def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: - """Navigate to a named route, pushing it onto the stack. + """Navigate to a named route. + + Behaviour depends on the kind of navigator: + + - **Root stack** (parent is the host's NavigationHandle): the + host's native ``_push`` is invoked so the user sees a real + UIKit / AndroidX transition; the in-Python stack is left + untouched because the native controller is the source of + truth. + - **Nested stack / tab / drawer**: ``set_stack`` is called with + the new route — the parent reconciler re-renders the active + screen subtree in place. + - **Unknown route**: forwarded to ``parent`` if one exists, + otherwise raises ``ValueError``. Args: - route_name: A route registered on this navigator. If unknown - and a parent handle exists, the call is forwarded. + route_name: A route registered on this navigator. params: Optional dict made available to the destination screen via [`use_route`][pythonnative.use_route] or - `nav.get_params()`. + ``nav.get_params()``. Raises: - ValueError: If `route_name` is unknown and no parent handle - exists. + ValueError: If ``route_name`` is unknown and no parent + handle exists. """ if route_name in self._screen_map: + if self._push_via_host(route_name, params): + return entry = _RouteEntry(route_name, params) self._set_stack(lambda s: list(s) + [entry]) elif self._parent is not None: @@ -161,21 +235,29 @@ def navigate(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> def go_back(self) -> None: """Pop the current screen from the stack. - If the stack is at its root and a parent handle exists, the call - is forwarded to the parent navigator (so a nested stack inside - a tab still pops back through the tab's host). + Resolution order: + + 1. If the current stack has more than one entry (a real + in-Python push), pop the top. + 2. If the parent is the host's NavigationHandle, pop the + native navigation controller. + 3. If the parent is another declarative handle, forward. + 4. Otherwise no-op. """ stack = self._get_stack() if len(stack) > 1: self._set_stack(lambda s: list(s[:-1])) - elif self._parent is not None: + return + if self._pop_via_host(): + return + if self._parent is not None: self._parent.go_back() def get_params(self) -> Dict[str, Any]: """Return the params dict for the current route. Returns: - The dict supplied to the most recent matching `navigate(...)` + The dict supplied to the most recent matching ``navigate(...)`` call, or an empty dict if none was supplied. """ stack = self._get_stack() @@ -184,18 +266,30 @@ def get_params(self) -> Dict[str, Any]: def reset(self, route_name: str, params: Optional[Dict[str, Any]] = None) -> None: """Reset the stack to a single route. - Useful for "log out" or "deep link entry" flows where you want - to discard the existing back stack. + For a root stack this pops every screen above the original + root (the screen the user landed on when the app launched) + and then pushes ``route_name`` on top. For nested navigators + the in-Python stack is replaced wholesale. Args: route_name: Route to install as the new root. params: Optional params for that route. Raises: - ValueError: If `route_name` is unknown. + ValueError: If ``route_name`` is unknown. """ if route_name not in self._screen_map: raise ValueError(f"Unknown route: {route_name!r}. Known routes: {list(self._screen_map)}") + if _parent_is_app_host(self._parent): + host = self._parent._host + reset_fn = getattr(host, "_reset_to_root", None) + if callable(reset_fn): + try: + reset_fn() + except Exception: + pass + if self._push_via_host(route_name, params): + return self._set_stack([_RouteEntry(route_name, params)]) @@ -290,6 +384,48 @@ def _build_screen_map(screens: Any) -> Dict[str, "_ScreenDef"]: return result +def _read_host_initial_route(parent_nav: Any) -> "tuple[Optional[str], Dict[str, Any]]": + """Extract a host-provided initial route from a parent NavigationHandle. + + When a pushed VC/Fragment boots, the host's ``_args`` carry the + requested route name and params (set by + [`_DeclarativeNavHandle._push_via_host`][pythonnative.navigation._DeclarativeNavHandle._push_via_host]). + The root Stack navigator reads them so the new screen renders on + the right entry rather than the navigator's first registered + screen. + + Returns: + ``(route_name, params)``. Both are ``None`` / ``{}`` when no + host args are present. + """ + if not _parent_is_app_host(parent_nav): + return None, {} + args = parent_nav.get_params() if parent_nav is not None else {} + if not isinstance(args, dict): + return None, {} + route = args.get(_INITIAL_ROUTE_KEY) + params = args.get(_INITIAL_PARAMS_KEY) or {} + if not isinstance(route, str) or not route: + return None, {} + if not isinstance(params, dict): + params = {} + return route, params + + +def _apply_screen_options(parent_nav: Any, screen_def: "_ScreenDef") -> None: + """Push the active screen's options into the host (title, etc.).""" + if not _parent_is_app_host(parent_nav): + return + host = parent_nav._host + title = screen_def.options.get("title") if screen_def is not None else None + setter = getattr(host, "_set_screen_options", None) + if callable(setter): + try: + setter({"title": title} if title is not None else {}) + except Exception: + pass + + @component def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = None) -> Element: screen_map = _build_screen_map(screens) @@ -298,8 +434,15 @@ def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = No parent_nav = use_context(_NavigationContext) - first_route = initial_route or next(iter(screen_map)) - stack, set_stack = use_state(lambda: [_RouteEntry(first_route)]) + requested_route, requested_params = _read_host_initial_route(parent_nav) + if requested_route is not None and requested_route in screen_map: + first_route = requested_route + first_params: Dict[str, Any] = dict(requested_params) + else: + first_route = initial_route or next(iter(screen_map)) + first_params = {} + + stack, set_stack = use_state(lambda: [_RouteEntry(first_route, first_params)]) stack_ref = use_ref(None) stack_ref["current"] = stack @@ -315,6 +458,8 @@ def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = No if screen_def is None: return Element("Text", {"text": f"Unknown route: {current.name}"}, []) + _apply_screen_options(parent_nav, screen_def) + screen_el = screen_def.component() return Provider(_NavigationContext, handle, Provider(_FocusContext, True, screen_el)) diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index 391829c..6506efe 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -88,10 +88,60 @@ def _resolve_component_path(page_ref: Any) -> str: def _import_component(component_path: str) -> Any: - """Import and return the component function from a dotted path.""" - module_path, component_name = component_path.rsplit(".", 1) - module = importlib.import_module(module_path) - return getattr(module, component_name) + """Import a component, supporting both dotted paths and registered apps. + + Resolution order: + + 1. If ``component_path`` contains a ``.`` and the dotted suffix + names an attribute on the parent module, return that attribute + directly (legacy behaviour — e.g. ``"app.main_page.App"``). + 2. Otherwise treat ``component_path`` as a module path: import + it and return the component registered via + [`pn.run`][pythonnative.run]. If nothing has been registered, + fall back to a top-level ``App`` attribute on the module. + + Args: + component_path: Either ``"app.main_page.App"`` (dotted path to + a specific component) or ``"app.main_page"`` (module path + that calls ``pn.run(App)`` at import time). + + Returns: + The resolved component callable. + + Raises: + ImportError: If neither resolution path succeeds. + """ + from . import app_registry + + if "." in component_path: + module_path, attr = component_path.rsplit(".", 1) + try: + module = importlib.import_module(module_path) + except ModuleNotFoundError: + module = None + if module is not None: + component = getattr(module, attr, None) + if component is not None: + return component + + importlib.import_module(component_path) + registered = app_registry.get_registered_app() + if registered is not None: + return registered + + try: + module = importlib.import_module(component_path) + component = getattr(module, "App", None) + if component is not None: + return component + except ModuleNotFoundError: + pass + + raise ImportError( + f"Could not resolve component {component_path!r}. " + "Pass a dotted path like 'app.main_page.App' or call pn.run(App) " + "inside the module so it can be auto-discovered." + ) # ====================================================================== @@ -304,20 +354,97 @@ def _hot_reload_tick(host: Any) -> bool: def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) -> None: - from .hooks import NavigationHandle, Provider, _NavigationContext + """Reload modules and refresh the host's reconciler tree. + + Tries **Fast Refresh** first: the changed modules are reloaded + and every ``element.type`` reference in the current ``VNode`` + tree is rewritten to point at the new module's functions. The + next render then runs the new bodies through the existing hook + slots, so component state survives. + + If Fast Refresh fails (the new module raised at import time, no + replacements could be located, or the next render itself + threw), the host falls back to a full remount: a brand-new + reconciler tree is mounted into the same native root. State is + lost but the app keeps running so the developer can fix the + error and try again. + """ from .hot_reload import ModuleReloader modules = list(changed_modules or []) - root_module = host._component_path.rsplit(".", 1)[0] + root_module = host._component_path.rsplit(".", 1)[0] if "." in host._component_path else host._component_path if root_module not in modules: modules.append(root_module) - ModuleReloader.reload_modules(modules) - host._component = _import_component(host._component_path) + reloaded = ModuleReloader.reload_modules(modules) + if not reloaded: + _log_pn(f"_reload_host: no modules could be reloaded from {modules!r}; aborting") + return + + try: + new_component = _import_component(host._component_path) + except Exception as e: + _log_pn(f"_reload_host: re-import failed: {e!r}; aborting reload") + return + host._component = new_component if host._reconciler is None: return + if _try_fast_refresh(host, reloaded): + print(f"[hot-reload] Fast Refresh: {', '.join(reloaded)}", file=sys.stderr) + return + + _full_remount(host, reloaded) + + +def _try_fast_refresh(host: Any, reloaded_modules: Sequence[str]) -> bool: + """Attempt an in-place component swap + re-render. + + Returns ``True`` only if the swap happened and the subsequent + render completed without raising. On exception we restore the + pre-render reconciler state so the caller can fall back to a + full remount. + """ + from .hooks import Provider, _NavigationContext + from .hot_reload import ModuleReloader + + reconciler = host._reconciler + if reconciler is None or reconciler._tree is None: + return False + + rewrote = ModuleReloader.refresh_in_place(reconciler, reloaded_modules) + if not rewrote: + return False + + host._is_rendering = True + try: + app_element = _render_app(host) + provider_element = Provider(_NavigationContext, host._nav_handle, app_element) + new_root = reconciler.reconcile(provider_element) + if new_root is not host._root_native_view: + host._detach_root(host._root_native_view) + host._root_native_view = new_root + host._attach_root(new_root) + except Exception as e: + _log_pn(f"_try_fast_refresh: render failed after swap: {e!r}; falling back to remount") + return False + finally: + host._is_rendering = False + + _drain_renders(host) + return True + + +def _full_remount(host: Any, reloaded_modules: Sequence[str]) -> None: + """Destroy the existing tree and mount a fresh one. + + Used by [`_reload_host`][pythonnative.page._reload_host] as the + fallback path when Fast Refresh cannot apply (e.g. the user + deleted a component that was on screen). + """ + from .hooks import NavigationHandle, Provider, _NavigationContext + old_reconciler = host._reconciler old_root = host._root_native_view old_nav = host._nav_handle @@ -345,7 +472,7 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) -> host._root_native_view = new_root host._attach_root(new_root) _drain_renders(host) - print(f"[hot-reload] Reloaded {', '.join(modules)}", file=sys.stderr) + print(f"[hot-reload] Remounted: {', '.join(reloaded_modules)}", file=sys.stderr) # ====================================================================== @@ -601,6 +728,26 @@ def _pop(self) -> None: except Exception: self.native_instance.finish() + def _reset_to_root(self) -> None: + """Pop everything above the root view-controller (best-effort).""" + try: + Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") + reset_fn = getattr(Navigator, "popToRoot", None) + if reset_fn is not None: + reset_fn(self.native_instance) + except Exception: + pass + + def _set_screen_options(self, options: Dict[str, Any]) -> None: + """Bind screen options (title, etc.) to the native action bar.""" + title = options.get("title") if isinstance(options, dict) else None + try: + activity = self.native_instance + if hasattr(activity, "setTitle") and title: + activity.setTitle(title) + except Exception: + pass + def _attach_root(self, native_view: Any) -> None: container = None try: @@ -939,6 +1086,31 @@ def _pop(self) -> None: if nav is not None: nav.popViewControllerAnimated_(True) + def _reset_to_root(self) -> None: + """Pop everything above the root view-controller.""" + nav = getattr(self.native_instance, "navigationController", None) + if nav is not None: + try: + nav.popToRootViewControllerAnimated_(True) + except Exception: + pass + + def _set_screen_options(self, options: Dict[str, Any]) -> None: + """Bind screen options (e.g. title) to the native nav bar. + + Setting ``UIViewController.title`` propagates to the + view controller's ``navigationItem.title`` so the + surrounding ``UINavigationController`` picks up the + new title on its next layout pass. + """ + title = options.get("title") if isinstance(options, dict) else None + if title is None or self.native_instance is None: + return + try: + self.native_instance.setTitle_(str(title)) + except Exception as e: + _log_pn(f"_set_screen_options: setTitle failed: {e!r}") + def _attach_root(self, native_view: Any) -> None: root_view = self.native_instance.view root_view.addSubview_(native_view) @@ -1140,6 +1312,13 @@ def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: def _pop(self) -> None: raise RuntimeError("go_back() requires a native runtime (iOS or Android)") + def _reset_to_root(self) -> None: + pass + + def _set_screen_options(self, options: Dict[str, Any]) -> None: + """No-op on desktop; native hosts override this.""" + return + def _attach_root(self, native_view: Any) -> None: pass diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt index 2d3d394..8e3b347 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt @@ -23,4 +23,21 @@ object Navigator { val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment navHost.navController.popBackStack() } + + /** + * Pop every fragment off the back stack except the start destination. + * + * Used by the declarative + * [`Stack.reset`][pythonnative.navigation._DeclarativeNavHandle.reset] + * call so navigators can return the user to the initial screen + * without manually popping one screen at a time. + */ + @JvmStatic + fun popToRoot(activity: FragmentActivity) { + val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + val navController = navHost.navController + // popBackStack(destination, inclusive=false) pops everything ABOVE the destination. + val startDestination = navController.graph.startDestinationId + navController.popBackStack(startDestination, false) + } } diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt index 9fb4d17..92ad25b 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt @@ -37,7 +37,11 @@ class PageFragment : Fragment() { } try { val py = Python.getInstance() - val pagePath = arguments?.getString("page_path") ?: "app.main_page.MainPage" + // Default to app.main_page.App — the new Stack-rooted entry + // point registered via pn.run(App). The legacy + // "app.main_page.MainPage" still resolves because the demo + // exports a backwards-compatible alias. + val pagePath = arguments?.getString("page_path") ?: "app.main_page.App" val argsJson = arguments?.getString("args_json") val filesRoot = requireContext().filesDir.absolutePath val devRoot = "$filesRoot/pythonnative_dev" diff --git a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml index 182bed8..baa5c28 100644 --- a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +++ b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml @@ -12,7 +12,7 @@ + android:defaultValue="app.main_page.App" /> Any: + """Clear the registered App before and after each test.""" + app_registry.clear() + yield + app_registry.clear() + + +def test_run_registers_app_component() -> None: + @pn.component + def MyApp() -> pn.Element: + return pn.Text("hi") + + pn.run(MyApp) + assert app_registry.get_registered_app() is MyApp + + +def test_run_returns_component_so_it_can_be_a_decorator() -> None: + @pn.component + def DecApp() -> pn.Element: + return pn.Text("hi") + + same = pn.run(DecApp) + assert same is DecApp + + +def test_run_rejects_non_callable() -> None: + with pytest.raises(TypeError, match="callable"): + pn.run("not a function") # type: ignore[arg-type] + + +def test_import_component_resolves_dotted_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_import_component`` accepts the legacy ``module.attr`` shape.""" + pkg = tmp_path / "demo_dotted" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "main.py").write_text( + "from pythonnative.element import Element\n\n" + "def Root():\n" + " return Element('Text', {'text': 'dotted'}, [])\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(os.fspath(tmp_path)) + sys.modules.pop("demo_dotted.main", None) + sys.modules.pop("demo_dotted", None) + + fn = _import_component("demo_dotted.main.Root") + el = fn() + assert el.type == "Text" + assert el.props["text"] == "dotted" + + +def test_import_component_resolves_module_via_pn_run( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_import_component`` finds an App registered via ``pn.run``.""" + pkg = tmp_path / "demo_register" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "entry.py").write_text( + "import pythonnative as pn\n" + "from pythonnative.element import Element\n\n" + "def App():\n" + " return Element('Text', {'text': 'registered'}, [])\n\n" + "pn.run(App)\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(os.fspath(tmp_path)) + sys.modules.pop("demo_register.entry", None) + sys.modules.pop("demo_register", None) + + fn = _import_component("demo_register.entry") + el = fn() + assert el.props["text"] == "registered" + + +def test_import_component_falls_back_to_App_attribute( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If the module defines ``App`` but doesn't call ``pn.run``, find it anyway.""" + pkg = tmp_path / "demo_implicit" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "entry.py").write_text( + "from pythonnative.element import Element\n\n" + "def App():\n" + " return Element('Text', {'text': 'implicit'}, [])\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(os.fspath(tmp_path)) + sys.modules.pop("demo_implicit.entry", None) + sys.modules.pop("demo_implicit", None) + + fn = _import_component("demo_implicit.entry") + el = fn() + assert el.props["text"] == "implicit" + + +def test_import_component_raises_when_nothing_resolves( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Helpful error when neither a dotted attr nor an App is found.""" + pkg = tmp_path / "demo_missing" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "entry.py").write_text("VALUE = 1\n", encoding="utf-8") + monkeypatch.syspath_prepend(os.fspath(tmp_path)) + sys.modules.pop("demo_missing.entry", None) + sys.modules.pop("demo_missing", None) + + with pytest.raises(ImportError, match="Could not resolve component"): + _import_component("demo_missing.entry") diff --git a/tests/test_cli.py b/tests/test_cli.py index ccab61b..28ff06a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,7 +29,9 @@ def test_cli_init_and_clean() -> None: assert os.path.isfile(main_page_path) with open(main_page_path, "r", encoding="utf-8") as f: content = f.read() - assert "def MainPage(" in content + assert "def App(" in content + assert "pn.run(App)" in content + assert "Stack.Navigator" in content assert os.path.isfile(os.path.join(tmpdir, "pythonnative.json")) assert os.path.isfile(os.path.join(tmpdir, "requirements.txt")) assert os.path.isfile(os.path.join(tmpdir, ".gitignore")) diff --git a/tests/test_hot_reload.py b/tests/test_hot_reload.py index 93ba031..af8469a 100644 --- a/tests/test_hot_reload.py +++ b/tests/test_hot_reload.py @@ -5,15 +5,18 @@ import os import sys from pathlib import Path +from typing import Any, Dict, List import pytest +from pythonnative.element import Element from pythonnative.hot_reload import ( DEV_ROOT_DIR, ModuleReloader, configure_dev_environment, manifest_path_for, ) +from pythonnative.reconciler import Reconciler def _write_module(path: Path, value: str) -> None: @@ -95,3 +98,206 @@ def test_reload_module_imports_from_prioritized_sys_path( reloaded = importlib.import_module("reload_pkg.screen") assert reloaded.VALUE == "overlay" + + +# ====================================================================== +# Fast Refresh: find_replacement_function / refresh_in_place +# ====================================================================== + + +class _MockView: + """Minimal native-view stand-in for Reconciler tests.""" + + _next_id = 0 + + def __init__(self, type_name: str, props: Dict[str, Any]) -> None: + _MockView._next_id += 1 + self.id = _MockView._next_id + self.type_name = type_name + self.props = dict(props) + self.children: List["_MockView"] = [] + + +class _MockBackend: + def create_view(self, type_name: str, props: Dict[str, Any]) -> _MockView: + return _MockView(type_name, props) + + def update_view(self, native_view: _MockView, type_name: str, changed: Dict[str, Any]) -> None: + native_view.props.update(changed) + + def add_child(self, parent: _MockView, child: _MockView, parent_type: str) -> None: + parent.children.append(child) + + def remove_child(self, parent: _MockView, child: _MockView, parent_type: str) -> None: + parent.children = [c for c in parent.children if c.id != child.id] + + def insert_child(self, parent: _MockView, child: _MockView, parent_type: str, index: int) -> None: + parent.children.insert(index, child) + + +def test_find_replacement_function_returns_new_function_for_reloaded_module( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pkg = tmp_path / "refresh_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "comp.py").write_text( + "from pythonnative.element import Element\n\n" + "def Screen():\n" + " return Element('Text', {'text': 'v1'}, [])\n", + encoding="utf-8", + ) + monkeypatch.syspath_prepend(os.fspath(tmp_path)) + sys.modules.pop("refresh_pkg.comp", None) + sys.modules.pop("refresh_pkg", None) + + module = importlib.import_module("refresh_pkg.comp") + old_fn = module.Screen + + # Rewrite and reload. + (pkg / "comp.py").write_text( + "from pythonnative.element import Element\n\n" + "def Screen():\n" + " return Element('Text', {'text': 'v2'}, [])\n", + encoding="utf-8", + ) + assert ModuleReloader.reload_module("refresh_pkg.comp") is True + new_fn = sys.modules["refresh_pkg.comp"].Screen + + assert new_fn is not old_fn + resolved = ModuleReloader.find_replacement_function(old_fn) + assert resolved is new_fn + + +def test_find_replacement_function_skips_when_module_not_reloaded() -> None: + """An unchanged function returns ``None`` (caller knows not to swap).""" + + def Untouched() -> None: + return None + + Untouched.__module__ = __name__ + assert ModuleReloader.find_replacement_function(Untouched) is None + + +def test_refresh_in_place_swaps_components_and_preserves_state( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """End-to-end: reload module, walk tree, and the next render uses new bodies.""" + pkg = tmp_path / "rstate_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text("", encoding="utf-8") + (pkg / "comp.py").write_text( + "import pythonnative as pn\n" + "from pythonnative.element import Element\n" + "from pythonnative.hooks import component, use_state\n\n" + "@component\n" + "def Counter():\n" + " count, set_count = use_state(0)\n" + " set_counter._set_count = set_count\n" + " return Element('Text', {'text': f'A:{count}'}, [])\n\n" + "class _Holder:\n" + " _set_count = None\n" + "set_counter = _Holder\n", + encoding="utf-8", + ) + # Disable bytecode caching: without this, two writes inside the same + # second can leave Python serving the stale .pyc (mtime resolution). + monkeypatch.setattr(sys, "dont_write_bytecode", True) + monkeypatch.syspath_prepend(os.fspath(tmp_path)) + sys.modules.pop("rstate_pkg.comp", None) + sys.modules.pop("rstate_pkg", None) + + module = importlib.import_module("rstate_pkg.comp") + + backend = _MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + root = rec.mount(module.Counter()) + + def get_text(view: Any) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = get_text(c) + if r is not None: + return r + return None + + assert get_text(root) == "A:0" + + # Bump the counter so the hook state is non-default. + module.set_counter._set_count(5) + rec.reconcile(module.Counter()) + assert get_text(rec._tree.native_view) == "A:5" + + # Edit the module — change the prefix from "A:" to "B:". + (pkg / "comp.py").write_text( + "import pythonnative as pn\n" + "from pythonnative.element import Element\n" + "from pythonnative.hooks import component, use_state\n\n" + "@component\n" + "def Counter():\n" + " count, set_count = use_state(0)\n" + " set_counter._set_count = set_count\n" + " return Element('Text', {'text': f'B:{count}'}, [])\n\n" + "class _Holder:\n" + " _set_count = None\n" + "set_counter = _Holder\n", + encoding="utf-8", + ) + # Force the mtime to advance so the import system rereads from disk + # even on filesystems with second-resolution mtimes. + import time as _time + + _time.sleep(0.01) + os.utime(pkg / "comp.py") + assert ModuleReloader.reload_module("rstate_pkg.comp") is True + + refreshed = ModuleReloader.refresh_in_place(rec, ["rstate_pkg.comp"]) + assert refreshed is True + + # Render with the reloaded module's Counter — the new function is + # called against the same VNode (and HookState), so state survives. + new_module = sys.modules["rstate_pkg.comp"] + rec.reconcile(new_module.Counter()) + assert get_text(rec._tree.native_view) == "B:5" + + +def test_refresh_in_place_returns_false_for_unreloaded_modules() -> None: + """No-op when none of the tree's components belong to a reloaded module.""" + + backend = _MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + rec.mount(Element("Text", {"text": "static"}, [])) + + refreshed = ModuleReloader.refresh_in_place(rec, ["some.other.module"]) + assert refreshed is False + + +def test_build_replacement_map_skips_nested_functions() -> None: + """Functions defined inside other functions cannot be re-resolved. + + ``inner``'s ``__qualname__`` contains ````, which is not a + module-level attribute path. The replacement-map builder should + notice and skip rather than crash trying to ``getattr`` through + ````. + """ + + def make_nested() -> Any: + def inner() -> Element: + return Element("Text", {"text": "nested"}, []) + + return inner + + inner = make_nested() + backend = _MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + rec.mount(Element(inner, {}, [])) + + mapping = ModuleReloader.build_replacement_map(rec, [inner.__module__]) + assert mapping == {} diff --git a/tests/test_navigation.py b/tests/test_navigation.py index f45ca55..3f3e068 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -209,6 +209,121 @@ def test_declarative_handle_navigate_unknown_raises() -> None: handle.navigate("Missing") +# ====================================================================== +# Native-backed delegation via host._push / host._pop +# ====================================================================== + + +class _FakeHost: + """Stub `_AppHost` used to assert native-push delegation.""" + + def __init__(self, component_path: str = "app.demo.App") -> None: + self._component_path = component_path + self.pushed: list = [] + self.popped = 0 + self.reset_count = 0 + + def _push(self, page: Any, args: Any) -> None: + self.pushed.append((page, args)) + + def _pop(self) -> None: + self.popped += 1 + + def _reset_to_root(self) -> None: + self.reset_count += 1 + + +class _FakeHostHandle: + """Mimics `NavigationHandle`: declarative navigators look for `_host`.""" + + def __init__(self, host: Any) -> None: + self._host = host + + def navigate(self, route_name: str, params: Any = None) -> None: + # Forwarding fallback (should not be called for host-routed pushes). + raise AssertionError("host handle navigate() invoked unexpectedly") + + def go_back(self) -> None: + raise AssertionError("host handle go_back() invoked unexpectedly") + + +def test_declarative_handle_native_push_when_parent_is_host() -> None: + """A root Stack whose parent has `_host` should push via the host.""" + host = _FakeHost("app.demo.App") + parent = _FakeHostHandle(host) + screens = {"Detail": _ScreenDef("Detail", lambda: None)} + + set_stack_calls: list = [] + + def set_stack(val: Any) -> None: + set_stack_calls.append(val) + + handle = _DeclarativeNavHandle(screens, lambda: [_RouteEntry("Home")], set_stack, parent=parent) + handle.navigate("Detail", {"id": 7}) + + assert len(host.pushed) == 1 + page_path, push_args = host.pushed[0] + assert page_path == "app.demo.App" + assert push_args["__pn_initial_route__"] == "Detail" + assert push_args["__pn_initial_params__"] == {"id": 7} + assert set_stack_calls == [] # in-Python stack is NOT touched + + +def test_declarative_handle_native_pop_at_root_when_parent_is_host() -> None: + """`go_back` on a single-entry root stack pops the host nav controller.""" + host = _FakeHost() + parent = _FakeHostHandle(host) + handle = _DeclarativeNavHandle( + {"A": _ScreenDef("A", lambda: None)}, + lambda: [_RouteEntry("A")], + lambda _: None, + parent=parent, + ) + + handle.go_back() + assert host.popped == 1 + + +def test_declarative_handle_native_pop_falls_through_to_in_python_first() -> None: + """In-Python pop wins when the stack has multiple entries, even on native.""" + host = _FakeHost() + parent = _FakeHostHandle(host) + stack: list = [_RouteEntry("A"), _RouteEntry("B")] + set_stack_calls: list = [] + + def set_stack(val: Any) -> None: + if callable(val): + new = val(stack) + stack.clear() + stack.extend(new) + else: + stack.clear() + stack.extend(val) + set_stack_calls.append(list(stack)) + + handle = _DeclarativeNavHandle({}, lambda: stack, set_stack, parent=parent) + handle.go_back() + assert host.popped == 0 # didn't escalate to native pop + assert set_stack_calls[-1] == [_RouteEntry("A")] or set_stack_calls[-1][0].name == "A" + + +def test_declarative_handle_native_reset_calls_host_helpers() -> None: + """`reset` on a root stack invokes both pop-to-root and push.""" + host = _FakeHost("app.demo.App") + parent = _FakeHostHandle(host) + handle = _DeclarativeNavHandle( + {"Home": _ScreenDef("Home", lambda: None)}, + lambda: [_RouteEntry("Home")], + lambda _: None, + parent=parent, + ) + + handle.reset("Home", {"fresh": True}) + assert host.reset_count == 1 + assert host.pushed[0][0] == "app.demo.App" + assert host.pushed[0][1]["__pn_initial_route__"] == "Home" + + def test_declarative_handle_reset_unknown_raises() -> None: handle = _DeclarativeNavHandle({"Home": _ScreenDef("Home", lambda: None)}, lambda: [], lambda _: None) with pytest.raises(ValueError, match="Unknown route"): @@ -844,3 +959,143 @@ def test_navigation_exports_from_package() -> None: assert hasattr(pn, "create_drawer_navigator") assert hasattr(pn, "use_route") assert hasattr(pn, "use_focus_effect") + assert hasattr(pn, "run") + + +# ====================================================================== +# Initial route propagated through host args (native-push pickup) +# ====================================================================== + + +class _ArgsHostHandle: + """Host nav handle that exposes args via `get_params`.""" + + def __init__(self, host_args: Dict[str, Any]) -> None: + self._host = type("HostStub", (), {"_component_path": "app.demo.App"})() + self._host_args = host_args + + def get_params(self) -> Dict[str, Any]: + return self._host_args + + def navigate(self, route_name: str, params: Any = None) -> None: + return None + + def go_back(self) -> None: + return None + + +def test_stack_navigator_initial_route_from_host_args() -> None: + """When the host's args carry __pn_initial_route__, the Stack starts there.""" + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + @component + def DetailScreen() -> Element: + params = use_route() + return Element("Text", {"text": f"detail:{params.get('id')}"}, []) + + parent = _ArgsHostHandle( + { + "__pn_initial_route__": "Detail", + "__pn_initial_params__": {"id": 99}, + } + ) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator( + Stack.Screen("Home", component=HomeScreen), + Stack.Screen("Detail", component=DetailScreen), + ) + from pythonnative.hooks import Provider as _Provider + + root = rec.mount(_Provider(_NavigationContext, parent, el)) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "detail:99" + + +def test_stack_navigator_falls_back_when_host_route_unknown() -> None: + """An unknown route in host args is ignored and the navigator picks its default.""" + Stack = create_stack_navigator() + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + parent = _ArgsHostHandle({"__pn_initial_route__": "MysteryScreen"}) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) + from pythonnative.hooks import Provider as _Provider + + root = rec.mount(_Provider(_NavigationContext, parent, el)) + + def find_text(view: MockView) -> Any: + if view.type_name == "Text": + return view.props.get("text") + for c in view.children: + r = find_text(c) + if r: + return r + return None + + assert find_text(root) == "home" + + +def test_stack_navigator_calls_set_screen_options() -> None: + """The active screen's options['title'] is forwarded to the host.""" + Stack = create_stack_navigator() + seen_options: list = [] + + class _OptionRecorder: + _component_path = "app.demo.App" + + def _set_screen_options(self, opts: Dict[str, Any]) -> None: + seen_options.append(opts) + + class _Parent: + def __init__(self, host: Any) -> None: + self._host = host + + def get_params(self) -> Dict[str, Any]: + return {} + + def navigate(self, route_name: str, params: Any = None) -> None: + return None + + def go_back(self) -> None: + return None + + @component + def HomeScreen() -> Element: + return Element("Text", {"text": "home"}, []) + + parent = _Parent(_OptionRecorder()) + + backend = MockBackend() + rec = Reconciler(backend) + rec._page_re_render = lambda: None + + el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen, options={"title": "Hello"})) + from pythonnative.hooks import Provider as _Provider + + rec.mount(_Provider(_NavigationContext, parent, el)) + + assert seen_options == [{"title": "Hello"}] From d8e0d1179e0fe52a72ab584cfa6847ad12e54b53 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 11 May 2026 18:30:06 -0700 Subject: [PATCH 2/6] docs(repo): refresh README --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3c5c845..3fd827a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app - **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation. - **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge. - **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app. -- **Navigation:** Push and pop screens with argument passing via the `use_navigation()` hook. +- **Native-backed navigation:** Declarative `Stack`, `Tab`, and `Drawer` navigators inspired by React Navigation. The root stack drives the platform's native navigation controller (`UINavigationController` on iOS, AndroidX Navigation Component on Android), so transitions, back gestures, and the hardware back button match what users expect. +- **Fast Refresh hot reload:** `pn run --hot-reload` watches `app/` and patches edits into the running app on save, preserving component state across most changes. - **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access. ## Quick Start @@ -55,7 +56,7 @@ import pythonnative as pn @pn.component -def MainPage(): +def App(): count, set_count = pn.use_state(0) return pn.Column( pn.Text(f"Count: {count}", style={"font_size": 24}), @@ -65,8 +66,13 @@ def MainPage(): ), style={"spacing": 12, "padding": 16}, ) + + +pn.run(App) ``` +`pn.run(App)` registers the root component for this Python process (analogous to `AppRegistry.registerComponent` in React Native). The bundled iOS and Android templates load your app by module path and pick up whatever component you register. See [Getting Started](https://docs.pythonnative.com/getting-started/) for the full `Stack.Navigator` scaffold that `pn init` produces. + ## Documentation Visit [docs.pythonnative.com](https://docs.pythonnative.com/) for the full documentation, including getting started guides, platform-specific instructions for Android and iOS, API reference, and working examples. From 09065838f6e5eea719f8257247e42c389afaa7e2 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 11 May 2026 18:47:12 -0700 Subject: [PATCH 3/6] refactor: replace pn.run(App) with App attr in app/main.py --- README.md | 5 +- docs/api/pythonnative.md | 17 +- docs/concepts/architecture.md | 19 +-- docs/concepts/components.md | 9 +- docs/examples.md | 4 +- docs/examples/hello-world.md | 6 +- docs/getting-started.md | 14 +- docs/guides/navigation.md | 25 +-- .../hello-world/app/{main_page.py => main.py} | 39 ++--- examples/hello-world/pythonnative.json | 2 +- src/pythonnative/__init__.py | 46 ------ src/pythonnative/app_registry.py | 63 -------- src/pythonnative/cli/pn.py | 17 +- src/pythonnative/hot_reload.py | 4 +- src/pythonnative/navigation.py | 2 - src/pythonnative/page.py | 85 +++++----- .../android_template/MainActivity.kt | 2 +- .../android_template/PageFragment.kt | 11 +- .../app/src/main/res/navigation/nav_graph.xml | 2 +- .../ios_template/ViewController.swift | 15 +- tests/e2e/android.yaml | 2 +- tests/e2e/flows/{main_page.yaml => main.yaml} | 2 +- tests/e2e/flows/navigation.yaml | 6 +- tests/e2e/ios.yaml | 2 +- tests/test_app_registry.py | 145 ------------------ tests/test_cli.py | 18 +-- tests/test_hot_reload.py | 10 +- tests/test_navigation.py | 1 - 28 files changed, 140 insertions(+), 433 deletions(-) rename examples/hello-world/app/{main_page.py => main.py} (90%) delete mode 100644 src/pythonnative/app_registry.py rename tests/e2e/flows/{main_page.yaml => main.yaml} (82%) delete mode 100644 tests/test_app_registry.py diff --git a/README.md b/README.md index 3fd827a..73938aa 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,9 @@ def App(): ), style={"spacing": 12, "padding": 16}, ) - - -pn.run(App) ``` -`pn.run(App)` registers the root component for this Python process (analogous to `AppRegistry.registerComponent` in React Native). The bundled iOS and Android templates load your app by module path and pick up whatever component you register. See [Getting Started](https://docs.pythonnative.com/getting-started/) for the full `Stack.Navigator` scaffold that `pn init` produces. +Save this as `app/main.py`. The bundled iOS and Android templates import `app.main` and look up its top-level `App` function (the convention is "name your root component `App`"). See [Getting Started](https://docs.pythonnative.com/getting-started/) for the full `Stack.Navigator` scaffold that `pn init` produces. ## Documentation diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index cc21190..5bcfeb4 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -7,8 +7,7 @@ in this overview; deeper internals (`reconciler`, `native_views`, ## Entry point -Your app module exports a root component and registers it with -`pn.run`: +Your app module defines a top-level component named `App`: ```python import pythonnative as pn @@ -16,16 +15,14 @@ import pythonnative as pn @pn.component def App(): return pn.NavigationContainer(...) - -pn.run(App) ``` -`pn.run` mirrors React Native's `AppRegistry.registerComponent`: -it stores the component for the native host to look up. The bundled -Android `PageFragment` and iOS `ViewController` load your app by -**module path** (`"app.main_page"`) and pick up the registered -component, so renaming your root component never requires touching -the native templates. +The bundled Android `PageFragment` and iOS `ViewController` load +your app by **module path** (`"app.main"`) and look up the +module's top-level `App` attribute. There is no registration step +or imperative bootstrap call. If you need to expose a +differently-named root component, configure the templates to load +an explicit dotted path like `"app.main.RootScreen"` instead. ::: pythonnative options: diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 56b38f6..6fc8db8 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -50,13 +50,13 @@ platform APIs synchronously from Python. [`create_page`][pythonnative.create_page] internally to bootstrap your Python component, and the reconciler drives the UI from there. -10. **`pn.run(App)` entry point.** The user's app module exports a - root component and registers it with `pn.run(App)` — mirroring - React Native's `AppRegistry.registerComponent`. The native - templates load the app by **module path** (e.g. - `"app.main_page"`) and pick up whichever component was - registered, so a refactor that renames the root component never - requires touching the Kotlin/Swift templates. +10. **`App` entry point.** The user's app module (`app/main.py`) + defines a top-level component named `App`. Native templates + import that module by path (`"app.main"`) and look up its `App` + attribute, so users never write a separate registration step. + Components with other names can still be loaded by passing an + explicit dotted path like `"app.main.RootScreen"` to the + template. ## How it works @@ -321,8 +321,9 @@ PythonNative navigation is **declarative** and **native-backed**: [`create_tab_navigator`][pythonnative.create_tab_navigator], [`create_drawer_navigator`][pythonnative.create_drawer_navigator]) wrapped in - [`NavigationContainer`][pythonnative.NavigationContainer], then - registers the root with `pn.run(App)`. + [`NavigationContainer`][pythonnative.NavigationContainer], and + names the root component `App` so the native templates can find + it. - The outermost `Stack.Navigator` delegates `navigate(...)`, `go_back()`, and `reset(...)` to the platform's native navigation controller — `UINavigationController` on iOS and the AndroidX diff --git a/docs/concepts/components.md b/docs/concepts/components.md index edadce5..8603450 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -170,15 +170,16 @@ element tree: ```python @pn.component -def MainPage(): +def App(): name, set_name = pn.use_state("World") return pn.Text(f"Hello, {name}!", style={"font_size": 24}) ``` The entry point [`create_page`][pythonnative.create_page] is called internally by native templates to bootstrap your root component. You -don't call it directly; just export your component and configure the -entry point in `pythonnative.json`. +don't call it directly: name your top-level component `App` (so the +templates can find it by convention) and `pythonnative.json` points +at the module that defines it. ## State and re-rendering @@ -221,7 +222,7 @@ def Counter(label: str = "Count", initial: int = 0): @pn.component -def MainPage(): +def App(): return pn.Column( Counter(label="Apples", initial=0), Counter(label="Oranges", initial=5), diff --git a/docs/examples.md b/docs/examples.md index fe475a1..10fcf5f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -19,11 +19,11 @@ project scaffolded with `pn init`. ```bash pn init my-app cd my-app -# Edit app/main_page.py and paste any of the snippets below. +# Edit app/main.py and paste any of the snippets below. pn run android # or: pn run ios ``` -The `app/main_page.py` that `pn init` writes already returns a small +The `app/main.py` that `pn init` writes already returns a small counter; replace it with one of the snippets to try a different example. diff --git a/docs/examples/hello-world.md b/docs/examples/hello-world.md index 23d38ca..faf13f4 100644 --- a/docs/examples/hello-world.md +++ b/docs/examples/hello-world.md @@ -9,14 +9,14 @@ The smallest possible PythonNative app. You'll learn how to: ## The code -Save this as `app/main_page.py`: +Save this as `app/main.py`: ```python import pythonnative as pn @pn.component -def MainPage(): +def App(): count, set_count = pn.use_state(0) return pn.Column( pn.Text(f"Count: {count}", style={"font_size": 24, "bold": True}), @@ -27,7 +27,7 @@ def MainPage(): ## What's happening -- `@pn.component` registers `MainPage` as a function component. Hooks +- `@pn.component` registers `App` as a function component. Hooks (like `use_state`) work because the decorator establishes a hook context for each call. - `pn.use_state(0)` returns `(value, setter)`. The setter triggers a diff --git a/docs/getting-started.md b/docs/getting-started.md index b0f0a63..7873673 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,12 +13,12 @@ pn init MyApp This scaffolds: -- `app/` with a minimal `main_page.py` +- `app/` with a minimal `main.py` - `pythonnative.json` project config - `requirements.txt` - `.gitignore` -A minimal `app/main_page.py` looks like: +A minimal `app/main.py` looks like: ```python import pythonnative as pn @@ -53,17 +53,14 @@ def App(): initial_route="Home", ) ) - - -pn.run(App) ``` Key ideas: - **`@pn.component`** marks a function as a PythonNative component. The function returns an element tree describing the UI. PythonNative creates and updates native views automatically. -- **`pn.use_state(initial)`** creates local component state. Call the setter to update it — the UI re-renders automatically. +- **`pn.use_state(initial)`** creates local component state. Call the setter to update it and the UI re-renders automatically. - **`pn.create_stack_navigator()`** returns a `Stack` with `.Navigator` and `.Screen` factories. Wrap them in `pn.NavigationContainer` to enable [`pn.use_navigation()`][pythonnative.use_navigation] and [`pn.use_route()`][pythonnative.use_route] anywhere below. -- **`pn.run(App)`** registers `App` as the entry point for this Python process — analogous to `AppRegistry.registerComponent` in React Native. The Android and iOS templates load it automatically. +- **The `App` function** is the entry point. The Android and iOS templates import `app.main`, look up its top-level `App` attribute, and start rendering. If you'd rather expose a differently-named component, configure your templates to load an explicit dotted path like `"app.main.RootScreen"`. - **`style={...}`** passes visual and layout properties as a dict (or list of dicts) to any component. - Element functions like `pn.Text(...)`, `pn.Button(...)`, `pn.Column(...)` create lightweight descriptions, not native objects. @@ -133,9 +130,6 @@ def App(): pn.Text(f"Count: {count}"), pn.Button("Tap me", on_click=lambda: set_count(count + 1)), ) - - -pn.run(App) ``` - On Android, logs are streamed via `adb logcat` filtered to the diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index 1cc627f..d30ba67 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -18,9 +18,9 @@ work everywhere. ## Declarative navigation -Define a root component that wraps a navigator in -[`NavigationContainer`][pythonnative.NavigationContainer], then -register it with `pn.run`: +Save a module at `app/main.py` that defines an `App` component +wrapping a navigator in +[`NavigationContainer`][pythonnative.NavigationContainer]: ```python import pythonnative as pn @@ -37,15 +37,12 @@ def App(): initial_route="Home", ) ) - - -pn.run(App) ``` The native templates (Android `PageFragment`, iOS `ViewController`) -look up the component registered via `pn.run`, so no other wiring is -required. `options={"title": ...}` propagates to the native -navigation bar. +import `app.main` and look up its top-level `App` attribute, so no +other wiring is required. `options={"title": ...}` propagates to the +native navigation bar. ### Stack navigator @@ -70,8 +67,6 @@ def App(): ) -pn.run(App) - @pn.component def HomeScreen(): nav = pn.use_navigation() @@ -113,9 +108,6 @@ def App(): Tab.Screen("Settings", SettingsScreen, options={"title": "Settings"}), ) ) - - -pn.run(App) ``` The tab bar emits a `TabBar` element that maps to platform-native views: @@ -143,8 +135,6 @@ def App(): ) -pn.run(App) - @pn.component def HomeScreen(): nav = pn.use_navigation() @@ -189,9 +179,6 @@ def App(): Stack.Screen("Detail", DetailScreen, options={"title": "Detail"}), ) ) - - -pn.run(App) ``` Inside `HomeScreen`, calling `nav.navigate("Detail")` walks up to the diff --git a/examples/hello-world/app/main_page.py b/examples/hello-world/app/main.py similarity index 90% rename from examples/hello-world/app/main_page.py rename to examples/hello-world/app/main.py index 0cee7c3..2571ad6 100644 --- a/examples/hello-world/app/main_page.py +++ b/examples/hello-world/app/main.py @@ -1,15 +1,15 @@ """Hello-world demo with native-backed stack navigation. -The app's root is an [`App`][app.main_page.App] component that returns -a [`Stack`][pythonnative.create_stack_navigator] navigator wrapping a -[`Tab`][pythonnative.create_tab_navigator] navigator. ``pn.run(App)`` at -module level registers the component so the templates can boot the app -just by importing this module. +The app's root is an [`App`][app.main.App] component that returns a +[`Stack`][pythonnative.create_stack_navigator] navigator wrapping a +[`Tab`][pythonnative.create_tab_navigator] navigator. Native templates +load this module, look up the top-level ``App`` attribute, and start +rendering. When the user taps "Go to Second Page" from inside a tab, the stack navigator pushes a real ``UIViewController`` / ``Fragment`` so they get system-grade slide transitions and swipe-back. Each push reuses this -Python interpreter — only the reconciler tree for the new screen is +Python interpreter; only the reconciler tree for the new screen is created. """ @@ -21,7 +21,7 @@ from app.second_page import SecondPage from app.third_page import ThirdPage -print("[hello-world] main_page module imported") +print("[hello-world] main module imported") MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] @@ -100,7 +100,7 @@ def handle_reset() -> None: @pn.component def HomeTab() -> pn.Element: - """Home tab — counter demo and push-navigation to other pages. + """Home tab: counter demo and push-navigation to other pages. ``nav.navigate("Second", ...)`` goes through the inner Tab handle, forwards to the outer Stack handle (root navigator), and pushes a @@ -116,7 +116,7 @@ def _on_mount() -> Callable[[], None]: def go_to_second() -> None: print("[HomeTab] navigating to Second") - nav.navigate("Second", {"message": "Greetings from MainPage"}) + nav.navigate("Second", {"message": "Greetings from Home"}) return pn.ScrollView( pn.Column( @@ -225,7 +225,7 @@ def LayoutTab() -> pn.Element: @pn.component def SettingsTab() -> pn.Element: - """Settings tab — Platform info, alerts, and a quick push to the showcase.""" + """Settings tab: Platform info, alerts, and a quick push to the showcase.""" nav = pn.use_navigation() dims = pn.use_window_dimensions() @@ -292,7 +292,7 @@ def render_row(item: dict, index: int) -> pn.Element: return pn.Column( pn.View( pn.Text( - "Virtualized FlatList — 500 rows backed by UITableView / RecyclerView", + "Virtualized FlatList: 500 rows backed by UITableView / RecyclerView", style={"font_size": 13, "color": "#6B7280"}, ), style={"padding": 16, "background_color": "#F9FAFB"}, @@ -323,7 +323,7 @@ def MainTabs() -> pn.Element: @pn.component def App() -> pn.Element: - """Root component registered with ``pn.run``. + """Root component for the hello-world demo. A [`Stack`][pythonnative.create_stack_navigator] wraps the tabbed home screen so the demo can push the showcase / forms pages onto @@ -337,18 +337,3 @@ def App() -> pn.Element: Stack.Screen("Third", component=ThirdPage, options={"title": "Third Page"}), ) ) - - -pn.run(App) - - -@pn.component -def MainPage() -> pn.Element: - """Backwards-compatible alias for templates that import ``MainPage``. - - The bundled iOS/Android templates default to ``app.main_page.App`` - after the navigation overhaul, but a few earlier templates - referenced ``MainPage`` directly. This shim keeps them working - until they are regenerated with the latest ``pn init`` output. - """ - return App() diff --git a/examples/hello-world/pythonnative.json b/examples/hello-world/pythonnative.json index db72356..4eeeb51 100644 --- a/examples/hello-world/pythonnative.json +++ b/examples/hello-world/pythonnative.json @@ -1,7 +1,7 @@ { "name": "PythonNative Demo", "appId": "com.pythonnative.demo", - "entryPoint": "app/main_page.py", + "entryPoint": "app/main.py", "pythonVersion": "3.11", "ios": {}, "android": {} diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index a9a33f8..0334f39 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -43,9 +43,6 @@ def App(): __version__ = "0.12.0" -from typing import Any, Callable - -from . import app_registry as _app_registry from .alerts import Alert from .animated import Animated, AnimatedValue from .components import ( @@ -105,48 +102,6 @@ def App(): from .platform import Platform from .style import StyleSheet, ThemeContext - -def run(component: Callable[..., Any]) -> Callable[..., Any]: - """Register the App component as the root of the application. - - Mirrors React Native's - [`AppRegistry.registerComponent`](https://reactnative.dev/docs/appregistry): - the user's module declares an ``App`` function once and registers - it at import time. Native templates then load the app by importing - its module — they do not need to know the App component's name. - - Args: - component: A zero-argument ``@component`` function. Typically - returns a [`Stack.Navigator`][pythonnative.create_stack_navigator] - wrapped in a - [`NavigationContainer`][pythonnative.NavigationContainer]. - - Returns: - The same ``component`` (so ``run`` can be used as a decorator - in a pinch, though calling it directly is the conventional - form). - - Example: - ```python - import pythonnative as pn - - Stack = pn.create_stack_navigator() - - @pn.component - def App(): - return pn.NavigationContainer( - Stack.Navigator( - Stack.Screen("Home", component=HomeScreen), - ) - ) - - pn.run(App) - ``` - """ - _app_registry.register(component) - return component - - __all__ = [ # Components "ActivityIndicator", @@ -176,7 +131,6 @@ def App(): # Core "Element", "create_page", - "run", # Hooks "batch_updates", "component", diff --git a/src/pythonnative/app_registry.py b/src/pythonnative/app_registry.py deleted file mode 100644 index 62d35d6..0000000 --- a/src/pythonnative/app_registry.py +++ /dev/null @@ -1,63 +0,0 @@ -"""App component registration for the `pn.run(App)` entry point. - -The ``pn.run`` convention mirrors ``AppRegistry.registerComponent`` in -React Native: the user's app module declares a top-level component -function and registers it once at import time. Native templates then -load the app via a single dotted module path (e.g. ``"app.main_page"``) -without needing to know the App component's exact name. - -Example: - ```python - import pythonnative as pn - - @pn.component - def App(): - return pn.NavigationContainer(...) - - pn.run(App) - ``` - -The Android (``PageFragment.kt``) and iOS (``ViewController.swift``) -templates pass the module path to -[`create_page`][pythonnative.create_page], which imports the module -(triggering this registration) and looks up the registered component. -""" - -from typing import Any, Callable, Optional - -_registered_app: Optional[Callable[..., Any]] = None -"""Module-level holder for the most recently registered App component. - -A single registration slot is intentional: real apps have exactly one -root component. Re-calling :func:`register` simply overwrites the -previous value — useful for tests and hot reloading. -""" - - -def register(component: Callable[..., Any]) -> None: - """Register the App component for this Python process. - - Args: - component: A zero-argument ``@component`` function that returns - an [`Element`][pythonnative.Element]. Typically wraps a - [`NavigationContainer`][pythonnative.NavigationContainer] - at the root. - - Raises: - TypeError: If ``component`` is not callable. - """ - global _registered_app - if not callable(component): - raise TypeError(f"pn.run expects a callable component, got {type(component).__name__}") - _registered_app = component - - -def get_registered_app() -> Optional[Callable[..., Any]]: - """Return the registered App component, or ``None`` if not set.""" - return _registered_app - - -def clear() -> None: - """Reset the registered App. Used by tests and full-host resets.""" - global _registered_app - _registered_app = None diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index c045a3a..8d4e319 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -32,9 +32,9 @@ def init_project(args: argparse.Namespace) -> None: """Scaffold a new PythonNative project in the current directory. - Creates `app/main_page.py`, `pythonnative.json`, - `requirements.txt`, and `.gitignore`. Refuses to overwrite - existing files unless `--force` is passed. + Creates `app/main.py`, `pythonnative.json`, `requirements.txt`, + and `.gitignore`. Refuses to overwrite existing files unless + `--force` is passed. Args: args: The parsed argparse namespace. Recognized attributes: @@ -68,9 +68,9 @@ def init_project(args: argparse.Namespace) -> None: os.makedirs(app_dir, exist_ok=True) - main_page_py = os.path.join(app_dir, "main_page.py") - if not os.path.exists(main_page_py) or args.force: - with open(main_page_py, "w", encoding="utf-8") as f: + main_py = os.path.join(app_dir, "main.py") + if not os.path.exists(main_py) or args.force: + with open(main_py, "w", encoding="utf-8") as f: f.write("""import pythonnative as pn Stack = pn.create_stack_navigator() @@ -110,16 +110,13 @@ def App(): Stack.Screen("Detail", component=DetailScreen, options={"title": "Detail"}), ) ) - - -pn.run(App) """) # Create config config = { "name": project_name, "appId": "com.example." + project_name.replace(" ", "").lower(), - "entryPoint": "app/main_page.py", + "entryPoint": "app/main.py", "pythonVersion": "3.11", "ios": {}, "android": {}, diff --git a/src/pythonnative/hot_reload.py b/src/pythonnative/hot_reload.py index a5c81d6..cc06e41 100644 --- a/src/pythonnative/hot_reload.py +++ b/src/pythonnative/hot_reload.py @@ -60,7 +60,7 @@ def configure_dev_environment(writable_root: str) -> str: """Create and prioritize the writable hot-reload source overlay. The returned directory is inserted at the front of `sys.path`, so a - pushed `app/main_page.py` shadows the copy bundled into the native + pushed `app/main.py` shadows the copy bundled into the native application. Templates call this before importing user code. Args: @@ -205,7 +205,7 @@ def reload_module(module_name: str) -> bool: """Reload a single module by its dotted name. Args: - module_name: Dotted module name (e.g., `"app.main_page"`). + module_name: Dotted module name (e.g., `"app.main"`). Returns: `True` if the module imported successfully from the current diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py index bb12a79..3e0ee34 100644 --- a/src/pythonnative/navigation.py +++ b/src/pythonnative/navigation.py @@ -42,8 +42,6 @@ def App(): Stack.Screen("Detail", component=DetailScreen, options={"title": "Detail"}), ) ) - - pn.run(App) ``` """ diff --git a/src/pythonnative/page.py b/src/pythonnative/page.py index 6506efe..9ff18f1 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/page.py @@ -17,13 +17,13 @@ per user gesture. Example: - User code defines a top-level component: + User code defines a top-level component named ``App``: ```python import pythonnative as pn @pn.component - def MainPage(): + def App(): count, set_count = pn.use_state(0) return pn.Column( pn.Text(f"Count: {count}", style={"font_size": 24}), @@ -36,7 +36,7 @@ def MainPage(): ```python host = pythonnative.page.create_page( - "app.main_page.MainPage", + "app.main", native_instance, ) host.on_create() @@ -88,22 +88,30 @@ def _resolve_component_path(page_ref: Any) -> str: def _import_component(component_path: str) -> Any: - """Import a component, supporting both dotted paths and registered apps. + """Import a component by module or dotted-attribute path. + + PythonNative's entry-point convention is "define a function named + ``App`` at the top of your module, and the native templates will + find it". So the templates pass a *module path* like + ``"app.main"`` and this helper imports the module and returns its + ``App`` attribute. + + A dotted ``module.Attribute`` path is also accepted as an escape + hatch (e.g. ``"app.main.RootScreen"``) for users who want to + expose a differently-named component without renaming it to + ``App``. Resolution order: - 1. If ``component_path`` contains a ``.`` and the dotted suffix - names an attribute on the parent module, return that attribute - directly (legacy behaviour — e.g. ``"app.main_page.App"``). - 2. Otherwise treat ``component_path`` as a module path: import - it and return the component registered via - [`pn.run`][pythonnative.run]. If nothing has been registered, - fall back to a top-level ``App`` attribute on the module. + 1. If ``component_path`` resolves cleanly as a module, return its + ``App`` attribute. + 2. Otherwise split on the final ``.``: import the parent module + and return the named attribute. Args: - component_path: Either ``"app.main_page.App"`` (dotted path to - a specific component) or ``"app.main_page"`` (module path - that calls ``pn.run(App)`` at import time). + component_path: Either ``"app.main"`` (module path with an + ``App`` attribute) or ``"app.main.SomeComponent"`` (dotted + path to a specific component). Returns: The resolved component callable. @@ -111,36 +119,31 @@ def _import_component(component_path: str) -> Any: Raises: ImportError: If neither resolution path succeeds. """ - from . import app_registry + try: + module = importlib.import_module(component_path) + except ModuleNotFoundError: + module = None + if module is not None: + component = getattr(module, "App", None) + if component is not None: + return component if "." in component_path: module_path, attr = component_path.rsplit(".", 1) try: - module = importlib.import_module(module_path) + parent = importlib.import_module(module_path) except ModuleNotFoundError: - module = None - if module is not None: - component = getattr(module, attr, None) + parent = None + if parent is not None: + component = getattr(parent, attr, None) if component is not None: return component - importlib.import_module(component_path) - registered = app_registry.get_registered_app() - if registered is not None: - return registered - - try: - module = importlib.import_module(component_path) - component = getattr(module, "App", None) - if component is not None: - return component - except ModuleNotFoundError: - pass - raise ImportError( f"Could not resolve component {component_path!r}. " - "Pass a dotted path like 'app.main_page.App' or call pn.run(App) " - "inside the module so it can be auto-discovered." + "Define a top-level `App` function in the module (e.g. " + "`app/main.py`) or pass an explicit dotted path like " + "`app.main.RootScreen`." ) @@ -799,8 +802,8 @@ def set_viewport_size(self, width: float, height: float) -> None: # Redirect Python's stdout/stderr through fd 2 so ``print()`` output is # visible via ``xcrun simctl launch --console-pty``. This runs at - # ``pythonnative.page`` import time, i.e. before any user page module - # (e.g. ``app.main_page``) is imported, so their top-level ``print()`` + # ``pythonnative.page`` import time, i.e. before any user app module + # (e.g. ``app.main``) is imported, so their top-level ``print()`` # calls are captured too. Gated on ``IS_IOS`` rather than rubicon-objc # being importable, so installing the ``[ios]`` extra on macOS does # not silently swap ``sys.stdout`` on a dev machine. @@ -1343,9 +1346,11 @@ def create_page( [`@component`][pythonnative.component] function. Args: - component_path: Dotted Python path to the component, e.g., - `"app.main_page.MainPage"`. The function is imported lazily - so user modules can be reloaded by the dev server. + component_path: Either a module path like `"app.main"` (the + module's top-level ``App`` attribute is used) or a dotted + attribute path like `"app.main.RootScreen"`. The function + is imported lazily so user modules can be reloaded by the + dev server. native_instance: The native `Activity` (Android) or `UIViewController` (iOS) pointer that owns this page. args_json: Optional JSON string of navigation arguments to pass @@ -1358,7 +1363,7 @@ def create_page( Example: ```python host = pythonnative.page.create_page( - "app.main_page.MainPage", + "app.main", native_instance, args_json='{"id": 42}', ) diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt index 7546b5c..f940ea2 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt @@ -28,7 +28,7 @@ class MainActivity : AppCompatActivity() { filesDir.absolutePath ) // Touch module to ensure bundled Python code is available; actual instantiation happens in PageFragment - py.getModule("app.main_page") + py.getModule("app.main") } catch (e: Exception) { Log.e("PythonNative", "Bootstrap failed", e) val tv = TextView(this) diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt index 92ad25b..cdf0d2a 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt @@ -37,11 +37,12 @@ class PageFragment : Fragment() { } try { val py = Python.getInstance() - // Default to app.main_page.App — the new Stack-rooted entry - // point registered via pn.run(App). The legacy - // "app.main_page.MainPage" still resolves because the demo - // exports a backwards-compatible alias. - val pagePath = arguments?.getString("page_path") ?: "app.main_page.App" + // Default to "app.main": PythonNative imports the module + // and looks up its top-level `App` attribute. Override + // via fragment arguments / nav graph to load a different + // module or a specific dotted-attribute path (e.g. + // "app.main.RootScreen"). + val pagePath = arguments?.getString("page_path") ?: "app.main" val argsJson = arguments?.getString("args_json") val filesRoot = requireContext().filesDir.absolutePath val devRoot = "$filesRoot/pythonnative_dev" diff --git a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml index baa5c28..5806198 100644 --- a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +++ b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml @@ -12,7 +12,7 @@ + android:defaultValue="app.main" /> Any: - """Clear the registered App before and after each test.""" - app_registry.clear() - yield - app_registry.clear() - - -def test_run_registers_app_component() -> None: - @pn.component - def MyApp() -> pn.Element: - return pn.Text("hi") - - pn.run(MyApp) - assert app_registry.get_registered_app() is MyApp - - -def test_run_returns_component_so_it_can_be_a_decorator() -> None: - @pn.component - def DecApp() -> pn.Element: - return pn.Text("hi") - - same = pn.run(DecApp) - assert same is DecApp - - -def test_run_rejects_non_callable() -> None: - with pytest.raises(TypeError, match="callable"): - pn.run("not a function") # type: ignore[arg-type] - - -def test_import_component_resolves_dotted_path( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``_import_component`` accepts the legacy ``module.attr`` shape.""" - pkg = tmp_path / "demo_dotted" - pkg.mkdir() - (pkg / "__init__.py").write_text("", encoding="utf-8") - (pkg / "main.py").write_text( - "from pythonnative.element import Element\n\n" - "def Root():\n" - " return Element('Text', {'text': 'dotted'}, [])\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(os.fspath(tmp_path)) - sys.modules.pop("demo_dotted.main", None) - sys.modules.pop("demo_dotted", None) - - fn = _import_component("demo_dotted.main.Root") - el = fn() - assert el.type == "Text" - assert el.props["text"] == "dotted" - - -def test_import_component_resolves_module_via_pn_run( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``_import_component`` finds an App registered via ``pn.run``.""" - pkg = tmp_path / "demo_register" - pkg.mkdir() - (pkg / "__init__.py").write_text("", encoding="utf-8") - (pkg / "entry.py").write_text( - "import pythonnative as pn\n" - "from pythonnative.element import Element\n\n" - "def App():\n" - " return Element('Text', {'text': 'registered'}, [])\n\n" - "pn.run(App)\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(os.fspath(tmp_path)) - sys.modules.pop("demo_register.entry", None) - sys.modules.pop("demo_register", None) - - fn = _import_component("demo_register.entry") - el = fn() - assert el.props["text"] == "registered" - - -def test_import_component_falls_back_to_App_attribute( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """If the module defines ``App`` but doesn't call ``pn.run``, find it anyway.""" - pkg = tmp_path / "demo_implicit" - pkg.mkdir() - (pkg / "__init__.py").write_text("", encoding="utf-8") - (pkg / "entry.py").write_text( - "from pythonnative.element import Element\n\n" - "def App():\n" - " return Element('Text', {'text': 'implicit'}, [])\n", - encoding="utf-8", - ) - monkeypatch.syspath_prepend(os.fspath(tmp_path)) - sys.modules.pop("demo_implicit.entry", None) - sys.modules.pop("demo_implicit", None) - - fn = _import_component("demo_implicit.entry") - el = fn() - assert el.props["text"] == "implicit" - - -def test_import_component_raises_when_nothing_resolves( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Helpful error when neither a dotted attr nor an App is found.""" - pkg = tmp_path / "demo_missing" - pkg.mkdir() - (pkg / "__init__.py").write_text("", encoding="utf-8") - (pkg / "entry.py").write_text("VALUE = 1\n", encoding="utf-8") - monkeypatch.syspath_prepend(os.fspath(tmp_path)) - sys.modules.pop("demo_missing.entry", None) - sys.modules.pop("demo_missing", None) - - with pytest.raises(ImportError, match="Could not resolve component"): - _import_component("demo_missing.entry") diff --git a/tests/test_cli.py b/tests/test_cli.py index 28ff06a..8bab151 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,13 +25,13 @@ def test_cli_init_and_clean() -> None: assert result.returncode == 0, result.stderr assert os.path.isdir(os.path.join(tmpdir, "app")) # scaffolded entrypoint - main_page_path = os.path.join(tmpdir, "app", "main_page.py") - assert os.path.isfile(main_page_path) - with open(main_page_path, "r", encoding="utf-8") as f: + main_path = os.path.join(tmpdir, "app", "main.py") + assert os.path.isfile(main_path) + with open(main_path, "r", encoding="utf-8") as f: content = f.read() assert "def App(" in content - assert "pn.run(App)" in content assert "Stack.Navigator" in content + assert "pn.run" not in content assert os.path.isfile(os.path.join(tmpdir, "pythonnative.json")) assert os.path.isfile(os.path.join(tmpdir, "requirements.txt")) assert os.path.isfile(os.path.join(tmpdir, ".gitignore")) @@ -174,23 +174,23 @@ def _raise(*args: object, **kwargs: object) -> None: def test_hot_reload_manifest_payload_maps_files_to_modules(tmp_path: Path) -> None: app_dir = tmp_path / "app" app_dir.mkdir() - changed = app_dir / "main_page.py" + changed = app_dir / "main.py" changed.write_text("print('hi')\n", encoding="utf-8") payload = pn_cli._hot_reload_manifest_payload([os.fspath(changed)], os.fspath(tmp_path), version="v1") assert payload == { "version": "v1", - "files": ["app/main_page.py"], - "modules": ["app.main_page"], + "files": ["app/main.py"], + "modules": ["app.main"], } def test_android_hot_reload_dest_points_to_overlay() -> None: - assert pn_cli._android_hot_reload_dest("app/main_page.py") == os.path.join( + assert pn_cli._android_hot_reload_dest("app/main.py") == os.path.join( "files", "pythonnative_dev", - "app/main_page.py", + "app/main.py", ) diff --git a/tests/test_hot_reload.py b/tests/test_hot_reload.py index af8469a..f9a83f0 100644 --- a/tests/test_hot_reload.py +++ b/tests/test_hot_reload.py @@ -33,7 +33,7 @@ def test_configure_dev_environment_prioritizes_overlay(tmp_path: Path) -> None: def test_file_to_module_normalizes_relative_paths() -> None: - assert ModuleReloader.file_to_module("app/main_page.py") == "app.main_page" + assert ModuleReloader.file_to_module("app/main.py") == "app.main" assert ModuleReloader.file_to_module("app\\pages\\home.py") == "app.pages.home" assert ModuleReloader.file_to_module("app/__init__.py") == "app" @@ -52,19 +52,19 @@ def reload(self, module_names: list[str]) -> None: json.dump( { "version": "1", - "files": ["app/main_page.py"], - "modules": ["app.main_page"], + "files": ["app/main.py"], + "modules": ["app.main"], }, f, ) version = ModuleReloader.reload_from_manifest(Page(), manifest_path) assert version == "1" - assert calls == [["app.main_page"]] + assert calls == [["app.main"]] version = ModuleReloader.reload_from_manifest(Page(), manifest_path, last_version=version) assert version == "1" - assert calls == [["app.main_page"]] + assert calls == [["app.main"]] def test_reload_module_imports_from_prioritized_sys_path( diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 3f3e068..7396da9 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -959,7 +959,6 @@ def test_navigation_exports_from_package() -> None: assert hasattr(pn, "create_drawer_navigator") assert hasattr(pn, "use_route") assert hasattr(pn, "use_focus_effect") - assert hasattr(pn, "run") # ====================================================================== From 3bcbe34973e5ccfc8c07914403fbaa4ee587376a Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 11 May 2026 18:55:35 -0700 Subject: [PATCH 4/6] refactor(examples): split hello-world into screens/; drop Page suffix --- examples/hello-world/app/main.py | 338 ++---------------- examples/hello-world/app/screens/__init__.py | 0 .../app/{third_page.py => screens/forms.py} | 37 +- examples/hello-world/app/screens/home.py | 96 +++++ examples/hello-world/app/screens/layout.py | 119 ++++++ examples/hello-world/app/screens/list.py | 46 +++ examples/hello-world/app/screens/settings.py | 57 +++ .../{second_page.py => screens/showcase.py} | 40 ++- examples/hello-world/app/theme.py | 17 + tests/e2e/android.yaml | 6 +- .../{layout_tab.yaml => layout_screen.yaml} | 2 +- .../flows/{list_tab.yaml => list_screen.yaml} | 2 +- tests/e2e/flows/main.yaml | 2 +- tests/e2e/flows/navigation.yaml | 14 +- ...settings_tab.yaml => settings_screen.yaml} | 2 +- tests/e2e/ios.yaml | 6 +- 16 files changed, 422 insertions(+), 362 deletions(-) create mode 100644 examples/hello-world/app/screens/__init__.py rename examples/hello-world/app/{third_page.py => screens/forms.py} (74%) create mode 100644 examples/hello-world/app/screens/home.py create mode 100644 examples/hello-world/app/screens/layout.py create mode 100644 examples/hello-world/app/screens/list.py create mode 100644 examples/hello-world/app/screens/settings.py rename examples/hello-world/app/{second_page.py => screens/showcase.py} (79%) create mode 100644 examples/hello-world/app/theme.py rename tests/e2e/flows/{layout_tab.yaml => layout_screen.yaml} (89%) rename tests/e2e/flows/{list_tab.yaml => list_screen.yaml} (79%) rename tests/e2e/flows/{settings_tab.yaml => settings_screen.yaml} (85%) diff --git a/examples/hello-world/app/main.py b/examples/hello-world/app/main.py index 2571ad6..19a5c0e 100644 --- a/examples/hello-world/app/main.py +++ b/examples/hello-world/app/main.py @@ -1,323 +1,45 @@ -"""Hello-world demo with native-backed stack navigation. +"""Hello-world demo: native-backed stack + tab navigation. -The app's root is an [`App`][app.main.App] component that returns a -[`Stack`][pythonnative.create_stack_navigator] navigator wrapping a -[`Tab`][pythonnative.create_tab_navigator] navigator. Native templates -load this module, look up the top-level ``App`` attribute, and start -rendering. +This module is the app's navigation map. It defines: -When the user taps "Go to Second Page" from inside a tab, the stack -navigator pushes a real ``UIViewController`` / ``Fragment`` so they get -system-grade slide transitions and swipe-back. Each push reuses this -Python interpreter; only the reconciler tree for the new screen is -created. -""" +- A root [`Stack`][pythonnative.create_stack_navigator] with three + routes (``Tabs`` -> ``Showcase`` -> ``Forms``). +- A nested [`Tab`][pythonnative.create_tab_navigator] navigator + that holds the four home tabs (Home, Layout, List, Settings). -from typing import Callable +Each screen lives in its own file under ``screens/`` so this file +stays focused on the navigation structure. When the user taps +"View Showcase" from inside a tab, the root stack pushes a real +``UIViewController`` / ``Fragment`` so they get system-grade slide +transitions and swipe-back. Each push reuses this Python +interpreter; only the reconciler tree for the new screen is created. -import emoji +The native templates load this module by path (``"app.main"``) and +look up the top-level ``App`` attribute. +""" import pythonnative as pn -from app.second_page import SecondPage -from app.third_page import ThirdPage +from app.screens.forms import FormsScreen +from app.screens.home import HomeScreen +from app.screens.layout import LayoutScreen +from app.screens.list import ListScreen +from app.screens.settings import SettingsScreen +from app.screens.showcase import ShowcaseScreen print("[hello-world] main module imported") -MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] - Stack = pn.create_stack_navigator() Tab = pn.create_tab_navigator() -styles = pn.StyleSheet.create( - title={"font_size": 24, "bold": True}, - subtitle={"font_size": 16, "color": "#666666"}, - hint={"font_size": 14, "color": "#666666"}, - medal={"font_size": 32}, - card={ - "spacing": 12, - "padding": 16, - "background_color": "#F8F9FA", - "align_items": "center", - }, - section={"spacing": 16, "padding": 24, "align_items": "stretch"}, - button_row={"spacing": 8, "align_items": "center"}, - flex_demo={ - "flex_direction": "row", - "spacing": 8, - "padding": 16, - "background_color": "#EDF2F7", - "height": 80, - }, - flex_box={"background_color": "#4299E1", "padding": 12}, - flex_box_alt={"background_color": "#48BB78", "padding": 12}, - flex_box_label={"color": "#FFFFFF", "bold": True, "text_align": "center"}, - abs_canvas={ - "background_color": "#1A202C", - "height": 200, - "padding": 0, - }, - abs_pin={ - "position": "absolute", - "background_color": "#F6AD55", - "padding": 8, - }, - abs_label={"color": "#1A202C", "bold": True}, -) - - -@pn.component -def counter_badge(initial: int = 0) -> pn.Element: - """Reusable counter component with its own hook-based state. - - State is preserved across Fast Refresh: edit the medal list or - tweak this component, save, and the on-screen tap count stays - where you left it. - """ - count, set_count = pn.use_state(initial) - medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") - - print(f"[counter_badge] render count={count}") - - def handle_tap() -> None: - print(f"[counter_badge] Tap me clicked; {count} -> {count + 1}") - set_count(count + 1) - - def handle_reset() -> None: - print(f"[counter_badge] Reset clicked from count={count}") - set_count(0) - - return pn.View( - pn.Text(f"Tapped {count} times", style=styles["subtitle"]), - pn.Text(medal, style=styles["medal"]), - pn.Row( - pn.Button("Tap me", on_click=handle_tap), - pn.Button("Reset", on_click=handle_reset), - style=styles["button_row"], - ), - style=styles["card"], - ) - - -@pn.component -def HomeTab() -> pn.Element: - """Home tab: counter demo and push-navigation to other pages. - - ``nav.navigate("Second", ...)`` goes through the inner Tab handle, - forwards to the outer Stack handle (root navigator), and pushes a - real native screen via the host's ``_push`` API. - """ - nav = pn.use_navigation() - - def _on_mount() -> Callable[[], None]: - print("[HomeTab] mounted") - return lambda: print("[HomeTab] unmounted") - - pn.use_effect(_on_mount, []) - - def go_to_second() -> None: - print("[HomeTab] navigating to Second") - nav.navigate("Second", {"message": "Greetings from Home"}) - - return pn.ScrollView( - pn.Column( - pn.Text("Hello from PythonNative Demo!", style=styles["title"]), - pn.Text( - "Try `pn run android --hot-reload`, edit this text, and save. " - "The running app should update without a rebuild, and the counter " - "below should preserve its value across the refresh.", - style=styles["hint"], - ), - counter_badge(), - pn.Button("Go to Second Page", on_click=go_to_second), - style=styles["section"], - ) - ) - - -@pn.component -def LayoutTab() -> pn.Element: - """Demonstrates the pure-Python flex layout engine. - - Showcases ``flex: 1`` distribution between siblings, fixed-aspect - boxes, and ``position: "absolute"`` overlays anchored to all four - edges. - """ - return pn.ScrollView( - pn.Column( - pn.Text("Flex layout", style=styles["title"]), - pn.Text( - "Three siblings sharing a row; the middle one expands with `flex: 1`.", - style=styles["hint"], - ), - pn.Row( - pn.View( - pn.Text("80px", style=styles["flex_box_label"]), - style={**styles["flex_box"], "width": 80}, - ), - pn.View( - pn.Text("flex: 1", style=styles["flex_box_label"]), - style={**styles["flex_box"], "flex": 1}, - ), - pn.View( - pn.Text("60px", style=styles["flex_box_label"]), - style={**styles["flex_box_alt"], "width": 60}, - ), - style=styles["flex_demo"], - ), - pn.Text("Aspect ratio", style=styles["title"]), - pn.Text( - "A square (1:1) and a 16:9 box, both sized purely by `aspect_ratio`.", - style=styles["hint"], - ), - pn.Row( - pn.View( - pn.Text("1:1", style=styles["flex_box_label"]), - style={**styles["flex_box"], "width": 80, "aspect_ratio": 1.0}, - ), - pn.View( - pn.Text("16:9", style=styles["flex_box_label"]), - style={ - **styles["flex_box_alt"], - "width": 144, - "aspect_ratio": 16 / 9, - }, - ), - style={"flex_direction": "row", "spacing": 12, "padding": 16}, - ), - pn.Text("Absolute positioning", style=styles["title"]), - pn.Text( - "The four pinned tags are positioned absolutely against this dark canvas.", - style=styles["hint"], - ), - pn.View( - pn.View( - pn.Text("top-left", style=styles["abs_label"]), - style={**styles["abs_pin"], "top": 8, "left": 8}, - ), - pn.View( - pn.Text("top-right", style=styles["abs_label"]), - style={**styles["abs_pin"], "top": 8, "right": 8}, - ), - pn.View( - pn.Text("bottom-left", style=styles["abs_label"]), - style={**styles["abs_pin"], "bottom": 8, "left": 8}, - ), - pn.View( - pn.Text("bottom-right", style=styles["abs_label"]), - style={**styles["abs_pin"], "bottom": 8, "right": 8}, - ), - pn.View( - pn.Text("centered", style=styles["abs_label"]), - style={ - **styles["abs_pin"], - "background_color": "#FBD38D", - "left": "30%", - "right": "30%", - "top": "40%", - }, - ), - style=styles["abs_canvas"], - ), - style=styles["section"], - ) - ) - - -@pn.component -def SettingsTab() -> pn.Element: - """Settings tab: Platform info, alerts, and a quick push to the showcase.""" - nav = pn.use_navigation() - dims = pn.use_window_dimensions() - - def _show_alert() -> None: - pn.Alert.show( - title="Hello!", - message="This is a native alert dialog.", - buttons=[ - {"label": "OK", "style": "default"}, - ], - ) - - def _confirm_destructive() -> None: - pn.Alert.confirm( - title="Delete item?", - message="This action cannot be undone.", - confirm_label="Delete", - cancel_label="Keep", - on_confirm=lambda: print("[SettingsTab] confirmed"), - on_cancel=lambda: print("[SettingsTab] cancelled"), - ) - - def _go_to_showcase() -> None: - nav.navigate("Second", {"message": "Visual showcase"}) - - return pn.ScrollView( - pn.Column( - pn.StatusBar(style="dark"), - pn.Text("Settings", style=styles["title"]), - pn.Text(f"PythonNative v{pn.__version__}", style=styles["subtitle"]), - pn.Text( - f"Running on {pn.Platform.OS} {pn.Platform.Version}", - style=styles["subtitle"], - ), - pn.Text( - f"Window: {dims['width']:.0f} × {dims['height']:.0f}", - style=styles["subtitle"], - ), - pn.Button("Show alert", on_click=_show_alert), - pn.Button("Confirm destructive", on_click=_confirm_destructive), - pn.Button("Visual showcase", on_click=_go_to_showcase), - style=styles["section"], - ) - ) - - -@pn.component -def ListTab() -> pn.Element: - """Demonstrates virtualized FlatList with native row recycling.""" - items = [{"id": i, "title": f"Row {i + 1}", "subtitle": f"Lorem ipsum #{i}"} for i in range(500)] - - def render_row(item: dict, index: int) -> pn.Element: - return pn.View( - pn.Text(item["title"], style={"font_size": 16, "font_weight": "600"}), - pn.Text(item["subtitle"], style={"font_size": 13, "color": "#6B7280"}), - style={ - "padding": 12, - "spacing": 4, - "background_color": "#FFFFFF", - "border_radius": 8, - }, - ) - - return pn.Column( - pn.View( - pn.Text( - "Virtualized FlatList: 500 rows backed by UITableView / RecyclerView", - style={"font_size": 13, "color": "#6B7280"}, - ), - style={"padding": 16, "background_color": "#F9FAFB"}, - ), - pn.FlatList( - data=items, - item_height=64, - separator_height=8, - render_item=render_row, - key_extractor=lambda item, _: str(item["id"]), - on_item_press=lambda i: print(f"[ListTab] tapped row {i}"), - style={"flex": 1, "background_color": "#F3F4F6"}, - ), - style={"flex": 1}, - ) - @pn.component def MainTabs() -> pn.Element: - """Root screen of the Stack: a four-tab home, layout, list, settings UI.""" + """Tabbed root screen: Home, Layout, List, Settings.""" return Tab.Navigator( - Tab.Screen("Home", component=HomeTab, options={"title": "Home"}), - Tab.Screen("Layout", component=LayoutTab, options={"title": "Layout"}), - Tab.Screen("List", component=ListTab, options={"title": "List"}), - Tab.Screen("Settings", component=SettingsTab, options={"title": "Settings"}), + Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), + Tab.Screen("Layout", component=LayoutScreen, options={"title": "Layout"}), + Tab.Screen("List", component=ListScreen, options={"title": "List"}), + Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}), ) @@ -326,14 +48,14 @@ def App() -> pn.Element: """Root component for the hello-world demo. A [`Stack`][pythonnative.create_stack_navigator] wraps the tabbed - home screen so the demo can push the showcase / forms pages onto + home screen so the demo can push the showcase / forms screens onto the native navigation stack. ``options["title"]`` is mirrored to the platform navigation bar. """ return pn.NavigationContainer( Stack.Navigator( - Stack.Screen("Main", component=MainTabs, options={"title": "Hello World"}), - Stack.Screen("Second", component=SecondPage, options={"title": "Second Page"}), - Stack.Screen("Third", component=ThirdPage, options={"title": "Third Page"}), + Stack.Screen("Tabs", component=MainTabs, options={"title": "Hello World"}), + Stack.Screen("Showcase", component=ShowcaseScreen, options={"title": "Showcase"}), + Stack.Screen("Forms", component=FormsScreen, options={"title": "Forms"}), ) ) diff --git a/examples/hello-world/app/screens/__init__.py b/examples/hello-world/app/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/hello-world/app/third_page.py b/examples/hello-world/app/screens/forms.py similarity index 74% rename from examples/hello-world/app/third_page.py rename to examples/hello-world/app/screens/forms.py index 18ad943..0ee83cc 100644 --- a/examples/hello-world/app/third_page.py +++ b/examples/hello-world/app/screens/forms.py @@ -1,11 +1,17 @@ -import pythonnative as pn +"""Forms screen: TextInput, Picker, RefreshControl, and KeyboardAvoidingView. + +Two levels deep on the native stack; demonstrates that +``KeyboardAvoidingView`` properly lifts content above the keyboard +on both platforms and that ``RefreshControl`` integrates with the +underlying ``UIRefreshControl`` / ``SwipeRefreshLayout``. +""" -print("[hello-world] third_page module imported") +import threading -styles = pn.StyleSheet.create( - title={"font_size": 24, "bold": True}, - section_title={"font_size": 18, "font_weight": "600", "color": "#0F172A"}, - hint={"font_size": 13, "color": "#6B7280"}, +import pythonnative as pn +from app.theme import styles + +local_styles = pn.StyleSheet.create( field={ "padding": 12, "border_radius": 8, @@ -14,7 +20,6 @@ "background_color": "#FFFFFF", "font_size": 16, }, - section={"spacing": 16, "padding": 20}, ) FRUIT_OPTIONS = [ @@ -26,8 +31,7 @@ @pn.component -def ThirdPage() -> pn.Element: - """Showcase TextInput, Picker, RefreshControl, and KeyboardAvoidingView.""" +def FormsScreen() -> pn.Element: nav = pn.use_navigation() name, set_name = pn.use_state("") notes, set_notes = pn.use_state("") @@ -43,18 +47,15 @@ def fake_refresh() -> None: def _done() -> None: set_refreshing(False) - # Pretend a network call completes after 800ms. - import threading - threading.Timer(0.8, _done).start() return pn.KeyboardAvoidingView( pn.ScrollView( pn.Column( - pn.Text("Third Page", style=styles["title"]), + pn.Text("Forms", style=styles["title"]), pn.Text("You navigated two levels deep.", style=styles["hint"]), pn.Text( - "Forms demo: single-line input, multiline TextInput, Picker, and pull-to-refresh.", + "Single-line input, multiline TextInput, Picker, and pull-to-refresh.", style=styles["hint"], ), pn.Text("Name", style=styles["section_title"]), @@ -64,7 +65,7 @@ def _done() -> None: on_change=set_name, auto_capitalize="words", return_key_type="next", - style=styles["field"], + style=local_styles["field"], ), pn.Text("Notes (multiline)", style=styles["section_title"]), pn.TextInput( @@ -73,7 +74,7 @@ def _done() -> None: on_change=set_notes, multiline=True, max_length=500, - style={**styles["field"], "height": 120}, + style={**local_styles["field"], "height": 120}, ), pn.Text("Favorite fruit", style=styles["section_title"]), pn.Picker( @@ -81,7 +82,7 @@ def _done() -> None: items=FRUIT_OPTIONS, on_change=set_fruit, placeholder="Pick a fruit…", - style=styles["field"], + style=local_styles["field"], ), pn.Text(f"You picked: {fruit}", style=styles["hint"]), pn.Button("Refresh", on_click=fake_refresh), @@ -89,7 +90,7 @@ def _done() -> None: "Refreshing…" if refreshing else "Idle.", style=styles["hint"], ), - pn.Button("Back to Second", on_click=go_back), + pn.Button("Back to Showcase", on_click=go_back), style=styles["section"], ), refresh_control=pn.RefreshControl(refreshing=refreshing, on_refresh=fake_refresh), diff --git a/examples/hello-world/app/screens/home.py b/examples/hello-world/app/screens/home.py new file mode 100644 index 0000000..5304e66 --- /dev/null +++ b/examples/hello-world/app/screens/home.py @@ -0,0 +1,96 @@ +"""Home tab: state hooks, a reusable child component, and a push to the showcase. + +Designed as the obvious "first thing a new user reads" — it shows +``use_state``, ``use_effect``, and an in-Python child component, plus +the canonical ``nav.navigate(...)`` call that pushes a real native +screen onto the stack. +""" + +from typing import Callable + +import emoji + +import pythonnative as pn +from app.theme import styles + +MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"] + +local_styles = pn.StyleSheet.create( + medal={"font_size": 32}, + card={ + "spacing": 12, + "padding": 16, + "background_color": "#F8F9FA", + "align_items": "center", + }, + button_row={"spacing": 8, "align_items": "center"}, +) + + +@pn.component +def counter_badge(initial: int = 0) -> pn.Element: + """Reusable counter component with its own hook-based state. + + State is preserved across Fast Refresh: edit the medal list or + tweak this component, save, and the on-screen tap count stays + where you left it. + """ + count, set_count = pn.use_state(initial) + medal = emoji.emojize(MEDALS[count] if count < len(MEDALS) else ":star:") + + print(f"[counter_badge] render count={count}") + + def handle_tap() -> None: + print(f"[counter_badge] Tap me clicked; {count} -> {count + 1}") + set_count(count + 1) + + def handle_reset() -> None: + print(f"[counter_badge] Reset clicked from count={count}") + set_count(0) + + return pn.View( + pn.Text(f"Tapped {count} times", style=styles["subtitle"]), + pn.Text(medal, style=local_styles["medal"]), + pn.Row( + pn.Button("Tap me", on_click=handle_tap), + pn.Button("Reset", on_click=handle_reset), + style=local_styles["button_row"], + ), + style=local_styles["card"], + ) + + +@pn.component +def HomeScreen() -> pn.Element: + """Counter demo + push-navigation entry point. + + ``nav.navigate("Showcase", ...)`` goes through the inner Tab handle, + forwards to the outer Stack handle (root navigator), and pushes a + real native screen via the host's ``_push`` API. + """ + nav = pn.use_navigation() + + def _on_mount() -> Callable[[], None]: + print("[HomeScreen] mounted") + return lambda: print("[HomeScreen] unmounted") + + pn.use_effect(_on_mount, []) + + def view_showcase() -> None: + print("[HomeScreen] navigating to Showcase") + nav.navigate("Showcase", {"message": "Greetings from Home"}) + + return pn.ScrollView( + pn.Column( + pn.Text("Hello from PythonNative Demo!", style=styles["title"]), + pn.Text( + "Try `pn run android --hot-reload`, edit this text, and save. " + "The running app should update without a rebuild, and the counter " + "below should preserve its value across the refresh.", + style=styles["hint"], + ), + counter_badge(), + pn.Button("View Showcase", on_click=view_showcase), + style=styles["section"], + ) + ) diff --git a/examples/hello-world/app/screens/layout.py b/examples/hello-world/app/screens/layout.py new file mode 100644 index 0000000..6f23542 --- /dev/null +++ b/examples/hello-world/app/screens/layout.py @@ -0,0 +1,119 @@ +"""Layout tab: a tour of the pure-Python flex engine. + +Shows three things the engine does that mobile devs typically need: + +- ``flex: 1`` to split a row between fixed and stretching children. +- ``aspect_ratio`` to size a box from a single dimension + ratio. +- ``position: "absolute"`` with edge anchors, including percentage + offsets for centering. +""" + +import pythonnative as pn +from app.theme import styles + +local_styles = pn.StyleSheet.create( + flex_demo={ + "flex_direction": "row", + "spacing": 8, + "padding": 16, + "background_color": "#EDF2F7", + "height": 80, + }, + flex_box={"background_color": "#4299E1", "padding": 12}, + flex_box_alt={"background_color": "#48BB78", "padding": 12}, + flex_box_label={"color": "#FFFFFF", "bold": True, "text_align": "center"}, + abs_canvas={ + "background_color": "#1A202C", + "height": 200, + "padding": 0, + }, + abs_pin={ + "position": "absolute", + "background_color": "#F6AD55", + "padding": 8, + }, + abs_label={"color": "#1A202C", "bold": True}, +) + + +@pn.component +def LayoutScreen() -> pn.Element: + return pn.ScrollView( + pn.Column( + pn.Text("Flex layout", style=styles["title"]), + pn.Text( + "Three siblings sharing a row; the middle one expands with `flex: 1`.", + style=styles["hint"], + ), + pn.Row( + pn.View( + pn.Text("80px", style=local_styles["flex_box_label"]), + style={**local_styles["flex_box"], "width": 80}, + ), + pn.View( + pn.Text("flex: 1", style=local_styles["flex_box_label"]), + style={**local_styles["flex_box"], "flex": 1}, + ), + pn.View( + pn.Text("60px", style=local_styles["flex_box_label"]), + style={**local_styles["flex_box_alt"], "width": 60}, + ), + style=local_styles["flex_demo"], + ), + pn.Text("Aspect ratio", style=styles["title"]), + pn.Text( + "A square (1:1) and a 16:9 box, both sized purely by `aspect_ratio`.", + style=styles["hint"], + ), + pn.Row( + pn.View( + pn.Text("1:1", style=local_styles["flex_box_label"]), + style={**local_styles["flex_box"], "width": 80, "aspect_ratio": 1.0}, + ), + pn.View( + pn.Text("16:9", style=local_styles["flex_box_label"]), + style={ + **local_styles["flex_box_alt"], + "width": 144, + "aspect_ratio": 16 / 9, + }, + ), + style={"flex_direction": "row", "spacing": 12, "padding": 16}, + ), + pn.Text("Absolute positioning", style=styles["title"]), + pn.Text( + "The four pinned tags are positioned absolutely against this dark canvas.", + style=styles["hint"], + ), + pn.View( + pn.View( + pn.Text("top-left", style=local_styles["abs_label"]), + style={**local_styles["abs_pin"], "top": 8, "left": 8}, + ), + pn.View( + pn.Text("top-right", style=local_styles["abs_label"]), + style={**local_styles["abs_pin"], "top": 8, "right": 8}, + ), + pn.View( + pn.Text("bottom-left", style=local_styles["abs_label"]), + style={**local_styles["abs_pin"], "bottom": 8, "left": 8}, + ), + pn.View( + pn.Text("bottom-right", style=local_styles["abs_label"]), + style={**local_styles["abs_pin"], "bottom": 8, "right": 8}, + ), + pn.View( + pn.Text("centered", style=local_styles["abs_label"]), + style={ + **local_styles["abs_pin"], + "background_color": "#FBD38D", + "left": "30%", + "right": "30%", + "top": "40%", + }, + ), + style=local_styles["abs_canvas"], + ), + style=styles["section"], + ) + ) diff --git a/examples/hello-world/app/screens/list.py b/examples/hello-world/app/screens/list.py new file mode 100644 index 0000000..cb0fa1c --- /dev/null +++ b/examples/hello-world/app/screens/list.py @@ -0,0 +1,46 @@ +"""List tab: virtualized FlatList demo. + +500 rows backed by ``UITableView`` on iOS and ``RecyclerView`` on +Android. Row views are recycled by the platform; PythonNative just +keeps a small pool of mounted reconciler trees and re-renders them +into recycled cells. +""" + +import pythonnative as pn + + +@pn.component +def ListScreen() -> pn.Element: + items = [{"id": i, "title": f"Row {i + 1}", "subtitle": f"Lorem ipsum #{i}"} for i in range(500)] + + def render_row(item: dict, index: int) -> pn.Element: + return pn.View( + pn.Text(item["title"], style={"font_size": 16, "font_weight": "600"}), + pn.Text(item["subtitle"], style={"font_size": 13, "color": "#6B7280"}), + style={ + "padding": 12, + "spacing": 4, + "background_color": "#FFFFFF", + "border_radius": 8, + }, + ) + + return pn.Column( + pn.View( + pn.Text( + "Virtualized FlatList: 500 rows backed by UITableView / RecyclerView", + style={"font_size": 13, "color": "#6B7280"}, + ), + style={"padding": 16, "background_color": "#F9FAFB"}, + ), + pn.FlatList( + data=items, + item_height=64, + separator_height=8, + render_item=render_row, + key_extractor=lambda item, _: str(item["id"]), + on_item_press=lambda i: print(f"[ListScreen] tapped row {i}"), + style={"flex": 1, "background_color": "#F3F4F6"}, + ), + style={"flex": 1}, + ) diff --git a/examples/hello-world/app/screens/settings.py b/examples/hello-world/app/screens/settings.py new file mode 100644 index 0000000..2eec057 --- /dev/null +++ b/examples/hello-world/app/screens/settings.py @@ -0,0 +1,57 @@ +"""Settings tab: Platform info, native alerts, and a push to the showcase. + +Demonstrates the imperative ``pn.Alert`` API, runtime queries via +``pn.Platform`` and ``pn.use_window_dimensions``, and how to drive +the root stack from inside a tab via ``pn.use_navigation``. +""" + +import pythonnative as pn +from app.theme import styles + + +@pn.component +def SettingsScreen() -> pn.Element: + nav = pn.use_navigation() + dims = pn.use_window_dimensions() + + def _show_alert() -> None: + pn.Alert.show( + title="Hello!", + message="This is a native alert dialog.", + buttons=[ + {"label": "OK", "style": "default"}, + ], + ) + + def _confirm_destructive() -> None: + pn.Alert.confirm( + title="Delete item?", + message="This action cannot be undone.", + confirm_label="Delete", + cancel_label="Keep", + on_confirm=lambda: print("[SettingsScreen] confirmed"), + on_cancel=lambda: print("[SettingsScreen] cancelled"), + ) + + def _view_showcase() -> None: + nav.navigate("Showcase", {"message": "Visual showcase"}) + + return pn.ScrollView( + pn.Column( + pn.StatusBar(style="dark"), + pn.Text("Settings", style=styles["title"]), + pn.Text(f"PythonNative v{pn.__version__}", style=styles["subtitle"]), + pn.Text( + f"Running on {pn.Platform.OS} {pn.Platform.Version}", + style=styles["subtitle"], + ), + pn.Text( + f"Window: {dims['width']:.0f} × {dims['height']:.0f}", + style=styles["subtitle"], + ), + pn.Button("Show alert", on_click=_show_alert), + pn.Button("Confirm destructive", on_click=_confirm_destructive), + pn.Button("Visual showcase", on_click=_view_showcase), + style=styles["section"], + ) + ) diff --git a/examples/hello-world/app/second_page.py b/examples/hello-world/app/screens/showcase.py similarity index 79% rename from examples/hello-world/app/second_page.py rename to examples/hello-world/app/screens/showcase.py index 4e8e29f..32334bd 100644 --- a/examples/hello-world/app/second_page.py +++ b/examples/hello-world/app/screens/showcase.py @@ -1,11 +1,14 @@ -import pythonnative as pn +"""Showcase screen: visual primitives — Animated, typography, borders, chips. + +Pushed onto the native stack by tapping "View Showcase" on the Home +tab. Receives a ``message`` param via ``nav.get_params()`` to +demonstrate route parameters. +""" -print("[hello-world] second_page module imported") +import pythonnative as pn +from app.theme import styles -styles = pn.StyleSheet.create( - title={"font_size": 24, "bold": True}, - section_title={"font_size": 18, "font_weight": "600", "color": "#0F172A"}, - hint={"font_size": 13, "color": "#6B7280"}, +local_styles = pn.StyleSheet.create( card={ "padding": 20, "background_color": "#FFFFFF", @@ -24,13 +27,12 @@ "background_color": "#0EA5E9", }, chip_label={"color": "#FFFFFF", "font_weight": "600", "font_size": 13}, - section={"spacing": 16, "padding": 20}, ) @pn.component def AnimatedCard() -> pn.Element: - """Demonstrates Animated.View driven by AnimatedValue + use_memo.""" + """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), []) @@ -90,39 +92,39 @@ def BordersAndShadows() -> pn.Element: "border_radius, border_width, shadow_*, elevation all in style.", style=styles["hint"], ), - style=styles["card"], + style=local_styles["card"], ) @pn.component def Chips() -> pn.Element: return pn.Row( - pn.View(pn.Text("New", style=styles["chip_label"]), style=styles["chip"]), + pn.View(pn.Text("New", style=local_styles["chip_label"]), style=local_styles["chip"]), pn.View( - pn.Text("Trending", style=styles["chip_label"]), - style={**styles["chip"], "background_color": "#22C55E"}, + pn.Text("Trending", style=local_styles["chip_label"]), + style={**local_styles["chip"], "background_color": "#22C55E"}, ), pn.View( - pn.Text("Sale", style=styles["chip_label"]), - style={**styles["chip"], "background_color": "#EF4444"}, + pn.Text("Sale", style=local_styles["chip_label"]), + style={**local_styles["chip"], "background_color": "#EF4444"}, ), style={"spacing": 8}, ) @pn.component -def SecondPage() -> pn.Element: +def ShowcaseScreen() -> pn.Element: nav = pn.use_navigation() message = nav.get_params().get("message", "Visual showcase") - print(f"[SecondPage] render message={message!r}") + print(f"[ShowcaseScreen] render message={message!r}") pressed_color, set_pressed_color = pn.use_state("#0EA5E9") def _toggle_color() -> None: set_pressed_color("#10B981" if pressed_color == "#0EA5E9" else "#0EA5E9") - def go_to_third() -> None: - nav.navigate("Third") + def view_forms() -> None: + nav.navigate("Forms") def go_back() -> None: nav.go_back() @@ -152,7 +154,7 @@ def go_back() -> None: on_press=_toggle_color, pressed_opacity=0.7, ), - pn.Button("Go to Third Page", on_click=go_to_third), + pn.Button("View Forms", on_click=view_forms), pn.Button("Back", on_click=go_back), style=styles["section"], ) diff --git a/examples/hello-world/app/theme.py b/examples/hello-world/app/theme.py new file mode 100644 index 0000000..ff3f95b --- /dev/null +++ b/examples/hello-world/app/theme.py @@ -0,0 +1,17 @@ +"""Shared styles for the hello-world demo. + +Centralizing the most-reused text and layout styles keeps each screen +file focused on its own behaviour. Screen-specific styles +(``flex_box``, ``abs_canvas``, ``chip``, ``field``, etc.) stay inline +in the screen that owns them so each file remains self-contained. +""" + +import pythonnative as pn + +styles = pn.StyleSheet.create( + title={"font_size": 24, "bold": True}, + subtitle={"font_size": 16, "color": "#666666"}, + section_title={"font_size": 18, "font_weight": "600", "color": "#0F172A"}, + hint={"font_size": 13, "color": "#6B7280"}, + section={"spacing": 16, "padding": 20, "align_items": "stretch"}, +) diff --git a/tests/e2e/android.yaml b/tests/e2e/android.yaml index 554c595..41c36bd 100644 --- a/tests/e2e/android.yaml +++ b/tests/e2e/android.yaml @@ -4,6 +4,6 @@ env: --- - runFlow: flows/main.yaml - runFlow: flows/navigation.yaml -- runFlow: flows/layout_tab.yaml -- runFlow: flows/list_tab.yaml -- runFlow: flows/settings_tab.yaml +- runFlow: flows/layout_screen.yaml +- runFlow: flows/list_screen.yaml +- runFlow: flows/settings_screen.yaml diff --git a/tests/e2e/flows/layout_tab.yaml b/tests/e2e/flows/layout_screen.yaml similarity index 89% rename from tests/e2e/flows/layout_tab.yaml rename to tests/e2e/flows/layout_screen.yaml index 8c41c76..bcbb51a 100644 --- a/tests/e2e/flows/layout_tab.yaml +++ b/tests/e2e/flows/layout_screen.yaml @@ -1,6 +1,6 @@ appId: ${APP_ID} --- -# Switch to the Layout tab and verify the flex / aspect-ratio / +# Switch to the Layout screen and verify the flex / aspect-ratio / # absolute-positioning demos all render after the layout pass. - launchApp - extendedWaitUntil: diff --git a/tests/e2e/flows/list_tab.yaml b/tests/e2e/flows/list_screen.yaml similarity index 79% rename from tests/e2e/flows/list_tab.yaml rename to tests/e2e/flows/list_screen.yaml index 81fa919..a2ee814 100644 --- a/tests/e2e/flows/list_tab.yaml +++ b/tests/e2e/flows/list_screen.yaml @@ -1,6 +1,6 @@ appId: ${APP_ID} --- -# Switch to the List tab and verify the virtualized FlatList renders rows. +# Switch to the List screen and verify the virtualized FlatList renders rows. - launchApp - extendedWaitUntil: visible: "Hello from PythonNative Demo!" diff --git a/tests/e2e/flows/main.yaml b/tests/e2e/flows/main.yaml index 1b8780c..62c04b6 100644 --- a/tests/e2e/flows/main.yaml +++ b/tests/e2e/flows/main.yaml @@ -7,7 +7,7 @@ appId: ${APP_ID} timeout: 30000 - assertVisible: "Tapped 0 times" - assertVisible: "Tap me" -- assertVisible: "Go to Second Page" +- assertVisible: "View Showcase" - tapOn: "Tap me" - assertVisible: "Tapped 1 times" - tapOn: "Tap me" diff --git a/tests/e2e/flows/navigation.yaml b/tests/e2e/flows/navigation.yaml index 40359b3..6d14ffd 100644 --- a/tests/e2e/flows/navigation.yaml +++ b/tests/e2e/flows/navigation.yaml @@ -1,19 +1,19 @@ appId: ${APP_ID} --- -# Navigate through all three pages and back to the home screen. +# Navigate Home -> Showcase -> Forms and back to Home. - launchApp - extendedWaitUntil: visible: "Hello from PythonNative Demo!" timeout: 30000 -- tapOn: "Go to Second Page" +- tapOn: "View Showcase" - assertVisible: "Greetings from Home" -- assertVisible: "Go to Third Page" +- assertVisible: "View Forms" - assertVisible: "Back" -- tapOn: "Go to Third Page" -- assertVisible: "Third Page" +- tapOn: "View Forms" +- assertVisible: "Forms" - assertVisible: "You navigated two levels deep." -- assertVisible: "Back to Second" -- tapOn: "Back to Second" +- assertVisible: "Back to Showcase" +- tapOn: "Back to Showcase" - assertVisible: "Greetings from Home" - tapOn: "Back" - assertVisible: "Hello from PythonNative Demo!" diff --git a/tests/e2e/flows/settings_tab.yaml b/tests/e2e/flows/settings_screen.yaml similarity index 85% rename from tests/e2e/flows/settings_tab.yaml rename to tests/e2e/flows/settings_screen.yaml index 2a860fd..ab692dc 100644 --- a/tests/e2e/flows/settings_tab.yaml +++ b/tests/e2e/flows/settings_screen.yaml @@ -1,6 +1,6 @@ appId: ${APP_ID} --- -# Switch to the Settings tab and verify Platform info + Alert API. +# Switch to the Settings screen and verify Platform info + Alert API. - launchApp - extendedWaitUntil: visible: "Hello from PythonNative Demo!" diff --git a/tests/e2e/ios.yaml b/tests/e2e/ios.yaml index 573cc38..a299b94 100644 --- a/tests/e2e/ios.yaml +++ b/tests/e2e/ios.yaml @@ -4,6 +4,6 @@ env: --- - runFlow: flows/main.yaml - runFlow: flows/navigation.yaml -- runFlow: flows/layout_tab.yaml -- runFlow: flows/list_tab.yaml -- runFlow: flows/settings_tab.yaml +- runFlow: flows/layout_screen.yaml +- runFlow: flows/list_screen.yaml +- runFlow: flows/settings_screen.yaml From 743d3390032d6b700735754c604ab6ea732d46f2 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 11 May 2026 19:13:44 -0700 Subject: [PATCH 5/6] refactor(screen)!: rename page/PageFragment to screen/ScreenFragment --- CONTRIBUTING.md | 6 +- docs/api/hooks.md | 2 +- docs/api/pythonnative.md | 4 +- docs/api/{page.md => screen.md} | 12 +-- docs/concepts/architecture.md | 14 +-- docs/concepts/components.md | 2 +- docs/concepts/lifecycle.md | 6 +- docs/examples/hello-world.md | 2 +- docs/guides/android.md | 6 +- docs/guides/hot-reload.md | 12 +-- docs/guides/ios.md | 4 +- docs/guides/navigation.md | 18 ++-- docs/guides/testing.md | 4 +- mkdocs.yml | 2 +- src/pythonnative/__init__.py | 4 +- src/pythonnative/cli/pn.py | 2 +- src/pythonnative/hooks.py | 26 ++--- src/pythonnative/hot_reload.py | 30 +++--- src/pythonnative/navigation.py | 4 +- src/pythonnative/platform_metrics.py | 18 ++-- src/pythonnative/reconciler.py | 14 +-- src/pythonnative/{page.py => screen.py} | 102 +++++++++--------- .../android_template/MainActivity.kt | 6 +- .../android_template/Navigator.kt | 6 +- .../{PageFragment.kt => ScreenFragment.kt} | 36 +++---- .../app/src/main/res/navigation/nav_graph.xml | 10 +- .../ios_template/ViewController.swift | 52 ++++----- src/pythonnative/utils.py | 10 +- tests/test_cli.py | 2 +- tests/test_hooks.py | 4 +- tests/test_hot_reload.py | 6 +- tests/test_metric_hooks.py | 6 +- tests/test_navigation.py | 36 +++---- tests/test_reconciler.py | 2 +- tests/{test_page.py => test_screen.py} | 56 +++++----- tests/test_smoke.py | 2 +- 36 files changed, 264 insertions(+), 264 deletions(-) rename docs/api/{page.md => screen.md} (65%) rename src/pythonnative/{page.py => screen.py} (94%) rename src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/{PageFragment.kt => ScreenFragment.kt} (74%) rename tests/{test_page.py => test_screen.py} (73%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a87445b..fc7b676 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,8 +110,8 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc - `native_modules` – native API modules for device capabilities (`native_modules/`) - `native_views` – platform-specific native view creation and updates (`native_views/`) - `package` – `src/pythonnative/__init__.py` exports and package boundary - - `page` – Page component, lifecycle, and reactive state (`page.py`) - `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`) + - `screen` – screen host, native lifecycle bridge, and render scheduling (`screen.py`) - `style` – StyleSheet and theming (`style.py`) - `utils` – shared utilities (`utils.py`) @@ -154,7 +154,7 @@ Breaking changes: - Use `!` after the type/scope or a `BREAKING CHANGE:` footer. ```text -feat(core)!: rename Page.set_root_view to set_root +feat(screen)!: rename create_page to create_screen BREAKING CHANGE: API renamed; update app code and templates. ``` @@ -288,7 +288,7 @@ pn run ios maestro --platform ios test ../../tests/e2e/ios.yaml ``` -Test flows live in `tests/e2e/flows/` and cover main page rendering, counter interaction, and multi-page navigation. The `e2e.yml` workflow runs these automatically on pushes to `main` and PRs. +Test flows live in `tests/e2e/flows/` and cover the main screen rendering, counter interaction, and multi-screen navigation. The `e2e.yml` workflow runs these automatically on pushes to `main` and PRs. ### CI diff --git a/docs/api/hooks.md b/docs/api/hooks.md index 8024783..bc6c9f7 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -16,7 +16,7 @@ slot across renders. These hooks subscribe to values published by `pythonnative.platform_metrics` and re-render the component when they -change. The page host is the only code that updates the underlying +change. The screen host is the only code that updates the underlying values; user code consumes them. - [`use_window_dimensions`][pythonnative.use_window_dimensions] — viewport size. diff --git a/docs/api/pythonnative.md b/docs/api/pythonnative.md index 5bcfeb4..c768921 100644 --- a/docs/api/pythonnative.md +++ b/docs/api/pythonnative.md @@ -17,7 +17,7 @@ def App(): return pn.NavigationContainer(...) ``` -The bundled Android `PageFragment` and iOS `ViewController` load +The bundled Android `ScreenFragment` and iOS `ViewController` load your app by **module path** (`"app.main"`) and look up the module's top-level `App` attribute. There is no registration step or imperative bootstrap call. If you need to expose a @@ -44,7 +44,7 @@ The reference is split per module so each page stays scannable: | Navigation | [Navigation](navigation.md) | [`NavigationContainer`][pythonnative.NavigationContainer], [`create_stack_navigator`][pythonnative.create_stack_navigator], [`create_tab_navigator`][pythonnative.create_tab_navigator], [`create_drawer_navigator`][pythonnative.create_drawer_navigator], [`use_navigation`][pythonnative.use_navigation] | | Styling | [Style](style.md) | [`StyleSheet`][pythonnative.StyleSheet], [`ThemeContext`][pythonnative.style.ThemeContext] | | Element descriptor | [Element](element.md) | [`Element`][pythonnative.Element] | -| Page host | [Page](page.md) | [`create_page`][pythonnative.create_page] | +| Screen host | [Screen](screen.md) | [`create_screen`][pythonnative.create_screen] | | Reconciler | [Reconciler](reconciler.md) | [`Reconciler`][pythonnative.reconciler.Reconciler] | | Native modules | [Native modules](native_modules.md) | `Camera`, `Location`, `FileSystem`, `Notifications` | | Native views | [Native views](native_views.md) | [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry], [`ViewHandler`][pythonnative.native_views.base.ViewHandler] | diff --git a/docs/api/page.md b/docs/api/screen.md similarity index 65% rename from docs/api/page.md rename to docs/api/screen.md index 100e709..78a6809 100644 --- a/docs/api/page.md +++ b/docs/api/screen.md @@ -1,13 +1,13 @@ -# Page +# Screen -The page host owns a [`Reconciler`][pythonnative.reconciler.Reconciler], +The screen host owns a [`Reconciler`][pythonnative.reconciler.Reconciler], schedules re-renders, and forwards platform lifecycle hooks (resume, pause, destroy) to navigators and effects. The bundled Android (`MainActivity`) and iOS (`ViewController`) templates create a -host via [`create_page`][pythonnative.create_page] and never need to be -edited by app code. +host via [`create_screen`][pythonnative.create_screen] and never need to +be edited by app code. -::: pythonnative.page +::: pythonnative.screen options: show_root_heading: false show_root_toc_entry: false @@ -17,5 +17,5 @@ edited by app code. ## Next steps - Understand the render queue in [Lifecycle](../concepts/lifecycle.md). -- See how navigation owns its own pages in +- See how navigation hosts each screen in [`NavigationContainer`][pythonnative.NavigationContainer]. diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md index 6fc8db8..0843991 100644 --- a/docs/concepts/architecture.md +++ b/docs/concepts/architecture.md @@ -47,7 +47,7 @@ platform APIs synchronously from Python. the JNI bridge. 9. **Thin native bootstrap.** The host app remains native (Android `Activity` or iOS `UIViewController`). It calls - [`create_page`][pythonnative.create_page] internally to bootstrap + [`create_screen`][pythonnative.create_screen] internally to bootstrap your Python component, and the reconciler drives the UI from there. 10. **`App` entry point.** The user's app module (`app/main.py`) @@ -120,7 +120,7 @@ Each component is a Python function that: - Has its own hook state per call site (each instance gets its own slot table). -The entry point [`create_page`][pythonnative.create_page] is called +The entry point [`create_screen`][pythonnative.create_screen] is called internally by the bundled native templates to bootstrap your root component. App code does not call it directly. @@ -259,7 +259,7 @@ See [Mental model](mental-model.md) for a wider comparison table. ## iOS flow (rubicon-objc) - The iOS template (Swift plus PythonKit) boots Python and calls - [`create_page`][pythonnative.create_page] internally with the + [`create_screen`][pythonnative.create_screen] internally with the current `UIViewController` pointer. - The reconciler creates UIKit views and attaches them to the controller's view. @@ -270,7 +270,7 @@ See [Mental model](mental-model.md) for a wider comparison table. - The Android template (Kotlin plus Chaquopy) initializes Python in `MainActivity` and passes the `Activity` to Python. -- `PageFragment` calls [`create_page`][pythonnative.create_page] +- `ScreenFragment` calls [`create_screen`][pythonnative.create_screen] internally, which renders the root component and attaches views to the fragment container. - State changes trigger re-render; the reconciler patches Android @@ -285,7 +285,7 @@ near-instant UI updates without full rebuilds. PythonNative uses a **Fast Refresh** strategy: 1. Reload the changed module(s) on the device. -2. For every active page host, walk the VNode tree and collect every +2. For every active screen host, walk the VNode tree and collect every component function defined in a reloaded module. 3. Match each one to its replacement by `__module__` + `__qualname__` and rewrite `Element.type` in place. @@ -331,7 +331,7 @@ PythonNative navigation is **declarative** and **native-backed**: stack, stacks inside tabs) stay in Python and reuse the existing reconciler. - Each pushed native screen is a fresh host with its own reconciler - and `_AppHost`. Initial routes are forwarded via host arguments + and `_ScreenHost`. Initial routes are forwarded via host arguments (`__pn_initial_route__` / `__pn_initial_params__`), so a pushed screen knows which `Stack.Screen` to render on its first frame. - Inside any screen, [`use_navigation`][pythonnative.use_navigation] @@ -347,7 +347,7 @@ native navigation bar. - iOS: one host `UIViewController` class, many instances pushed on a `UINavigationController`. - Android: single host `Activity` with a `NavHostFragment` and a - stack of generic `PageFragment`s driven by a navigation graph. + stack of generic `ScreenFragment`s driven by a navigation graph. ## Next steps diff --git a/docs/concepts/components.md b/docs/concepts/components.md index 8603450..8ecd3ce 100644 --- a/docs/concepts/components.md +++ b/docs/concepts/components.md @@ -175,7 +175,7 @@ def App(): return pn.Text(f"Hello, {name}!", style={"font_size": 24}) ``` -The entry point [`create_page`][pythonnative.create_page] is called +The entry point [`create_screen`][pythonnative.create_screen] is called internally by native templates to bootstrap your root component. You don't call it directly: name your top-level component `App` (so the templates can find it by convention) and `pythonnative.json` points diff --git a/docs/concepts/lifecycle.md b/docs/concepts/lifecycle.md index e54bf71..b732a29 100644 --- a/docs/concepts/lifecycle.md +++ b/docs/concepts/lifecycle.md @@ -9,7 +9,7 @@ fold into it, and where you can hook in. A render pass is triggered by: -- Initial mount via [`create_page`][pythonnative.create_page]. +- Initial mount via [`create_screen`][pythonnative.create_screen]. - A setter from [`use_state`][pythonnative.use_state] or a `dispatch` from [`use_reducer`][pythonnative.use_reducer]. - A navigation event (`navigate`, `go_back`, `replace`). @@ -29,7 +29,7 @@ The phases: first; new [`use_effect`][pythonnative.use_effect] callbacks run after, in depth-first order so children commit before parents. 4. **Drain**. If any effect set state, another render pass is queued - immediately. The page host caps the loop to prevent runaway + immediately. The screen host caps the loop to prevent runaway re-renders. ```text @@ -86,7 +86,7 @@ When the user navigates away: ## App lifecycle (Android / iOS) -The page host forwards the platform's app-level lifecycle to navigators +The screen host forwards the platform's app-level lifecycle to navigators and effects: - **Resume / `viewWillAppear`**: the active screen's `use_focus_effect` diff --git a/docs/examples/hello-world.md b/docs/examples/hello-world.md index faf13f4..3758a96 100644 --- a/docs/examples/hello-world.md +++ b/docs/examples/hello-world.md @@ -31,7 +31,7 @@ def App(): (like `use_state`) work because the decorator establishes a hook context for each call. - `pn.use_state(0)` returns `(value, setter)`. The setter triggers a - re-render scheduled by the page host. + re-render scheduled by the screen host. - `pn.Column(*children, style=...)` returns a vertical container element. Both the children and the style are read on every render; the reconciler diffs them against the previous render and updates diff --git a/docs/guides/android.md b/docs/guides/android.md index dfd9559..69387c0 100644 --- a/docs/guides/android.md +++ b/docs/guides/android.md @@ -12,8 +12,8 @@ No network is required for the template itself; the template zip is bundled with Your `app/` directory contains `@pn.component` function components. The native Android template uses -[`create_page`][pythonnative.create_page] internally to bootstrap your -root component inside a `PageFragment`. You don't call `create_page` +[`create_screen`][pythonnative.create_screen] internally to bootstrap your +root component inside a `ScreenFragment`. You don't call `create_screen` directly; just export your component and configure the entry point in `pythonnative.json`. @@ -41,7 +41,7 @@ the template use, so you get Python output without the usual logcat noise: |-----------------|--------------------------------------------------| | `python.stdout` | `print()` / anything written to `sys.stdout` | | `python.stderr` | tracebacks / anything written to `sys.stderr` | -| `MainActivity`, `PageFragment`, `Navigator` | Kotlin template lifecycle | +| `MainActivity`, `ScreenFragment`, `Navigator` | Kotlin template lifecycle | | `AndroidRuntime:E` | Fatal Java/Kotlin exceptions | Press Ctrl+C to stop streaming. Pass `--no-logs` to skip log streaming diff --git a/docs/guides/hot-reload.md b/docs/guides/hot-reload.md index c39f229..77c48c0 100644 --- a/docs/guides/hot-reload.md +++ b/docs/guides/hot-reload.md @@ -3,7 +3,7 @@ Hot reload turns your edit-save-rebuild loop into edit-save-see. The `pn` CLI watches `app/` for changes and pushes the modified files straight to the running app, where a small device-side helper reloads -the affected modules and asks the page host to re-render. +the affected modules and asks the screen host to re-render. ## Turn it on @@ -43,7 +43,7 @@ When a source file changes, the CLI copies it to that overlay: After the files are in place, the CLI writes `reload.json`. The Android and iOS templates poll that manifest on the platform main -thread and call the page host's reload hook. The host re-imports the +thread and call the screen host's reload hook. The host re-imports the root component by dotted path, resets hook/navigation state for the page, and mounts the refreshed tree. @@ -54,7 +54,7 @@ PythonNative reloads any `.py` file under `app/`. The device-side the file to a dotted module name (e.g., `app/pages/home.py` becomes `app.pages.home`) and re-imports it from disk. -After reloading, every active page host runs **Fast Refresh** in +After reloading, every active screen host runs **Fast Refresh** in place: 1. Walk the live VNode tree and collect every component function @@ -79,7 +79,7 @@ tree doesn't reference yet, or the swap raises — the host get stuck with a stale tree. Hook state is reset in that case. Per-screen scope: each native screen (UIViewController on iOS, -PageFragment on Android) runs its own host, so Fast Refresh +ScreenFragment on Android) runs its own host, so Fast Refresh operates independently per host. Two pushed screens both running Fast Refresh for the same changed module each swap their own references. @@ -104,8 +104,8 @@ references. !!! warning "References across modules" If module `a` does `from b import Foo` and only `b.py` changes, - module `a` may still hold the *old* `Foo`. The page host always - reloads the root page module after changed modules so common + module `a` may still hold the *old* `Foo`. The screen host always + reloads the root screen module after changed modules so common component imports update, but long-lived references (e.g., stashed in a global) can drift. When in doubt, restart the app. diff --git a/docs/guides/ios.md b/docs/guides/ios.md index 5d7d29b..3091596 100644 --- a/docs/guides/ios.md +++ b/docs/guides/ios.md @@ -12,8 +12,8 @@ The default `ViewController.swift` initializes PythonKit, prints the Python vers Your `app/` directory contains `@pn.component` function components. The native iOS template uses -[`create_page`][pythonnative.create_page] internally to bootstrap your -root component inside a `ViewController`. You don't call `create_page` +[`create_screen`][pythonnative.create_screen] internally to bootstrap your +root component inside a `ViewController`. You don't call `create_screen` directly; just export your component and configure the entry point in `pythonnative.json`. diff --git a/docs/guides/navigation.md b/docs/guides/navigation.md index d30ba67..df4a264 100644 --- a/docs/guides/navigation.md +++ b/docs/guides/navigation.md @@ -39,7 +39,7 @@ def App(): ) ``` -The native templates (Android `PageFragment`, iOS `ViewController`) +The native templates (Android `ScreenFragment`, iOS `ViewController`) import `app.main` and look up its top-level `App` attribute, so no other wiring is required. `options={"title": ...}` propagates to the native navigation bar. @@ -252,22 +252,22 @@ PythonNative forwards lifecycle events from the host: ### iOS (UIViewController per screen) - Each pushed screen is a Swift `ViewController` instance with its - own Python `_AppHost` and reconciler. + own Python `_ScreenHost` and reconciler. - Screens are pushed and popped on a root `UINavigationController` set up by the template's `SceneDelegate`. - The declarative `Stack.Navigator` delegates to `nav.pushViewController_animated_` / `popViewControllerAnimated_` and the initial-route name is forwarded via the host's - `requestedPagePath` / `requestedPageArgsJSON` properties. + `requestedScreenPath` / `requestedScreenArgsJSON` properties. - Screen `options.title` is applied via `UIViewController.title`, which the surrounding `UINavigationController` picks up. ### Android (single Activity, Fragment stack) - The host `MainActivity` embeds a `NavHostFragment` containing a - navigation graph with a single generic `PageFragment` destination. -- Each pushed screen is a fresh `PageFragment` instance with its own - Python `_AppHost` and reconciler; arguments live in Fragment - arguments (`page_path` / `args_json`) and restore across + navigation graph with a single generic `ScreenFragment` destination. +- Each pushed screen is a fresh `ScreenFragment` instance with its own + Python `_ScreenHost` and reconciler; arguments live in Fragment + arguments (`screen_path` / `args_json`) and restore across configuration changes. - Push/pop delegate to `NavController` through a small `Navigator` Kotlin helper, including `popToRoot` for `Stack.reset(...)`. @@ -277,12 +277,12 @@ PythonNative forwards lifecycle events from the host: Pushing onto a native stack is most useful when the new screen does not have to re-bootstrap Python or re-run the whole tree. Each -pushed view-controller / fragment owns its own Python `_AppHost`, +pushed view-controller / fragment owns its own Python `_ScreenHost`, so: - The previous screen's reconciler stays alive in memory; its hook state and native views are preserved by the platform stack. -- The new screen's `_AppHost` resolves its initial route from the +- The new screen's `_ScreenHost` resolves its initial route from the arguments passed by the parent's `navigate(...)` call, so the declarative `Stack.Navigator` always renders the right screen on the first frame. diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 12caa65..90720b3 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -68,7 +68,7 @@ component under test. ## Rendering a component in a test -`create_page` boots an `_AppHost` which is the same shape used at +`create_screen` boots an `_ScreenHost` which is the same shape used at runtime. For tests we want a more direct path: invoke the reconciler with a known root and read its output. @@ -84,7 +84,7 @@ def render(element): return rec.root_view # the mock dict for the root element ``` -(For a longer-running test (effects, navigation), use `create_page` so +(For a longer-running test (effects, navigation), use `create_screen` so you get the full lifecycle plumbing.) ## Asserting on rendered output diff --git a/mkdocs.yml b/mkdocs.yml index f7cb52c..0acda5b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -101,7 +101,7 @@ nav: - Alerts: api/alerts.md - Platform: api/platform.md - Element: api/element.md - - Page: api/page.md + - Screen: api/screen.md - Reconciler: api/reconciler.md - Layout: api/layout.md - Style: api/style.md diff --git a/src/pythonnative/__init__.py b/src/pythonnative/__init__.py index 0334f39..c7f1425 100644 --- a/src/pythonnative/__init__.py +++ b/src/pythonnative/__init__.py @@ -98,8 +98,8 @@ def App(): use_focus_effect, use_route, ) -from .page import create_page from .platform import Platform +from .screen import create_screen from .style import StyleSheet, ThemeContext __all__ = [ @@ -130,7 +130,7 @@ def App(): "WebView", # Core "Element", - "create_page", + "create_screen", # Hooks "batch_updates", "component", diff --git a/src/pythonnative/cli/pn.py b/src/pythonnative/cli/pn.py index 8d4e319..d9e2977 100644 --- a/src/pythonnative/cli/pn.py +++ b/src/pythonnative/cli/pn.py @@ -344,7 +344,7 @@ def _read_requirements(requirements_path: str) -> list[str]: "python.stdout:V", "python.stderr:V", "MainActivity:V", - "PageFragment:V", + "ScreenFragment:V", "Navigator:V", "PythonNative:V", "AndroidRuntime:E", diff --git a/src/pythonnative/hooks.py b/src/pythonnative/hooks.py index fc22e16..0b7401b 100644 --- a/src/pythonnative/hooks.py +++ b/src/pythonnative/hooks.py @@ -511,13 +511,13 @@ def use_window_dimensions() -> Dict[str, float]: """Return the current viewport size and re-render when it changes. Equivalent to React Native's ``useWindowDimensions``. The values - are pushed by the page host whenever the platform reports a new + are pushed by the screen host whenever the platform reports a new size (initial layout, rotation, multitasking split-view). Returns: A dict with ``"width"`` and ``"height"`` floats in layout units (pt on iOS, dp on Android). Both are ``0.0`` until the - page host has run its first layout pass. + screen host has run its first layout pass. Raises: RuntimeError: If called outside a `@component` function. @@ -742,20 +742,20 @@ def HomeScreen(): def __init__(self, host: Any) -> None: self._host = host - def navigate(self, page: Any, params: Optional[Dict[str, Any]] = None) -> None: - """Push ``page`` onto the navigation stack. + def navigate(self, component: Any, params: Optional[Dict[str, Any]] = None) -> None: + """Push ``component`` onto the navigation stack. Args: - page: A ``@component`` function or a dotted Python path - (e.g. ``"app.detail.DetailScreen"``). When a Stack - navigator is the root of the app, prefer the + component: A ``@component`` function or a dotted Python + path (e.g. ``"app.detail.DetailScreen"``). When a + Stack navigator is the root of the app, prefer the declarative ``nav.navigate("Detail", params)`` form - returned by ``use_navigation()`` — it pushes by route - name and the host re-uses its own ``App`` component. + returned by ``use_navigation()`` (it pushes by route + name and the host re-uses its own ``App`` component). params: Optional dict of arguments serialized into the target screen. """ - self._host._push(page, params) + self._host._push(component, params) def go_back(self) -> None: """Pop the current screen and return to the previous one.""" @@ -780,13 +780,13 @@ def use_navigation() -> NavigationHandle: Raises: RuntimeError: If called outside a component rendered via - [`create_page`][pythonnative.create_page]. + [`create_screen`][pythonnative.create_screen]. """ handle = use_context(_NavigationContext) if handle is None: raise RuntimeError( - "use_navigation() called outside a PythonNative page. " - "Ensure your component is rendered via create_page()." + "use_navigation() called outside a PythonNative screen. " + "Ensure your component is rendered via create_screen()." ) return handle diff --git a/src/pythonnative/hot_reload.py b/src/pythonnative/hot_reload.py index cc06e41..8de6eda 100644 --- a/src/pythonnative/hot_reload.py +++ b/src/pythonnative/hot_reload.py @@ -8,8 +8,8 @@ ``simctl`` file copy on iOS). - **Device-side**: [`ModuleReloader`][pythonnative.hot_reload.ModuleReloader] reloads - changed Python modules using ``importlib`` and asks the page host - to re-render its current tree. + changed Python modules using ``importlib`` and asks the screen + host to re-render its current tree. Two strategies share the device-side surface: @@ -264,7 +264,7 @@ def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]: If empty, `file_path` is treated as already relative. Returns: - The dotted module name (e.g., `"app.pages.home"`), or + The dotted module name (e.g., `"app.screens.home"`), or `None` for an empty path. """ rel = os.path.relpath(file_path, base_dir) if base_dir else file_path @@ -287,24 +287,24 @@ def modules_from_files(file_paths: Sequence[str], base_dir: str = "") -> List[st return modules @staticmethod - def reload_page(page_instance: Any, module_names: Optional[Sequence[str]] = None) -> None: - """Force a page re-render after a module reload. + def reload_screen(screen_instance: Any, module_names: Optional[Sequence[str]] = None) -> None: + """Force a screen re-render after a module reload. Args: - page_instance: An `_AppHost` instance (or duck-typed + screen_instance: A `_ScreenHost` instance (or duck-typed equivalent) that exposes a `_reconciler` attribute. module_names: Optional modules that changed. Reload-aware - page hosts use this to refresh imports before re-render. + screen hosts use this to refresh imports before re-render. """ - reload_fn = getattr(page_instance, "reload", None) + reload_fn = getattr(screen_instance, "reload", None) if callable(reload_fn): reload_fn(list(module_names or [])) return - from .page import _request_render + from .screen import _request_render - if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None: - _request_render(page_instance) + if hasattr(screen_instance, "_reconciler") and screen_instance._reconciler is not None: + _request_render(screen_instance) @staticmethod def find_replacement_function(old_fn: Any) -> Optional[Any]: @@ -460,7 +460,7 @@ def refresh_in_place(reconciler: Any, reloaded_modules: Iterable[str]) -> bool: @staticmethod def reload_from_manifest( - page_instance: Any, + screen_instance: Any, manifest_path: str, *, last_version: Optional[str] = None, @@ -468,9 +468,9 @@ def reload_from_manifest( """Apply a reload manifest if it is newer than `last_version`. Args: - page_instance: Page host to refresh. + screen_instance: Screen host to refresh. manifest_path: JSON manifest written by the CLI. - last_version: Version already applied by this page host. + last_version: Version already applied by this screen host. Returns: The manifest version after applying, or `last_version` when @@ -491,5 +491,5 @@ def reload_from_manifest( files = manifest.get("files", []) modules = ModuleReloader.modules_from_files(files if isinstance(files, list) else []) - ModuleReloader.reload_page(page_instance, [str(module) for module in modules]) + ModuleReloader.reload_screen(screen_instance, [str(module) for module in modules]) return version diff --git a/src/pythonnative/navigation.py b/src/pythonnative/navigation.py index 3e0ee34..6882153 100644 --- a/src/pythonnative/navigation.py +++ b/src/pythonnative/navigation.py @@ -140,8 +140,8 @@ class _DeclarativeNavHandle: Implements the same interface as [`NavigationHandle`][pythonnative.hooks.NavigationHandle] so [`use_navigation`][pythonnative.use_navigation] returns a - compatible object regardless of whether the app uses page-based - navigation or declarative navigators. + compatible object regardless of whether the app drives navigation + imperatively or through declarative navigators. When ``parent`` is the host's own ``NavigationHandle`` (root Stack), ``navigate`` / ``go_back`` / ``reset`` drive the native diff --git a/src/pythonnative/platform_metrics.py b/src/pythonnative/platform_metrics.py index e6b2adc..288e637 100644 --- a/src/pythonnative/platform_metrics.py +++ b/src/pythonnative/platform_metrics.py @@ -1,6 +1,6 @@ -"""Platform-level metrics shared between page hosts and view handlers. +"""Platform-level metrics shared between screen hosts and view handlers. -The page host (`pythonnative.page`) is the only place that knows +The screen host (`pythonnative.screen`) is the only place that knows about native window/safe-area state because it is the only piece of code that holds a reference to the native ``UIViewController`` (iOS) or ``Activity`` (Android). Native view handlers, however, need @@ -15,11 +15,11 @@ Rather than threading those values through every [`measure_intrinsic`][pythonnative.native_views.base.ViewHandler.measure_intrinsic] -call signature, the page host writes them here and handlers read +call signature, the screen host writes them here and handlers read them on demand. Values are in **dp on Android** and **pt on iOS** — i.e., the same "layout units" the layout engine uses on each platform, so handlers can add them to other layout-unit values -without further conversion. On iOS the page host consumes the top +without further conversion. On iOS the screen host consumes the top safe-area inset by positioning the root view below it, then publishes ``top=0`` here; Android publishes the raw system-bar insets because the host view normally remains full-screen. @@ -107,7 +107,7 @@ def _unsub() -> None: def set_safe_area_insets(top: float, left: float, bottom: float, right: float) -> None: """Publish the current safe-area insets. - Called by the platform-specific page host whenever it learns a + Called by the platform-specific screen host whenever it learns a new value (e.g., on first layout, on rotation, on multitasking split-view changes). Negative inputs are clamped to ``0.0`` so handlers don't have to defend against bad data from native @@ -140,7 +140,7 @@ def get_safe_area_insets() -> SafeAreaInsets: The default value is ``(0, 0, 0, 0)`` — handlers should still function correctly on a desktop / unit-test environment where no - page host has published insets. + screen host has published insets. """ return _safe_area_insets @@ -160,7 +160,7 @@ def reset_safe_area_insets() -> None: def set_window_dimensions(width: float, height: float) -> None: """Publish the viewport size in layout units. - Called by the page host on initial layout, rotation, and split- + Called by the screen host on initial layout, rotation, and split- view changes. Notifies subscribers (and therefore re-renders components using ``use_window_dimensions``) only when the size actually changes. @@ -215,7 +215,7 @@ def reset_keyboard_height() -> None: # # Only iOS exposes an explicit constant here. The iOS handler can't # trust ``UITabBar.sizeThatFits_`` (it has historically returned 0 in -# some configurations) and the page host deliberately extends the +# some configurations) and the screen host deliberately extends the # root view past the bottom safe area so the bar reaches the home # indicator — both pieces conspire to require a single source of # truth for the height formula. @@ -238,7 +238,7 @@ def ios_tab_bar_height() -> float: """Return the iOS tab-bar intrinsic height in points. Equal to ``IOS_TAB_BAR_BASE_HEIGHT_PT + safe_area_insets.bottom`` - so the bar reaches the home indicator. The iOS page host + so the bar reaches the home indicator. The iOS screen host deliberately extends the root view past the bottom safe area for this very reason — the tab bar absorbs the inset and UIKit renders the pill with internal padding for the home indicator. diff --git a/src/pythonnative/reconciler.py b/src/pythonnative/reconciler.py index eb729d3..2334e7d 100644 --- a/src/pythonnative/reconciler.py +++ b/src/pythonnative/reconciler.py @@ -24,7 +24,7 @@ the committed VNodes and fed through [`calculate_layout`][pythonnative.layout.calculate_layout]; the resulting per-node frames are applied via the backend's - ``set_frame``. The viewport size is supplied by the page host via + ``set_frame``. The viewport size is supplied by the screen host via [`set_viewport_size`][pythonnative.reconciler.Reconciler.set_viewport_size]. """ @@ -87,7 +87,7 @@ class Reconciler: def __init__(self, backend: Any) -> None: self.backend = backend self._tree: Optional[VNode] = None - self._page_re_render: Optional[Any] = None + self._screen_re_render: Optional[Any] = None self._viewport_size: Tuple[float, float] = (0.0, 0.0) self._layout_pass = 0 @@ -146,7 +146,7 @@ def reconcile(self, new_element: Element) -> Any: def set_viewport_size(self, width: float, height: float) -> None: """Update the viewport size and re-run layout if it changed. - Called by the page host whenever the platform reports a new + Called by the screen host whenever the platform reports a new container size (Android: ``onLayoutChange``; iOS: ``viewDidLayoutSubviews``). The first call after mount triggers the initial layout pass; subsequent identical @@ -272,7 +272,7 @@ def _create_tree(self, element: Element) -> VNode: from .hooks import HookState, _set_hook_state hook_state = HookState() - hook_state._trigger_render = self._page_re_render + hook_state._trigger_render = self._screen_re_render _set_hook_state(hook_state) try: rendered = element.type(**element.props) @@ -385,7 +385,7 @@ def _reconcile_node(self, old: VNode, new_el: Element) -> VNode: hook_state = HookState() hook_state.reset_index() - hook_state._trigger_render = self._page_re_render + hook_state._trigger_render = self._screen_re_render _set_hook_state(hook_state) try: rendered = new_el.type(**new_el.props) @@ -644,12 +644,12 @@ def _run_layout(self) -> None: Wraps the user's root VNode in a synthetic outer `LayoutNode` with the viewport size so the user's root always fills the screen by default (matching React Native). - Skipped silently until the page host has supplied a + Skipped silently until the screen host has supplied a viewport size via [`set_viewport_size`][pythonnative.reconciler.Reconciler.set_viewport_size]. The root native view's *frame* is intentionally NOT touched: - its position and size are owned by the page host (iOS + its position and size are owned by the screen host (iOS ``_sync_root_frame`` places it below the top safe-area inset; Android attaches it with ``MATCH_PARENT``). Calling ``set_frame(root, 0, 0, w, h)`` here would silently reset diff --git a/src/pythonnative/page.py b/src/pythonnative/screen.py similarity index 94% rename from src/pythonnative/page.py rename to src/pythonnative/screen.py index 9ff18f1..f2da87b 100644 --- a/src/pythonnative/page.py +++ b/src/pythonnative/screen.py @@ -1,11 +1,11 @@ -"""Page host: the bridge between native lifecycle and function components. +"""Screen host: the bridge between native lifecycle and function components. -Users do not subclass `Page`. Instead they write `@component` functions -and the native template calls -[`create_page`][pythonnative.create_page] to obtain a host that manages -the reconciler and lifecycle. +Users do not write screen classes by hand. Instead they write +``@component`` functions and the native template calls +[`create_screen`][pythonnative.create_screen] to obtain a host that +manages the reconciler and lifecycle for that screen. -The page host owns: +The screen host owns: - A [`Reconciler`][pythonnative.reconciler.Reconciler] backed by the platform's native-view registry. @@ -35,7 +35,7 @@ def App(): The native template wires it in: ```python - host = pythonnative.page.create_page( + host = pythonnative.screen.create_screen( "app.main", native_instance, ) @@ -75,16 +75,16 @@ def _log_pn(msg: str) -> None: # ====================================================================== -def _resolve_component_path(page_ref: Any) -> str: +def _resolve_component_path(component_ref: Any) -> str: """Resolve a component function or string into a `module.name` path.""" - if isinstance(page_ref, str): - return page_ref - func = getattr(page_ref, "__wrapped__", page_ref) + if isinstance(component_ref, str): + return component_ref + func = getattr(component_ref, "__wrapped__", component_ref) module = getattr(func, "__module__", None) name = getattr(func, "__name__", None) if module and name: return f"{module}.{name}" - raise ValueError(f"Cannot resolve component path for {page_ref!r}") + raise ValueError(f"Cannot resolve component path for {component_ref!r}") def _import_component(component_path: str) -> Any: @@ -171,7 +171,7 @@ def _push_viewport_size(host: Any, width: float, height: float) -> None: """Forward a viewport-size change to the reconciler. Called by the native template (or our injected layout listener - on Android, or `_attach_root` on iOS) whenever the page + on Android, or `_attach_root` on iOS) whenever the screen container's bounds change. Coordinates must be in points (not raw pixels). Also publishes the new dimensions to `pythonnative.platform_metrics` so the @@ -207,7 +207,7 @@ def _new_reconciler(host: Any) -> Any: from .reconciler import Reconciler reconciler = Reconciler(get_registry()) - reconciler._page_re_render = lambda: _request_render(host) + reconciler._screen_re_render = lambda: _request_render(host) return reconciler @@ -442,7 +442,7 @@ def _try_fast_refresh(host: Any, reloaded_modules: Sequence[str]) -> bool: def _full_remount(host: Any, reloaded_modules: Sequence[str]) -> None: """Destroy the existing tree and mount a fresh one. - Used by [`_reload_host`][pythonnative.page._reload_host] as the + Used by [`_reload_host`][pythonnative.screen._reload_host] as the fallback path when Fast Refresh cannot apply (e.g. the user deleted a component that was on screen). """ @@ -552,7 +552,7 @@ def _android_publish_window_insets(view: Any) -> None: ``getInsets(systemBars())`` API, and very old phones may not expose ``getRootWindowInsets`` at all. All branches are wrapped in ``try/except`` because diagnostics here must - never crash a page host. + never crash a screen host. """ try: from . import platform_metrics @@ -656,10 +656,10 @@ def _android_push_initial_viewport(host: Any, view: Any) -> None: except Exception: pass - class _AppHost: + class _ScreenHost: """Android host backed by an `Activity` and fragment-based navigation. - Owned by the page fragment template. Bridges Android lifecycle + Owned by the screen fragment template. Bridges Android lifecycle callbacks (`onCreate`, `onPause`, etc.) to the reconciler and the function component. """ @@ -718,11 +718,11 @@ def set_args(self, args: Any) -> None: def _get_nav_args(self) -> Dict[str, Any]: return self._args - def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: - page_path = _resolve_component_path(page) + def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None: + screen_path = _resolve_component_path(component) Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator") args_json = json.dumps(args) if args else None - Navigator.push(self.native_instance, page_path, args_json) + Navigator.push(self.native_instance, screen_path, args_json) def _pop(self) -> None: try: @@ -802,7 +802,7 @@ def set_viewport_size(self, width: float, height: float) -> None: # Redirect Python's stdout/stderr through fd 2 so ``print()`` output is # visible via ``xcrun simctl launch --console-pty``. This runs at - # ``pythonnative.page`` import time, i.e. before any user app module + # ``pythonnative.screen`` import time, i.e. before any user app module # (e.g. ``app.main``) is imported, so their top-level ``print()`` # calls are captured too. Gated on ``IS_IOS`` rather than rubicon-objc # being importable, so installing the ``[ios]`` extra on macOS does @@ -815,7 +815,7 @@ def set_viewport_size(self, width: float, height: float) -> None: except Exception: pass - _IOS_PAGE_REGISTRY: _Dict[int, Any] = {} + _IOS_SCREEN_REGISTRY: _Dict[int, Any] = {} _IOS_SCHEDULED_RENDER_HOSTS: _Dict[int, Any] = {} _ios_render_scheduler_target: Any = None _ios_native_render_scheduler: Any = None @@ -834,7 +834,7 @@ def _objc_addr(obj: Any) -> Optional[int]: - Pure-Python integers also occur (e.g., when the caller has already converted). - This helper covers all three so the page-host registry is + This helper covers all three so the screen-host registry is keyed under the same integer Swift sends back via ``forward_lifecycle``. Returns ``None`` only if every conversion path fails, in which case the caller logs a diagnostic. @@ -866,19 +866,19 @@ def _log_pn(msg: str) -> None: except Exception: pass - def _ios_register_page(vc_instance: Any, host_obj: Any) -> None: + def _ios_register_screen(vc_instance: Any, host_obj: Any) -> None: ptr = _objc_addr(vc_instance) if ptr is None: - _log_pn(f"register_page: could not extract address from {type(vc_instance).__name__}") + _log_pn(f"register_screen: could not extract address from {type(vc_instance).__name__}") return - _IOS_PAGE_REGISTRY[ptr] = host_obj - _log_pn(f"register_page: addr={ptr} (registry size={len(_IOS_PAGE_REGISTRY)})") + _IOS_SCREEN_REGISTRY[ptr] = host_obj + _log_pn(f"register_screen: addr={ptr} (registry size={len(_IOS_SCREEN_REGISTRY)})") - def _ios_unregister_page(vc_instance: Any) -> None: + def _ios_unregister_screen(vc_instance: Any) -> None: ptr = _objc_addr(vc_instance) if ptr is None: return - _IOS_PAGE_REGISTRY.pop(ptr, None) + _IOS_SCREEN_REGISTRY.pop(ptr, None) def _flush_ios_scheduled_renders() -> None: hosts = list(_IOS_SCHEDULED_RENDER_HOSTS.values()) @@ -922,12 +922,12 @@ def forward_lifecycle(native_addr: int, event: str) -> None: except Exception as e: _log_pn(f"forward_lifecycle: bad native_addr={native_addr!r}: {e!r}") return - host = _IOS_PAGE_REGISTRY.get(key) + host = _IOS_SCREEN_REGISTRY.get(key) if host is None: _log_pn( f"forward_lifecycle: NO HOST for event={event!r} addr={key} " - f"(registry has {len(_IOS_PAGE_REGISTRY)} entry(ies): " - f"{list(_IOS_PAGE_REGISTRY.keys())})" + f"(registry has {len(_IOS_SCREEN_REGISTRY)} entry(ies): " + f"{list(_IOS_SCREEN_REGISTRY.keys())})" ) return handler = getattr(host, event, None) @@ -991,10 +991,10 @@ def _schedule_render_async(host: Any) -> bool: _log_pn(f"_request_render: iOS defer failed ({e!r}); rendering synchronously") return False - class _AppHost: + class _ScreenHost: """iOS host backed by a `UIViewController`. - Owned by the page view-controller template. Bridges iOS + Owned by the screen view-controller template. Bridges iOS lifecycle callbacks (`viewDidLoad`, `viewWillDisappear`, etc.) to the reconciler and the function component. """ @@ -1008,7 +1008,7 @@ def __init__(self, native_instance: Any, component_path: str, component_func: An self.native_instance = native_instance _init_host_common(self, component_path, component_func) if self.native_instance is not None: - _ios_register_page(self.native_instance, self) + _ios_register_screen(self.native_instance, self) def on_create(self) -> None: _on_create(self) @@ -1024,7 +1024,7 @@ def on_stop(self) -> None: def on_destroy(self) -> None: if self.native_instance is not None: - _ios_unregister_page(self.native_instance) + _ios_unregister_screen(self.native_instance) def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None: _enable_hot_reload(self, manifest_path) @@ -1050,8 +1050,8 @@ def set_args(self, args: Any) -> None: def _get_nav_args(self) -> Dict[str, Any]: return self._args - def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: - page_path = _resolve_component_path(page) + def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None: + screen_path = _resolve_component_path(component) ViewController = None try: ViewController = ObjCClass("ViewController") @@ -1072,9 +1072,9 @@ def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: next_vc = ViewController.alloc().init() try: - next_vc.setValue_forKey_(page_path, "requestedPagePath") + next_vc.setValue_forKey_(screen_path, "requestedScreenPath") if args: - next_vc.setValue_forKey_(json.dumps(args), "requestedPageArgsJSON") + next_vc.setValue_forKey_(json.dumps(args), "requestedScreenArgsJSON") except Exception: pass nav = getattr(self.native_instance, "navigationController", None) @@ -1245,7 +1245,7 @@ def set_viewport_size(self, width: float, height: float) -> None: else: - class _AppHost: + class _ScreenHost: """Desktop stub used when no native runtime is available. Fully functional for unit tests when a mock backend is @@ -1309,7 +1309,7 @@ def set_args(self, args: Any) -> None: def _get_nav_args(self) -> Dict[str, Any]: return self._args - def _push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None: + def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None: raise RuntimeError("navigate() requires a native runtime (iOS or Android)") def _pop(self) -> None: @@ -1334,14 +1334,14 @@ def _detach_root(self, native_view: Any) -> None: # ====================================================================== -def create_page( +def create_screen( component_path: str, native_instance: Any = None, args_json: Optional[str] = None, -) -> _AppHost: - """Create a page host for a function component. +) -> _ScreenHost: + """Create a screen host for a function component. - Called by native templates (`PageFragment.kt` on Android, + Called by native templates (`ScreenFragment.kt` on Android, `ViewController.swift` on iOS) to bridge the native lifecycle to a [`@component`][pythonnative.component] function. @@ -1352,17 +1352,17 @@ def create_page( is imported lazily so user modules can be reloaded by the dev server. native_instance: The native `Activity` (Android) or - `UIViewController` (iOS) pointer that owns this page. + `UIViewController` (iOS) pointer that owns this screen. args_json: Optional JSON string of navigation arguments to pass to the component on first render. Returns: - An `_AppHost` ready to receive lifecycle callbacks (`on_create`, + A `_ScreenHost` ready to receive lifecycle callbacks (`on_create`, `on_pause`, etc.) from the platform. Example: ```python - host = pythonnative.page.create_page( + host = pythonnative.screen.create_screen( "app.main", native_instance, args_json='{"id": 42}', @@ -1371,7 +1371,7 @@ def create_page( ``` """ component_func = _import_component(component_path) - host = _AppHost(native_instance, component_path, component_func) + host = _ScreenHost(native_instance, component_path, component_func) if args_json: _set_args(host, args_json) return host diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt index f940ea2..0746dd8 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt @@ -19,15 +19,15 @@ class MainActivity : AppCompatActivity() { Python.start(AndroidPlatform(this)) } try { - // Set content view to the NavHost layout; the initial page loads via nav_graph startDestination + // Set content view to the NavHost layout; the initial screen loads via nav_graph startDestination setContentView(R.layout.activity_main) - // Optionally, bootstrap Python so first fragment can create the initial page onCreate + // Optionally, bootstrap Python so first fragment can create the initial screen onCreate val py = Python.getInstance() py.getModule("pythonnative.hot_reload").callAttr( "configure_dev_environment", filesDir.absolutePath ) - // Touch module to ensure bundled Python code is available; actual instantiation happens in PageFragment + // Touch module to ensure bundled Python code is available; actual instantiation happens in ScreenFragment py.getModule("app.main") } catch (e: Exception) { Log.e("PythonNative", "Bootstrap failed", e) diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt index 8e3b347..874b0b8 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/Navigator.kt @@ -7,15 +7,15 @@ import androidx.navigation.fragment.NavHostFragment object Navigator { @JvmStatic - fun push(activity: FragmentActivity, pagePath: String, argsJson: String?) { + fun push(activity: FragmentActivity, screenPath: String, argsJson: String?) { val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHost.navController val args = Bundle() - args.putString("page_path", pagePath) + args.putString("screen_path", screenPath) if (argsJson != null) { args.putString("args_json", argsJson) } - navController.navigate(R.id.pageFragment, args) + navController.navigate(R.id.screenFragment, args) } @JvmStatic diff --git a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt similarity index 74% rename from src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt rename to src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt index cdf0d2a..bf8fe06 100644 --- a/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +++ b/src/pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt @@ -14,14 +14,14 @@ import com.chaquo.python.PyObject import com.chaquo.python.Python import com.chaquo.python.android.AndroidPlatform -class PageFragment : Fragment() { +class ScreenFragment : Fragment() { private val TAG = javaClass.simpleName - private var page: PyObject? = null + private var screen: PyObject? = null private val hotReloadHandler = Handler(Looper.getMainLooper()) private val hotReloadRunnable = object : Runnable { override fun run() { try { - page?.callAttr("hot_reload_tick") + screen?.callAttr("hot_reload_tick") } catch (e: Exception) { Log.w(TAG, "hot_reload_tick failed", e) } finally { @@ -42,17 +42,17 @@ class PageFragment : Fragment() { // via fragment arguments / nav graph to load a different // module or a specific dotted-attribute path (e.g. // "app.main.RootScreen"). - val pagePath = arguments?.getString("page_path") ?: "app.main" + val screenPath = arguments?.getString("screen_path") ?: "app.main" val argsJson = arguments?.getString("args_json") val filesRoot = requireContext().filesDir.absolutePath val devRoot = "$filesRoot/pythonnative_dev" val hotReload = py.getModule("pythonnative.hot_reload") hotReload.callAttr("configure_dev_environment", filesRoot) - val pnPage = py.getModule("pythonnative.page") - page = pnPage.callAttr("create_page", pagePath, requireActivity(), argsJson) - page?.callAttr("enable_hot_reload", "$devRoot/reload.json", devRoot) + val pnScreen = py.getModule("pythonnative.screen") + screen = pnScreen.callAttr("create_screen", screenPath, requireActivity(), argsJson) + screen?.callAttr("enable_hot_reload", "$devRoot/reload.json", devRoot) } catch (e: Exception) { - Log.e(TAG, "Failed to instantiate page", e) + Log.e(TAG, "Failed to instantiate screen", e) } } @@ -75,13 +75,13 @@ class PageFragment : Fragment() { // Python side will call set_root_view to attach a native view to Activity. // In fragment-based architecture, the Activity will set contentView once, // so we ensure the fragment's container is available for Python to target. - // Expose the fragment container to Python so Page.set_root_view can attach into it + // Expose the fragment container to Python so the screen host can attach into it. try { val py = Python.getInstance() val utils = py.getModule("pythonnative.utils") utils.callAttr("set_android_fragment_container", view) // Now that container exists, invoke on_create so Python can attach its root view - page?.callAttr("on_create") + screen?.callAttr("on_create") hotReloadHandler.removeCallbacks(hotReloadRunnable) hotReloadHandler.postDelayed(hotReloadRunnable, 500) } catch (e: Exception) { @@ -91,22 +91,22 @@ class PageFragment : Fragment() { override fun onStart() { super.onStart() - try { page?.callAttr("on_start") } catch (e: Exception) { Log.w(TAG, "on_start failed", e) } + try { screen?.callAttr("on_start") } catch (e: Exception) { Log.w(TAG, "on_start failed", e) } } override fun onResume() { super.onResume() - try { page?.callAttr("on_resume") } catch (e: Exception) { Log.w(TAG, "on_resume failed", e) } + try { screen?.callAttr("on_resume") } catch (e: Exception) { Log.w(TAG, "on_resume failed", e) } } override fun onPause() { super.onPause() - try { page?.callAttr("on_pause") } catch (e: Exception) { Log.w(TAG, "on_pause failed", e) } + try { screen?.callAttr("on_pause") } catch (e: Exception) { Log.w(TAG, "on_pause failed", e) } } override fun onStop() { super.onStop() - try { page?.callAttr("on_stop") } catch (e: Exception) { Log.w(TAG, "on_stop failed", e) } + try { screen?.callAttr("on_stop") } catch (e: Exception) { Log.w(TAG, "on_stop failed", e) } } override fun onDestroyView() { @@ -117,14 +117,14 @@ class PageFragment : Fragment() { override fun onDestroy() { super.onDestroy() hotReloadHandler.removeCallbacks(hotReloadRunnable) - try { page?.callAttr("on_destroy") } catch (e: Exception) { Log.w(TAG, "on_destroy failed", e) } + try { screen?.callAttr("on_destroy") } catch (e: Exception) { Log.w(TAG, "on_destroy failed", e) } } companion object { - fun newInstance(pagePath: String, argsJson: String?): PageFragment { - val f = PageFragment() + fun newInstance(screenPath: String, argsJson: String?): ScreenFragment { + val f = ScreenFragment() f.arguments = bundleOf( - "page_path" to pagePath, + "screen_path" to screenPath, "args_json" to argsJson ) return f diff --git a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml index 5806198..2c2a72a 100644 --- a/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +++ b/src/pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml @@ -3,14 +3,14 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_graph" - app:startDestination="@id/pageFragment"> + app:startDestination="@id/screenFragment"> + android:id="@+id/screenFragment" + android:name="com.pythonnative.android_template.ScreenFragment" + android:label="ScreenFragment"> drain_ios_scheduled_renders failed: \(error)") @@ -38,12 +38,12 @@ public func pn_schedule_render_drain() { class ViewController: UIViewController { // Ensure Python.framework is configured only once per process private static var hasInitializedPython: Bool = false - // Optional keys for dynamic page navigation - @objc dynamic var requestedPagePath: String? = nil - @objc dynamic var requestedPageArgsJSON: String? = nil + // Optional keys for dynamic screen navigation + @objc dynamic var requestedScreenPath: String? = nil + @objc dynamic var requestedScreenArgsJSON: String? = nil private var pythonReady: Bool = false #if canImport(PythonKit) - private var page: PythonObject? = nil + private var screen: PythonObject? = nil #endif private var hotReloadTimer: Timer? = nil @@ -92,7 +92,7 @@ class ViewController: UIViewController { } let sys = Python.import("sys") if firstInit { - // One concise bootstrap line per process; per-page detail is left + // One concise bootstrap line per process; per-screen detail is left // to Python-side print() statements streamed via pn run ios. let shortVersion = "\(sys.version)".split(separator: "\n").first.map(String.init) ?? "\(sys.version)" NSLog("[PN] Python \(shortVersion) initialized") @@ -114,24 +114,24 @@ class ViewController: UIViewController { // convention is "import the module and grab its top-level // `App` attribute", so the default is just the module path // "app.main". Push navigation overrides this via - // `requestedPagePath`, which may also be a dotted-attribute + // `requestedScreenPath`, which may also be a dotted-attribute // path like "app.main.RootScreen". - let pagePath: String = requestedPagePath ?? "app.main" + let screenPath: String = requestedScreenPath ?? "app.main" do { - let pnPage = try Python.attemptImport("pythonnative.page") + let pnScreen = try Python.attemptImport("pythonnative.screen") let ptr = Unmanaged.passUnretained(self).toOpaque() let addr = UInt(bitPattern: ptr) - let argsJson: PythonObject = (requestedPageArgsJSON != nil) - ? PythonObject(requestedPageArgsJSON!) + let argsJson: PythonObject = (requestedScreenArgsJSON != nil) + ? PythonObject(requestedScreenArgsJSON!) : Python.None - let page = try pnPage.create_page.throwing.dynamicallyCall( - withArguments: [pagePath, addr, argsJson] + let screen = try pnScreen.create_screen.throwing.dynamicallyCall( + withArguments: [screenPath, addr, argsJson] ) - self.page = page + self.screen = screen let devRoot = "\(NSHomeDirectory())/Documents/pythonnative_dev" let manifestPath = "\(devRoot)/reload.json" - _ = try page.enable_hot_reload.throwing.dynamicallyCall(withArguments: [manifestPath, devRoot]) - _ = try page.on_create.throwing.dynamicallyCall(withArguments: []) + _ = try screen.enable_hot_reload.throwing.dynamicallyCall(withArguments: [manifestPath, devRoot]) + _ = try screen.on_create.throwing.dynamicallyCall(withArguments: []) startHotReloadPolling() return } catch { @@ -154,7 +154,7 @@ class ViewController: UIViewController { if pythonReady { let ptr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptr, "on_start"]) } catch {} } @@ -171,7 +171,7 @@ class ViewController: UIViewController { if pythonReady { let ptr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptr, "on_layout"]) } catch { NSLog("[PN] swift.viewDidLayoutSubviews -> on_layout failed: \(error)") @@ -186,7 +186,7 @@ class ViewController: UIViewController { if pythonReady { let ptrAddr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptrAddr, "on_resume"]) } catch { NSLog("[PN] swift.viewDidAppear -> on_resume failed: \(error)") @@ -201,7 +201,7 @@ class ViewController: UIViewController { if pythonReady { let ptr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptr, "on_pause"]) } catch {} } @@ -214,7 +214,7 @@ class ViewController: UIViewController { if pythonReady { let ptr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptr, "on_stop"]) } catch {} } @@ -227,7 +227,7 @@ class ViewController: UIViewController { if pythonReady { let ptr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptr, "on_save_instance_state"]) } catch {} } @@ -240,7 +240,7 @@ class ViewController: UIViewController { if pythonReady { let ptr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptr, "on_restore_instance_state"]) } catch {} } @@ -253,7 +253,7 @@ class ViewController: UIViewController { if pythonReady { let ptr = UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()) do { - let pn = try Python.attemptImport("pythonnative.page") + let pn = try Python.attemptImport("pythonnative.screen") _ = try pn.forward_lifecycle.throwing.dynamicallyCall(withArguments: [ptr, "on_destroy"]) } catch {} } @@ -264,9 +264,9 @@ class ViewController: UIViewController { #if canImport(PythonKit) hotReloadTimer?.invalidate() hotReloadTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - guard let page = self?.page else { return } + guard let screen = self?.screen else { return } do { - _ = try page.hot_reload_tick.throwing.dynamicallyCall(withArguments: []) + _ = try screen.hot_reload_tick.throwing.dynamicallyCall(withArguments: []) } catch { NSLog("[PN] hot_reload_tick failed: \(error)") } diff --git a/src/pythonnative/utils.py b/src/pythonnative/utils.py index e53bdea..ef9416a 100644 --- a/src/pythonnative/utils.py +++ b/src/pythonnative/utils.py @@ -7,7 +7,7 @@ [`IS_IOS`][pythonnative.utils.IS_IOS] are read. The Android variants also expose a small global registry for the -current `Activity` / `Context` and the page's fragment container view; +current `Activity` / `Context` and the screen's fragment container view; both are populated by the bundled Android template before any PythonNative code runs. @@ -140,7 +140,7 @@ def set_android_fragment_container(container_view: Any) -> None: Args: container_view: A Java `android.view.ViewGroup` that holds the - page's view tree. + screen's view tree. """ global _android_fragment_container _android_fragment_container = container_view @@ -160,7 +160,7 @@ def get_android_context() -> Any: raise RuntimeError("get_android_context() called on non-Android platform") if _android_context is None: raise RuntimeError( - "Android context not set. Ensure Page is initialized from an Activity before constructing views." + "Android context not set. Ensure the screen host is initialized from an Activity before constructing views." ) return _android_context @@ -173,12 +173,12 @@ def get_android_fragment_container() -> Any: Raises: RuntimeError: If called on a non-Android platform, or before - `PageFragment` has registered its container. + `ScreenFragment` has registered its container. """ if not IS_ANDROID: raise RuntimeError("get_android_fragment_container() called on non-Android platform") if _android_fragment_container is None: raise RuntimeError( - "Android fragment container not set. Ensure PageFragment has been created before set_root_view." + "Android fragment container not set. Ensure ScreenFragment has been created before set_root_view." ) return _android_fragment_container diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bab151..bc3be94 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -95,7 +95,7 @@ def test_cli_run_prepare_only_android_and_ios() -> None: "com", "pythonnative", "android_template", - "PageFragment.kt", + "ScreenFragment.kt", ) assert os.path.isfile(page_fragment) virtual_list_helper = os.path.join( diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 1eddbeb..93627bd 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -209,7 +209,7 @@ def counter() -> Element: backend = MockBackend() rec = Reconciler(backend) re_rendered: list = [] - rec._page_re_render = lambda: re_rendered.append(1) + rec._screen_re_render = lambda: re_rendered.append(1) root = rec.mount(counter()) assert root.props["text"] == "0" @@ -617,7 +617,7 @@ def counter() -> Element: backend = MockBackend() rec = Reconciler(backend) re_rendered: list = [] - rec._page_re_render = lambda: re_rendered.append(1) + rec._screen_re_render = lambda: re_rendered.append(1) root = rec.mount(counter()) assert root.props["text"] == "0" diff --git a/tests/test_hot_reload.py b/tests/test_hot_reload.py index f9a83f0..434b6bb 100644 --- a/tests/test_hot_reload.py +++ b/tests/test_hot_reload.py @@ -213,7 +213,7 @@ def test_refresh_in_place_swaps_components_and_preserves_state( backend = _MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None root = rec.mount(module.Counter()) @@ -271,7 +271,7 @@ def test_refresh_in_place_returns_false_for_unreloaded_modules() -> None: backend = _MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None rec.mount(Element("Text", {"text": "static"}, [])) refreshed = ModuleReloader.refresh_in_place(rec, ["some.other.module"]) @@ -296,7 +296,7 @@ def inner() -> Element: inner = make_nested() backend = _MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None rec.mount(Element(inner, {}, [])) mapping = ModuleReloader.build_replacement_map(rec, [inner.__module__]) diff --git a/tests/test_metric_hooks.py b/tests/test_metric_hooks.py index 3f7cb9d..a0e9976 100644 --- a/tests/test_metric_hooks.py +++ b/tests/test_metric_hooks.py @@ -91,7 +91,7 @@ def comp() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: rec.reconcile(comp()) + rec._screen_re_render = lambda: rec.reconcile(comp()) rec.mount(comp()) initial_render_count = len(rendered) @@ -125,7 +125,7 @@ def comp() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: rec.reconcile(comp()) + rec._screen_re_render = lambda: rec.reconcile(comp()) rec.mount(comp()) before = len(rendered) @@ -158,7 +158,7 @@ def comp() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: rec.reconcile(comp()) + rec._screen_re_render = lambda: rec.reconcile(comp()) rec.mount(comp()) before = len(rendered) diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 7396da9..71d0d07 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -215,7 +215,7 @@ def test_declarative_handle_navigate_unknown_raises() -> None: class _FakeHost: - """Stub `_AppHost` used to assert native-push delegation.""" + """Stub `_ScreenHost` used to assert native-push delegation.""" def __init__(self, component_path: str = "app.demo.App") -> None: self._component_path = component_path @@ -262,8 +262,8 @@ def set_stack(val: Any) -> None: handle.navigate("Detail", {"id": 7}) assert len(host.pushed) == 1 - page_path, push_args = host.pushed[0] - assert page_path == "app.demo.App" + screen_path, push_args = host.pushed[0] + assert screen_path == "app.demo.App" assert push_args["__pn_initial_route__"] == "Detail" assert push_args["__pn_initial_params__"] == {"id": 7} assert set_stack_calls == [] # in-Python stack is NOT touched @@ -454,7 +454,7 @@ def DetailScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Stack.Navigator( Stack.Screen("Home", component=HomeScreen), @@ -489,7 +489,7 @@ def DetailScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Stack.Navigator( Stack.Screen("Home", component=HomeScreen), @@ -522,7 +522,7 @@ def HomeScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) rec.mount(el) @@ -538,7 +538,7 @@ def test_stack_navigator_empty_screens() -> None: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Stack.Navigator() root = rec.mount(el) @@ -569,7 +569,7 @@ def SettingsScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Tab.Navigator( Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}), @@ -602,7 +602,7 @@ def ScreenB() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Tab.Navigator( Tab.Screen("TabA", component=ScreenA, options={"title": "Tab A"}), @@ -634,7 +634,7 @@ def test_tab_navigator_empty_screens() -> None: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Tab.Navigator() root = rec.mount(el) @@ -665,7 +665,7 @@ def SettingsScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Drawer.Navigator( Drawer.Screen("Home", component=HomeScreen), @@ -690,7 +690,7 @@ def test_drawer_navigator_empty_screens() -> None: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Drawer.Navigator() root = rec.mount(el) @@ -789,7 +789,7 @@ def DetailScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) renders: list = [] - rec._page_re_render = lambda: renders.append(1) + rec._screen_re_render = lambda: renders.append(1) el = Stack.Navigator( Stack.Screen("Home", component=HomeScreen), @@ -813,7 +813,7 @@ def HomeScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = NavigationContainer(Stack.Navigator(Stack.Screen("Home", component=HomeScreen))) root = rec.mount(el) @@ -930,7 +930,7 @@ def InnerStack() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Tab.Navigator( Tab.Screen("TabA", component=InnerStack), @@ -1005,7 +1005,7 @@ def DetailScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Stack.Navigator( Stack.Screen("Home", component=HomeScreen), @@ -1039,7 +1039,7 @@ def HomeScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen)) from pythonnative.hooks import Provider as _Provider @@ -1090,7 +1090,7 @@ def HomeScreen() -> Element: backend = MockBackend() rec = Reconciler(backend) - rec._page_re_render = lambda: None + rec._screen_re_render = lambda: None el = Stack.Navigator(Stack.Screen("Home", component=HomeScreen, options={"title": "Hello"})) from pythonnative.hooks import Provider as _Provider diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 838d75b..58e8222 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -643,7 +643,7 @@ def test_layout_pass_positions_flex_children_in_row() -> None: ], ) root = rec.mount(el) - # The root view's frame is owned by the page host (e.g., on iOS the root + # The root view's frame is owned by the screen host (e.g., on iOS the root # is positioned below the top safe-area inset by ``_sync_root_frame``); # the layout engine intentionally leaves it untouched. Children are # positioned relative to the root's local origin. diff --git a/tests/test_page.py b/tests/test_screen.py similarity index 73% rename from tests/test_page.py rename to tests/test_screen.py index 44ecb30..c4baede 100644 --- a/tests/test_page.py +++ b/tests/test_screen.py @@ -1,4 +1,4 @@ -"""Tests for page-host lifecycle behavior.""" +"""Tests for screen-host lifecycle behavior.""" import os import sys @@ -9,18 +9,18 @@ from pythonnative.native_views import NativeViewRegistry from pythonnative.native_views.base import ViewHandler -from pythonnative.page import create_page +from pythonnative.screen import create_screen class StubView: - """Small native-view stand-in used by page-host tests.""" + """Small native-view stand-in used by screen-host tests.""" def __init__(self, props: Dict[str, Any]) -> None: self.props = dict(props) class TextHandler(ViewHandler): - """Minimal text handler for mounting page roots on desktop.""" + """Minimal text handler for mounting screen roots on desktop.""" def create(self, props: Dict[str, Any]) -> StubView: return StubView(props) @@ -38,7 +38,7 @@ def _write_screen(path: Path, text: str) -> None: ) -def test_page_reload_reimports_root_component( +def test_screen_reload_reimports_root_component( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -58,7 +58,7 @@ def test_page_reload_reimports_root_component( monkeypatch.setattr(native_views, "_registry", registry) - host: Any = create_page("reload_app.screen.MainPage") + host: Any = create_screen("reload_app.screen.MainPage") host.on_create() assert host._root_native_view.props["text"] == "before" @@ -68,11 +68,11 @@ def test_page_reload_reimports_root_component( assert host._root_native_view.props["text"] == "after" -def test_app_host_exposes_on_layout_lifecycle_hook(monkeypatch: pytest.MonkeyPatch) -> None: +def test_screen_host_exposes_on_layout_lifecycle_hook(monkeypatch: pytest.MonkeyPatch) -> None: """Regression: every host class must accept an ``on_layout`` callback. The iOS template forwards ``viewDidLayoutSubviews`` as - ``on_layout`` so the page host can re-push the safe-area-aware + ``on_layout`` so the screen host can re-push the safe-area-aware viewport size; missing the method on the desktop or Android branches would raise ``AttributeError`` at runtime. """ @@ -87,11 +87,11 @@ def test_app_host_exposes_on_layout_lifecycle_hook(monkeypatch: pytest.MonkeyPat def _root() -> Element: return Element("Text", {"text": "hi"}, []) - import pythonnative.page as page_mod + import pythonnative.screen as screen_mod - monkeypatch.setattr(page_mod, "_import_component", lambda path: _root) + monkeypatch.setattr(screen_mod, "_import_component", lambda path: _root) - host: Any = create_page("dummy.path.Root") + host: Any = create_screen("dummy.path.Root") host.on_create() # Should be callable and idempotent on every host class. host.on_layout() @@ -109,19 +109,19 @@ def test_objc_addr_handles_bytes_pointer() -> None: """Regression: rubicon-objc 0.5.x exposes ``ptr`` as raw bytes. Calling ``int(bytes_object)`` raises ``ValueError`` ("invalid - literal for int() with base 10"), so the page-host registry must - decode the address with ``int.from_bytes``. Without this, the iOS - page host failed to register itself and every + literal for int() with base 10"), so the screen-host registry + must decode the address with ``int.from_bytes``. Without this, + the iOS screen host failed to register itself and every ``forward_lifecycle`` call (``on_layout`` / ``on_resume`` / ``on_pause``) silently dropped on the floor — which was exactly the bug surfaced by the missing ``[PN] on_layout`` log lines on iPhone 17 Pro. """ - import pythonnative.page as page_mod + import pythonnative.screen as screen_mod - objc_addr = getattr(page_mod, "_objc_addr", None) + objc_addr = getattr(screen_mod, "_objc_addr", None) if objc_addr is None: - pytest.skip("page module did not export _objc_addr (Android branch)") + pytest.skip("screen module did not export _objc_addr (Android branch)") bytes_le = (4_317_709_568).to_bytes(8, byteorder="little", signed=False) assert objc_addr(_StubObjC(bytes_le)) == 4_317_709_568 @@ -136,11 +136,11 @@ def test_objc_addr_handles_int_and_c_void_p_like_pointer() -> None: the address. Both paths must funnel to the same numeric key so a library upgrade does not silently break lifecycle dispatch. """ - import pythonnative.page as page_mod + import pythonnative.screen as screen_mod - objc_addr = getattr(page_mod, "_objc_addr", None) + objc_addr = getattr(screen_mod, "_objc_addr", None) if objc_addr is None: - pytest.skip("page module did not export _objc_addr (Android branch)") + pytest.skip("screen module did not export _objc_addr (Android branch)") assert objc_addr(_StubObjC(0xDEADBEEF)) == 0xDEADBEEF @@ -150,23 +150,23 @@ class _CVoidPLike: assert objc_addr(_StubObjC(_CVoidPLike())) == 0xCAFEF00D -def test_ios_register_page_makes_forward_lifecycle_succeed() -> None: +def test_ios_register_screen_makes_forward_lifecycle_succeed() -> None: """End-to-end: a registered host receives the matching Swift event. Swift hands ``forward_lifecycle`` an ``UInt`` (Python ``int``) of - the ``UIViewController`` address. The page module must key its + the ``UIViewController`` address. The screen module must key its registry under the same numeric address, regardless of whether rubicon-objc gave it back as bytes, an int, or a ``c_void_p``. This test pins down that contract so the iOS lifecycle callbacks keep firing after dependency upgrades. """ - import pythonnative.page as page_mod + import pythonnative.screen as screen_mod - register = getattr(page_mod, "_ios_register_page", None) - forward = getattr(page_mod, "forward_lifecycle", None) - registry = getattr(page_mod, "_IOS_PAGE_REGISTRY", None) + register = getattr(screen_mod, "_ios_register_screen", None) + forward = getattr(screen_mod, "forward_lifecycle", None) + registry = getattr(screen_mod, "_IOS_SCREEN_REGISTRY", None) if register is None or forward is None or registry is None: - pytest.skip("page module is on the Android branch; no iOS hooks") + pytest.skip("screen module is on the Android branch; no iOS hooks") addr_int = 4_317_709_568 bytes_le = addr_int.to_bytes(8, byteorder="little", signed=False) @@ -182,7 +182,7 @@ def on_layout(self) -> None: registry.clear() try: register(_StubObjC(bytes_le), host) - assert addr_int in registry, "register_page failed to decode bytes ptr" + assert addr_int in registry, "register_screen failed to decode bytes ptr" forward(addr_int, "on_layout") assert host.received == ["on_layout"] finally: diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 23c865a..a37bfd9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -35,7 +35,7 @@ def test_public_api_names() -> None: "View", "WebView", # Core - "create_page", + "create_screen", # Hooks "batch_updates", "component", From 95ab249328cef105af93cded2b9ddb678463a915 Mon Sep 17 00:00:00 2001 From: Owen Carey <37121709+owenthcarey@users.noreply.github.com> Date: Mon, 11 May 2026 19:31:49 -0700 Subject: [PATCH 6/6] docs(repo): refresh README --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 73938aa..8b8d4a5 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,6 @@ def App(): ) ``` -Save this as `app/main.py`. The bundled iOS and Android templates import `app.main` and look up its top-level `App` function (the convention is "name your root component `App`"). See [Getting Started](https://docs.pythonnative.com/getting-started/) for the full `Stack.Navigator` scaffold that `pn init` produces. - ## Documentation Visit [docs.pythonnative.com](https://docs.pythonnative.com/) for the full documentation, including getting started guides, platform-specific instructions for Android and iOS, API reference, and working examples.