Skip to content

Commit 042f411

Browse files
authored
feat(screen,hot_reload)!: add native stack navigation and Fast Refresh (#1)
1 parent 7d9bf90 commit 042f411

58 files changed

Lines changed: 2095 additions & 785 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ Recommended scopes (choose the smallest, most accurate unit; prefer module/direc
110110
- `native_modules` – native API modules for device capabilities (`native_modules/`)
111111
- `native_views` – platform-specific native view creation and updates (`native_views/`)
112112
- `package``src/pythonnative/__init__.py` exports and package boundary
113-
- `page` – Page component, lifecycle, and reactive state (`page.py`)
114113
- `reconciler` – virtual view tree diffing and reconciliation (`reconciler.py`)
114+
- `screen` – screen host, native lifecycle bridge, and render scheduling (`screen.py`)
115115
- `style` – StyleSheet and theming (`style.py`)
116116
- `utils` – shared utilities (`utils.py`)
117117

@@ -154,7 +154,7 @@ Breaking changes:
154154
- Use `!` after the type/scope or a `BREAKING CHANGE:` footer.
155155

156156
```text
157-
feat(core)!: rename Page.set_root_view to set_root
157+
feat(screen)!: rename create_page to create_screen
158158
159159
BREAKING CHANGE: API renamed; update app code and templates.
160160
```
@@ -288,7 +288,7 @@ pn run ios
288288
maestro --platform ios test ../../tests/e2e/ios.yaml
289289
```
290290

291-
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.
291+
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.
292292

293293
### CI
294294

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ PythonNative is a cross-platform toolkit for building native Android and iOS app
3737
- **Virtual view tree + reconciler:** Element trees are diffed and patched with minimal native mutations, similar to React's reconciliation.
3838
- **Direct native bindings:** Python calls platform APIs directly through Chaquopy and rubicon-objc, with no JavaScript bridge.
3939
- **CLI scaffolding:** `pn init` creates a ready-to-run project; `pn run android` and `pn run ios` build and launch your app.
40-
- **Navigation:** Push and pop screens with argument passing via the `use_navigation()` hook.
40+
- **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.
41+
- **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.
4142
- **Bundled templates:** Android Gradle and iOS Xcode templates are included, so scaffolding requires no network access.
4243

4344
## Quick Start
@@ -55,7 +56,7 @@ import pythonnative as pn
5556

5657

5758
@pn.component
58-
def MainPage():
59+
def App():
5960
count, set_count = pn.use_state(0)
6061
return pn.Column(
6162
pn.Text(f"Count: {count}", style={"font_size": 24}),

docs/api/hooks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ slot across renders.
1616

1717
These hooks subscribe to values published by
1818
`pythonnative.platform_metrics` and re-render the component when they
19-
change. The page host is the only code that updates the underlying
19+
change. The screen host is the only code that updates the underlying
2020
values; user code consumes them.
2121

2222
- [`use_window_dimensions`][pythonnative.use_window_dimensions] — viewport size.

docs/api/pythonnative.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ PythonNative re-exports a small public surface from
55
in this overview; deeper internals (`reconciler`, `native_views`,
66
`page`) are documented for contributors and integrators.
77

8+
## Entry point
9+
10+
Your app module defines a top-level component named `App`:
11+
12+
```python
13+
import pythonnative as pn
14+
15+
@pn.component
16+
def App():
17+
return pn.NavigationContainer(...)
18+
```
19+
20+
The bundled Android `ScreenFragment` and iOS `ViewController` load
21+
your app by **module path** (`"app.main"`) and look up the
22+
module's top-level `App` attribute. There is no registration step
23+
or imperative bootstrap call. If you need to expose a
24+
differently-named root component, configure the templates to load
25+
an explicit dotted path like `"app.main.RootScreen"` instead.
26+
827
::: pythonnative
928
options:
1029
show_root_heading: false
@@ -25,7 +44,7 @@ The reference is split per module so each page stays scannable:
2544
| 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] |
2645
| Styling | [Style](style.md) | [`StyleSheet`][pythonnative.StyleSheet], [`ThemeContext`][pythonnative.style.ThemeContext] |
2746
| Element descriptor | [Element](element.md) | [`Element`][pythonnative.Element] |
28-
| Page host | [Page](page.md) | [`create_page`][pythonnative.create_page] |
47+
| Screen host | [Screen](screen.md) | [`create_screen`][pythonnative.create_screen] |
2948
| Reconciler | [Reconciler](reconciler.md) | [`Reconciler`][pythonnative.reconciler.Reconciler] |
3049
| Native modules | [Native modules](native_modules.md) | `Camera`, `Location`, `FileSystem`, `Notifications` |
3150
| Native views | [Native views](native_views.md) | [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry], [`ViewHandler`][pythonnative.native_views.base.ViewHandler] |
Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
# Page
1+
# Screen
22

3-
The page host owns a [`Reconciler`][pythonnative.reconciler.Reconciler],
3+
The screen host owns a [`Reconciler`][pythonnative.reconciler.Reconciler],
44
schedules re-renders, and forwards platform lifecycle hooks (resume,
55
pause, destroy) to navigators and effects. The bundled
66
Android (`MainActivity`) and iOS (`ViewController`) templates create a
7-
host via [`create_page`][pythonnative.create_page] and never need to be
8-
edited by app code.
7+
host via [`create_screen`][pythonnative.create_screen] and never need to
8+
be edited by app code.
99

10-
::: pythonnative.page
10+
::: pythonnative.screen
1111
options:
1212
show_root_heading: false
1313
show_root_toc_entry: false
@@ -17,5 +17,5 @@ edited by app code.
1717
## Next steps
1818

1919
- Understand the render queue in [Lifecycle](../concepts/lifecycle.md).
20-
- See how navigation owns its own pages in
20+
- See how navigation hosts each screen in
2121
[`NavigationContainer`][pythonnative.NavigationContainer].

docs/concepts/architecture.md

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,16 @@ platform APIs synchronously from Python.
4747
the JNI bridge.
4848
9. **Thin native bootstrap.** The host app remains native (Android
4949
`Activity` or iOS `UIViewController`). It calls
50-
[`create_page`][pythonnative.create_page] internally to bootstrap
50+
[`create_screen`][pythonnative.create_screen] internally to bootstrap
5151
your Python component, and the reconciler drives the UI from
5252
there.
53+
10. **`App` entry point.** The user's app module (`app/main.py`)
54+
defines a top-level component named `App`. Native templates
55+
import that module by path (`"app.main"`) and look up its `App`
56+
attribute, so users never write a separate registration step.
57+
Components with other names can still be loaded by passing an
58+
explicit dotted path like `"app.main.RootScreen"` to the
59+
template.
5360

5461
## How it works
5562

@@ -113,7 +120,7 @@ Each component is a Python function that:
113120
- Has its own hook state per call site (each instance gets its own
114121
slot table).
115122

116-
The entry point [`create_page`][pythonnative.create_page] is called
123+
The entry point [`create_screen`][pythonnative.create_screen] is called
117124
internally by the bundled native templates to bootstrap your root
118125
component. App code does not call it directly.
119126

@@ -252,7 +259,7 @@ See [Mental model](mental-model.md) for a wider comparison table.
252259
## iOS flow (rubicon-objc)
253260

254261
- The iOS template (Swift plus PythonKit) boots Python and calls
255-
[`create_page`][pythonnative.create_page] internally with the
262+
[`create_screen`][pythonnative.create_screen] internally with the
256263
current `UIViewController` pointer.
257264
- The reconciler creates UIKit views and attaches them to the
258265
controller's view.
@@ -263,17 +270,31 @@ See [Mental model](mental-model.md) for a wider comparison table.
263270

264271
- The Android template (Kotlin plus Chaquopy) initializes Python in
265272
`MainActivity` and passes the `Activity` to Python.
266-
- `PageFragment` calls [`create_page`][pythonnative.create_page]
273+
- `ScreenFragment` calls [`create_screen`][pythonnative.create_screen]
267274
internally, which renders the root component and attaches views to
268275
the fragment container.
269276
- State changes trigger re-render; the reconciler patches Android
270277
views in place.
271278

272-
## Hot reload
279+
## Hot reload (Fast Refresh)
273280

274281
During development, `pn run --hot-reload` watches `app/` for file
275282
changes and pushes updated Python files to the running app, enabling
276-
near-instant UI updates without full rebuilds. See
283+
near-instant UI updates without full rebuilds.
284+
285+
PythonNative uses a **Fast Refresh** strategy:
286+
287+
1. Reload the changed module(s) on the device.
288+
2. For every active screen host, walk the VNode tree and collect every
289+
component function defined in a reloaded module.
290+
3. Match each one to its replacement by `__module__` +
291+
`__qualname__` and rewrite `Element.type` in place.
292+
4. Trigger one reconcile pass. Because the VNode and its `HookState`
293+
are reused, component state (`use_state`, `use_reducer`, refs) is
294+
preserved across the edit.
295+
296+
If Fast Refresh can't produce a clean swap, the host falls back to a
297+
**full remount** of its root component. See
277298
[Hot reload guide](../guides/hot-reload.md).
278299

279300
## Native API modules
@@ -293,33 +314,40 @@ See [Native modules guide](../guides/native-modules.md).
293314

294315
## Navigation
295316

296-
PythonNative provides two navigation approaches:
297-
298-
- **Declarative navigators** (recommended):
299-
[`NavigationContainer`][pythonnative.NavigationContainer] with
300-
[`create_stack_navigator`][pythonnative.create_stack_navigator],
301-
[`create_tab_navigator`][pythonnative.create_tab_navigator], and
302-
[`create_drawer_navigator`][pythonnative.create_drawer_navigator].
303-
Navigation state is managed in Python as component state, and
304-
navigators are composable; you can nest tabs inside stacks, and so
305-
on.
306-
- **Page-level navigation**:
307-
[`use_navigation`][pythonnative.use_navigation] returns a
308-
navigation handle with `.navigate()`, `.go_back()`, and
309-
`.get_params()`, delegating to native platform navigation when
310-
running on device.
311-
312-
Both approaches are supported. The declarative system uses the
313-
existing reconciler pipeline; navigators are function components that
314-
render the active screen via `use_state`, and navigation context is
315-
provided via [`Provider`][pythonnative.Provider].
316-
317-
See the [Navigation guide](../guides/navigation.md) for full details.
317+
PythonNative navigation is **declarative** and **native-backed**:
318+
319+
- The user describes their app as a tree of navigators
320+
([`create_stack_navigator`][pythonnative.create_stack_navigator],
321+
[`create_tab_navigator`][pythonnative.create_tab_navigator],
322+
[`create_drawer_navigator`][pythonnative.create_drawer_navigator])
323+
wrapped in
324+
[`NavigationContainer`][pythonnative.NavigationContainer], and
325+
names the root component `App` so the native templates can find
326+
it.
327+
- The outermost `Stack.Navigator` delegates `navigate(...)`,
328+
`go_back()`, and `reset(...)` to the platform's native navigation
329+
controller — `UINavigationController` on iOS and the AndroidX
330+
Navigation Component on Android. Nested navigators (tabs inside a
331+
stack, stacks inside tabs) stay in Python and reuse the existing
332+
reconciler.
333+
- Each pushed native screen is a fresh host with its own reconciler
334+
and `_ScreenHost`. Initial routes are forwarded via host arguments
335+
(`__pn_initial_route__` / `__pn_initial_params__`), so a pushed
336+
screen knows which `Stack.Screen` to render on its first frame.
337+
- Inside any screen, [`use_navigation`][pythonnative.use_navigation]
338+
returns a `NavigationHandle`; [`use_route`][pythonnative.use_route]
339+
returns the current route name and params. Both are the same
340+
hooks regardless of whether the active navigator is native-backed
341+
or pure-Python.
342+
343+
See the [Navigation guide](../guides/navigation.md) for the full
344+
walkthrough, including how `options={"title": ...}` flows into the
345+
native navigation bar.
318346

319347
- iOS: one host `UIViewController` class, many instances pushed on a
320348
`UINavigationController`.
321349
- Android: single host `Activity` with a `NavHostFragment` and a
322-
stack of generic `PageFragment`s driven by a navigation graph.
350+
stack of generic `ScreenFragment`s driven by a navigation graph.
323351

324352
## Next steps
325353

docs/concepts/components.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,15 +170,16 @@ element tree:
170170

171171
```python
172172
@pn.component
173-
def MainPage():
173+
def App():
174174
name, set_name = pn.use_state("World")
175175
return pn.Text(f"Hello, {name}!", style={"font_size": 24})
176176
```
177177

178-
The entry point [`create_page`][pythonnative.create_page] is called
178+
The entry point [`create_screen`][pythonnative.create_screen] is called
179179
internally by native templates to bootstrap your root component. You
180-
don't call it directly; just export your component and configure the
181-
entry point in `pythonnative.json`.
180+
don't call it directly: name your top-level component `App` (so the
181+
templates can find it by convention) and `pythonnative.json` points
182+
at the module that defines it.
182183

183184
## State and re-rendering
184185

@@ -221,7 +222,7 @@ def Counter(label: str = "Count", initial: int = 0):
221222

222223

223224
@pn.component
224-
def MainPage():
225+
def App():
225226
return pn.Column(
226227
Counter(label="Apples", initial=0),
227228
Counter(label="Oranges", initial=5),

docs/concepts/lifecycle.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ fold into it, and where you can hook in.
99

1010
A render pass is triggered by:
1111

12-
- Initial mount via [`create_page`][pythonnative.create_page].
12+
- Initial mount via [`create_screen`][pythonnative.create_screen].
1313
- A setter from [`use_state`][pythonnative.use_state] or a `dispatch`
1414
from [`use_reducer`][pythonnative.use_reducer].
1515
- A navigation event (`navigate`, `go_back`, `replace`).
@@ -29,7 +29,7 @@ The phases:
2929
first; new [`use_effect`][pythonnative.use_effect] callbacks run
3030
after, in depth-first order so children commit before parents.
3131
4. **Drain**. If any effect set state, another render pass is queued
32-
immediately. The page host caps the loop to prevent runaway
32+
immediately. The screen host caps the loop to prevent runaway
3333
re-renders.
3434

3535
```text
@@ -86,7 +86,7 @@ When the user navigates away:
8686

8787
## App lifecycle (Android / iOS)
8888

89-
The page host forwards the platform's app-level lifecycle to navigators
89+
The screen host forwards the platform's app-level lifecycle to navigators
9090
and effects:
9191

9292
- **Resume / `viewWillAppear`**: the active screen's `use_focus_effect`

docs/examples.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ project scaffolded with `pn init`.
1919
```bash
2020
pn init my-app
2121
cd my-app
22-
# Edit app/main_page.py and paste any of the snippets below.
22+
# Edit app/main.py and paste any of the snippets below.
2323
pn run android # or: pn run ios
2424
```
2525

26-
The `app/main_page.py` that `pn init` writes already returns a small
26+
The `app/main.py` that `pn init` writes already returns a small
2727
counter; replace it with one of the snippets to try a different
2828
example.
2929

docs/examples/hello-world.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ The smallest possible PythonNative app. You'll learn how to:
99

1010
## The code
1111

12-
Save this as `app/main_page.py`:
12+
Save this as `app/main.py`:
1313

1414
```python
1515
import pythonnative as pn
1616

1717

1818
@pn.component
19-
def MainPage():
19+
def App():
2020
count, set_count = pn.use_state(0)
2121
return pn.Column(
2222
pn.Text(f"Count: {count}", style={"font_size": 24, "bold": True}),
@@ -27,11 +27,11 @@ def MainPage():
2727

2828
## What's happening
2929

30-
- `@pn.component` registers `MainPage` as a function component. Hooks
30+
- `@pn.component` registers `App` as a function component. Hooks
3131
(like `use_state`) work because the decorator establishes a hook
3232
context for each call.
3333
- `pn.use_state(0)` returns `(value, setter)`. The setter triggers a
34-
re-render scheduled by the page host.
34+
re-render scheduled by the screen host.
3535
- `pn.Column(*children, style=...)` returns a vertical container
3636
element. Both the children and the style are read on every render;
3737
the reconciler diffs them against the previous render and updates

0 commit comments

Comments
 (0)