pythonnative 0.32.0__py3-none-any.whl → 0.34.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. pythonnative/__init__.py +27 -15
  2. pythonnative/animated.py +7 -13
  3. pythonnative/cli/pn.py +1 -2
  4. pythonnative/component.py +255 -0
  5. pythonnative/components/__init__.py +106 -0
  6. pythonnative/components/_base.py +132 -0
  7. pythonnative/components/controls.py +515 -0
  8. pythonnative/components/layout.py +459 -0
  9. pythonnative/components/lists.py +809 -0
  10. pythonnative/components/media.py +204 -0
  11. pythonnative/components/overlays.py +107 -0
  12. pythonnative/components/pressable.py +252 -0
  13. pythonnative/components/structural.py +167 -0
  14. pythonnative/components/text.py +291 -0
  15. pythonnative/diagnostics.py +1 -1
  16. pythonnative/element.py +108 -29
  17. pythonnative/gestures.py +1 -1
  18. pythonnative/hooks.py +366 -671
  19. pythonnative/hosts/__init__.py +85 -0
  20. pythonnative/hosts/android.py +269 -0
  21. pythonnative/hosts/base.py +665 -0
  22. pythonnative/hosts/desktop.py +107 -0
  23. pythonnative/hosts/ios.py +404 -0
  24. pythonnative/hot_reload.py +16 -24
  25. pythonnative/layout.py +372 -66
  26. pythonnative/native_modules/__init__.py +23 -0
  27. pythonnative/native_modules/net_info.py +5 -1
  28. pythonnative/native_views/__init__.py +1 -1
  29. pythonnative/native_views/android.py +50 -6
  30. pythonnative/native_views/base.py +64 -1
  31. pythonnative/native_views/desktop.py +11 -3
  32. pythonnative/native_views/ios.py +72 -12
  33. pythonnative/navigation/__init__.py +102 -0
  34. pythonnative/navigation/container.py +154 -0
  35. pythonnative/navigation/handle.py +573 -0
  36. pythonnative/navigation/hooks.py +94 -0
  37. pythonnative/navigation/host.py +58 -0
  38. pythonnative/navigation/linking.py +200 -0
  39. pythonnative/navigation/navigators.py +637 -0
  40. pythonnative/navigation/screen.py +149 -0
  41. pythonnative/navigation/state.py +248 -0
  42. pythonnative/net.py +4 -0
  43. pythonnative/platform_metrics.py +1 -1
  44. pythonnative/preview.py +21 -19
  45. pythonnative/project/android.py +7 -1
  46. pythonnative/project/doctor.py +1 -1
  47. pythonnative/project/ios.py +1 -0
  48. pythonnative/project/runtime_assets.py +1 -1
  49. pythonnative/reconciler/__init__.py +29 -0
  50. pythonnative/reconciler/boundaries.py +365 -0
  51. pythonnative/reconciler/children.py +88 -0
  52. pythonnative/reconciler/core.py +1153 -0
  53. pythonnative/reconciler/layout_pass.py +359 -0
  54. pythonnative/reconciler/vnode.py +266 -0
  55. pythonnative/scheduler.py +159 -0
  56. pythonnative/sdk/_components.py +2 -4
  57. pythonnative/style.py +95 -13
  58. pythonnative/suspense.py +8 -12
  59. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +85 -1
  60. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +6 -7
  61. pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
  62. pythonnative/templates/android_template/app/src/main/res/values/strings.xml +3 -1
  63. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +33 -0
  64. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +1 -1
  65. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -10
  66. pythonnative/testing/__init__.py +53 -0
  67. pythonnative/testing/backend.py +276 -0
  68. pythonnative/testing/harness.py +391 -0
  69. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/METADATA +2 -21
  70. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/RECORD +74 -43
  71. pythonnative/components.py +0 -2856
  72. pythonnative/navigation.py +0 -1031
  73. pythonnative/reconciler.py +0 -2298
  74. pythonnative/screen.py +0 -2068
  75. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/WHEEL +0 -0
  76. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/entry_points.txt +0 -0
  77. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/licenses/LICENSE +0 -0
  78. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,149 @@
1
+ """Screen definitions and the typed options a screen accepts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any, Callable, Dict, Literal, Mapping, Optional, TypedDict, Union
7
+
8
+ if sys.version_info >= (3, 11):
9
+ from typing import Unpack
10
+ else: # pragma: no cover
11
+ from typing_extensions import Unpack
12
+
13
+ from ..element import Element
14
+
15
+ __all__ = ["HeaderSlot", "ScreenDef", "ScreenOptions", "Unpack"]
16
+
17
+ HeaderSlot = Union[Element, Callable[[], Optional[Element]], None]
18
+ """An element (or zero-arg factory) rendered into a header slot."""
19
+
20
+
21
+ class ScreenOptions(TypedDict, total=False):
22
+ """Per-screen options accepted by ``Screen(...)`` and ``nav.set_options(...)``.
23
+
24
+ All keys are optional. Navigators ignore keys they don't use; the
25
+ native host applies the header keys it can (see the platform notes
26
+ on each key).
27
+
28
+ Attributes:
29
+ title: Screen title. Stack navigators show it in the native
30
+ navigation bar; tab and drawer navigators use it as the item
31
+ label.
32
+ header_shown: Whether the native navigation bar is visible for
33
+ this screen (default ``True``).
34
+ header_large_title: iOS: use a large title that collapses on
35
+ scroll. Ignored elsewhere.
36
+ header_back_title: iOS: label of the back button shown on the
37
+ *next* screen when it navigates back to this one. Ignored
38
+ elsewhere.
39
+ header_back_visible: Whether the back button is shown
40
+ (default ``True``).
41
+ header_left: Element (or factory) rendered at the leading edge
42
+ of the navigation bar. Rendered by PythonNative into the bar
43
+ on iOS; ignored on Android and desktop today.
44
+ header_right: Element (or factory) rendered at the trailing edge
45
+ of the navigation bar (same platform notes as
46
+ ``header_left``).
47
+ header_tint_color: Color of the bar's buttons and back chevron.
48
+ header_style: Style dict for the bar itself; ``background_color``
49
+ is honored on every platform that draws a bar.
50
+ header_title_style: Style dict for the title label
51
+ (``color``, ``font_size``, ``bold``).
52
+ presentation: ``"card"`` (default) pushes; ``"modal"`` presents
53
+ the screen as a sheet on iOS and as a full-screen push
54
+ elsewhere.
55
+ gesture_enabled: Whether the interactive back gesture (iOS
56
+ swipe) can pop this screen (default ``True``).
57
+ animation: Transition to use when the screen is pushed:
58
+ ``"default"``, ``"none"``, ``"fade"``, ``"slide_from_right"``,
59
+ ``"slide_from_bottom"``. Only ``"none"`` versus animated is
60
+ distinguished on iOS and Android today.
61
+ tab_bar_icon: Native system icon identifier for tab items. A
62
+ string is used on every platform; a dict like
63
+ ``{"ios": "house.fill", "android": "ic_menu_home"}`` selects
64
+ per platform (SF Symbols on iOS, ``android.R.drawable.<name>``
65
+ on Android).
66
+ tab_bar_badge: Badge text or count shown on the tab item.
67
+ tab_bar_label: Label used for the tab item when it should differ
68
+ from ``title``.
69
+ lazy: Tab and drawer navigators only: mount the screen the first
70
+ time it's focused (default ``True``) instead of at
71
+ navigator mount.
72
+ unmount_on_blur: Tab and drawer navigators only: unmount the
73
+ screen when it loses focus instead of keeping it alive
74
+ hidden (default ``False``).
75
+ """
76
+
77
+ title: str
78
+ header_shown: bool
79
+ header_large_title: bool
80
+ header_back_title: str
81
+ header_back_visible: bool
82
+ header_left: HeaderSlot
83
+ header_right: HeaderSlot
84
+ header_tint_color: str
85
+ header_style: Dict[str, Any]
86
+ header_title_style: Dict[str, Any]
87
+ presentation: Literal["card", "modal"]
88
+ gesture_enabled: bool
89
+ animation: Literal["default", "none", "fade", "slide_from_right", "slide_from_bottom"]
90
+ tab_bar_icon: Union[str, Dict[str, str]]
91
+ tab_bar_badge: Union[str, int]
92
+ tab_bar_label: str
93
+ lazy: bool
94
+ unmount_on_blur: bool
95
+
96
+
97
+ class ScreenDef:
98
+ """Configuration for one screen inside a navigator.
99
+
100
+ Created by ``Navigator.Screen(name, component, **options)``.
101
+
102
+ Attributes:
103
+ name: Route name used by ``nav.navigate(name)``.
104
+ component: The ``@component`` rendered when this screen is
105
+ active. Receives no props; read params with
106
+ [`use_route`][pythonnative.use_route].
107
+ options: Static [`ScreenOptions`][pythonnative.ScreenOptions]
108
+ for the screen. May be a callable ``(route) -> options`` to
109
+ derive options from the route's params.
110
+ initial_params: Params merged under any params supplied by
111
+ ``navigate`` when this screen is first shown.
112
+ """
113
+
114
+ __slots__ = ("name", "component", "options", "initial_params")
115
+
116
+ def __init__(
117
+ self,
118
+ name: str,
119
+ component: Callable[[], Any],
120
+ *,
121
+ options: Union[ScreenOptions, Callable[[Any], ScreenOptions], None] = None,
122
+ initial_params: Optional[Mapping[str, Any]] = None,
123
+ **option_kwargs: Unpack[ScreenOptions],
124
+ ) -> None:
125
+ if not name or not isinstance(name, str):
126
+ raise TypeError("Screen name must be a non-empty string")
127
+ if not callable(component):
128
+ raise TypeError(f"Screen {name!r}: component must be a @component, got {component!r}")
129
+ self.name = name
130
+ self.component = component
131
+ if callable(options):
132
+ if option_kwargs:
133
+ raise TypeError("Pass either a callable `options` or keyword options, not both")
134
+ self.options: Union[ScreenOptions, Callable[[Any], ScreenOptions]] = options
135
+ else:
136
+ merged: Dict[str, Any] = dict(options or {})
137
+ merged.update(option_kwargs)
138
+ self.options = merged # type: ignore[assignment]
139
+ self.initial_params: Dict[str, Any] = dict(initial_params or {})
140
+
141
+ def resolve_options(self, route: Any) -> Dict[str, Any]:
142
+ """Return the static options, evaluating a callable ``options`` for ``route``."""
143
+ opts = self.options
144
+ if callable(opts):
145
+ return dict(opts(route) or {})
146
+ return dict(opts)
147
+
148
+ def __repr__(self) -> str:
149
+ return f"Screen({self.name!r})"
@@ -0,0 +1,248 @@
1
+ """Immutable navigation state and the pure operations on it.
2
+
3
+ A navigator's state is a [`NavigationState`][pythonnative.navigation.NavigationState]:
4
+ an ordered tuple of [`Route`][pythonnative.navigation.Route] entries
5
+ plus the index of the active one. Stack navigators treat the tuple as
6
+ a history (the active route is always the last); tab and drawer
7
+ navigators keep one route per screen and move the index.
8
+
9
+ Every operation returns a new state, so navigators can store the state
10
+ in ``use_state`` and diff it like any other value. States serialize to
11
+ plain dicts (``to_dict`` / ``from_dict``) so a native host can hand a
12
+ pushed screen the full history it belongs to.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import itertools
18
+ from typing import Any, Dict, Iterable, Iterator, Mapping, Optional, Sequence, Tuple
19
+
20
+ __all__ = ["NavigationState", "Route"]
21
+
22
+ _route_keys = itertools.count(1)
23
+
24
+
25
+ def _new_key(name: str) -> str:
26
+ return f"{name}-{next(_route_keys)}"
27
+
28
+
29
+ class Route:
30
+ """One entry in a navigator's state.
31
+
32
+ Attributes:
33
+ name: The screen name this route renders.
34
+ params: Parameters passed to the screen (read with
35
+ [`use_route`][pythonnative.use_route]).
36
+ key: Stable identity for this particular visit to the screen,
37
+ unique per process. Two pushes of the same screen have
38
+ different keys, so their component state never mixes.
39
+ state: Seed state for a navigator rendered by this screen
40
+ (set by ``navigate("Tabs", screen="Profile")`` and by deep
41
+ links). ``None`` for ordinary screens.
42
+ """
43
+
44
+ __slots__ = ("name", "params", "key", "state")
45
+
46
+ def __init__(
47
+ self,
48
+ name: str,
49
+ params: Optional[Mapping[str, Any]] = None,
50
+ key: Optional[str] = None,
51
+ state: Optional["NavigationState"] = None,
52
+ ) -> None:
53
+ self.name = name
54
+ self.params: Dict[str, Any] = dict(params or {})
55
+ self.key = key or _new_key(name)
56
+ self.state = state
57
+
58
+ def with_params(self, params: Mapping[str, Any], *, merge: bool = True) -> "Route":
59
+ """Return a copy carrying ``params`` (merged over the current ones by default)."""
60
+ merged = {**self.params, **params} if merge else dict(params)
61
+ return Route(self.name, merged, key=self.key, state=self.state)
62
+
63
+ def with_state(self, state: Optional["NavigationState"]) -> "Route":
64
+ """Return a copy carrying a nested navigator seed ``state``."""
65
+ return Route(self.name, self.params, key=self.key, state=state)
66
+
67
+ def to_dict(self) -> Dict[str, Any]:
68
+ """Serialize to a plain dict (``name``, ``params``, ``key``, and ``state`` when nested)."""
69
+ out: Dict[str, Any] = {"name": self.name, "params": dict(self.params), "key": self.key}
70
+ if self.state is not None:
71
+ out["state"] = self.state.to_dict()
72
+ return out
73
+
74
+ @classmethod
75
+ def from_dict(cls, data: Mapping[str, Any]) -> "Route":
76
+ """Rebuild a route from ``to_dict`` output, keeping its ``key`` so component state carries over."""
77
+ params = data.get("params")
78
+ nested = data.get("state")
79
+ return cls(
80
+ str(data["name"]),
81
+ params if isinstance(params, Mapping) else {},
82
+ key=data.get("key"),
83
+ state=NavigationState.from_dict(nested) if isinstance(nested, Mapping) and nested.get("routes") else None,
84
+ )
85
+
86
+ def __eq__(self, other: object) -> bool:
87
+ if not isinstance(other, Route):
88
+ return NotImplemented
89
+ return (
90
+ self.name == other.name
91
+ and self.params == other.params
92
+ and self.key == other.key
93
+ and self.state == other.state
94
+ )
95
+
96
+ def __hash__(self) -> int:
97
+ return hash(self.key)
98
+
99
+ def __repr__(self) -> str:
100
+ return f"Route({self.name!r}, params={self.params!r}, key={self.key!r})"
101
+
102
+
103
+ class NavigationState:
104
+ """Immutable ordered routes plus the active index.
105
+
106
+ Attributes:
107
+ routes: The routes, oldest first.
108
+ index: Position of the active route in ``routes``.
109
+ """
110
+
111
+ __slots__ = ("routes", "index")
112
+
113
+ def __init__(self, routes: Iterable[Route], index: Optional[int] = None) -> None:
114
+ self.routes: Tuple[Route, ...] = tuple(routes)
115
+ if not self.routes:
116
+ raise ValueError("NavigationState needs at least one route")
117
+ self.index = len(self.routes) - 1 if index is None else index
118
+ if not 0 <= self.index < len(self.routes):
119
+ raise IndexError(f"index {self.index} out of range for {len(self.routes)} route(s)")
120
+
121
+ # ------------------------------------------------------------------
122
+ # Introspection
123
+ # ------------------------------------------------------------------
124
+
125
+ @property
126
+ def current(self) -> Route:
127
+ """The active route."""
128
+ return self.routes[self.index]
129
+
130
+ @property
131
+ def can_go_back(self) -> bool:
132
+ """Whether a route precedes the active one."""
133
+ return self.index > 0
134
+
135
+ def find(self, name: str) -> Optional[int]:
136
+ """Index of the most recent route named ``name``, or ``None``."""
137
+ for i in range(len(self.routes) - 1, -1, -1):
138
+ if self.routes[i].name == name:
139
+ return i
140
+ return None
141
+
142
+ def __len__(self) -> int:
143
+ return len(self.routes)
144
+
145
+ def __iter__(self) -> Iterator[Route]:
146
+ return iter(self.routes)
147
+
148
+ def __eq__(self, other: object) -> bool:
149
+ if not isinstance(other, NavigationState):
150
+ return NotImplemented
151
+ return self.routes == other.routes and self.index == other.index
152
+
153
+ def __repr__(self) -> str:
154
+ names = [r.name for r in self.routes]
155
+ return f"NavigationState({names!r}, index={self.index})"
156
+
157
+ # ------------------------------------------------------------------
158
+ # Stack operations (history semantics)
159
+ # ------------------------------------------------------------------
160
+
161
+ def push(
162
+ self, name: str, params: Optional[Mapping[str, Any]] = None, state: Optional["NavigationState"] = None
163
+ ) -> "NavigationState":
164
+ """Append a new route and make it active (drops any forward entries)."""
165
+ kept = self.routes[: self.index + 1]
166
+ return NavigationState(kept + (Route(name, params, state=state),))
167
+
168
+ def pop(self, count: int = 1) -> "NavigationState":
169
+ """Remove up to ``count`` routes from the end (never below one)."""
170
+ keep = max(1, len(self.routes) - max(0, count))
171
+ return NavigationState(self.routes[:keep])
172
+
173
+ def pop_to_top(self) -> "NavigationState":
174
+ """Keep only the first route."""
175
+ return NavigationState(self.routes[:1])
176
+
177
+ def pop_to(
178
+ self, name: str, params: Optional[Mapping[str, Any]] = None, state: Optional["NavigationState"] = None
179
+ ) -> "NavigationState":
180
+ """Pop back to the most recent route named ``name``, merging ``params`` into it."""
181
+ idx = self.find(name)
182
+ if idx is None:
183
+ raise KeyError(name)
184
+ routes = list(self.routes[: idx + 1])
185
+ if params:
186
+ routes[-1] = routes[-1].with_params(params)
187
+ if state is not None:
188
+ routes[-1] = routes[-1].with_state(state)
189
+ return NavigationState(routes)
190
+
191
+ def replace(
192
+ self, name: str, params: Optional[Mapping[str, Any]] = None, state: Optional["NavigationState"] = None
193
+ ) -> "NavigationState":
194
+ """Swap the active route for a fresh one (new key, so state resets)."""
195
+ routes = list(self.routes)
196
+ routes[self.index] = Route(name, params, state=state)
197
+ return NavigationState(routes, self.index)
198
+
199
+ def navigate(
200
+ self, name: str, params: Optional[Mapping[str, Any]] = None, state: Optional["NavigationState"] = None
201
+ ) -> "NavigationState":
202
+ """Go to ``name``: pop back to it if it's in the history, else push it."""
203
+ if self.find(name) is not None:
204
+ return self.pop_to(name, params, state)
205
+ return self.push(name, params, state)
206
+
207
+ # ------------------------------------------------------------------
208
+ # Tab / drawer operations (one route per screen)
209
+ # ------------------------------------------------------------------
210
+
211
+ def jump_to(
212
+ self, name: str, params: Optional[Mapping[str, Any]] = None, state: Optional["NavigationState"] = None
213
+ ) -> "NavigationState":
214
+ """Activate the route named ``name`` in place, merging ``params``."""
215
+ idx = self.find(name)
216
+ if idx is None:
217
+ raise KeyError(name)
218
+ routes = list(self.routes)
219
+ if params:
220
+ routes[idx] = routes[idx].with_params(params)
221
+ if state is not None:
222
+ routes[idx] = routes[idx].with_state(state)
223
+ return NavigationState(routes, idx)
224
+
225
+ # ------------------------------------------------------------------
226
+ # Shared
227
+ # ------------------------------------------------------------------
228
+
229
+ def set_params(self, params: Mapping[str, Any]) -> "NavigationState":
230
+ """Merge ``params`` into the active route."""
231
+ routes = list(self.routes)
232
+ routes[self.index] = routes[self.index].with_params(params)
233
+ return NavigationState(routes, self.index)
234
+
235
+ def reset(self, routes: Sequence[Route], index: Optional[int] = None) -> "NavigationState":
236
+ """Return a state holding exactly ``routes`` with ``index`` active (the last route by default)."""
237
+ return NavigationState(routes, index)
238
+
239
+ def to_dict(self) -> Dict[str, Any]:
240
+ """Serialize to a plain dict with ``routes`` (each via ``Route.to_dict``) and ``index``."""
241
+ return {"routes": [r.to_dict() for r in self.routes], "index": self.index}
242
+
243
+ @classmethod
244
+ def from_dict(cls, data: Mapping[str, Any]) -> "NavigationState":
245
+ """Rebuild a state from ``to_dict`` output; a missing ``index`` activates the last route."""
246
+ routes = [Route.from_dict(r) for r in data.get("routes", [])]
247
+ index = data.get("index")
248
+ return cls(routes, int(index) if isinstance(index, int) else None)
pythonnative/net.py CHANGED
@@ -77,6 +77,10 @@ class Response:
77
77
  """``True`` if the status is in the 2xx range."""
78
78
  return 200 <= self.status < 300
79
79
 
80
+ def __repr__(self) -> str:
81
+ cls = type(self).__name__
82
+ return f"{cls}(status={self.status}, url={self.url!r}, ok={self.ok}, content={len(self.content)} bytes)"
83
+
80
84
  def text(self, encoding: Optional[str] = None) -> str:
81
85
  """Decode ``content`` to ``str``.
82
86
 
@@ -1,6 +1,6 @@
1
1
  """Platform-level metrics shared between screen hosts and view handlers.
2
2
 
3
- The screen host (`pythonnative.screen`) is the only place that knows
3
+ The screen host (`pythonnative.hosts`) is the only place that knows
4
4
  about native window/safe-area state because it is the only piece of
5
5
  code that holds a reference to the native ``UIViewController``
6
6
  (iOS) or ``Activity`` (Android). Native view handlers, however, need
pythonnative/preview.py CHANGED
@@ -12,9 +12,9 @@ Architecture
12
12
  navigation stack gets its own child container inside the stage; the
13
13
  desktop view handlers create widgets under the active container.
14
14
  - [`DesktopApp`][pythonnative.preview.DesktopApp] owns the navigation
15
- stack of [`screen`][pythonnative.screen] hosts and the push/pop/reset
16
- primitives the declarative navigators call through ``host._push`` /
17
- ``host._pop``.
15
+ stack of [`hosts`][pythonnative.hosts] and the push/pop/reset
16
+ primitives a root ``Stack.Navigator`` reaches through the host's
17
+ [`HostNavigator`][pythonnative.navigation.HostNavigator] methods.
18
18
  - The Tk event loop runs on the main thread. A lightweight poll
19
19
  (`~60 Hz`) drains (a) UI work marshaled from the asyncio runtime
20
20
  thread via [`runtime.call_on_main_thread`][pythonnative.runtime.call_on_main_thread],
@@ -77,7 +77,7 @@ class DesktopApp:
77
77
  """Navigation-stack controller for the desktop preview window.
78
78
 
79
79
  One instance backs a preview session. It is handed to each
80
- [`screen`][pythonnative.screen] host as the ``native_instance`` so
80
+ [`DesktopScreenHost`][pythonnative.hosts.desktop.DesktopScreenHost] as the ``native_instance`` so
81
81
  hosts can drive navigation (``push_screen`` / ``pop_screen`` /
82
82
  ``reset_to_root``), report the viewport size, and set the window
83
83
  title, mirroring the role a ``UIViewController`` / ``Activity``
@@ -116,7 +116,7 @@ class DesktopApp:
116
116
  return frame
117
117
 
118
118
  def _show_container(self, host: Any) -> None:
119
- container = getattr(host, "_pn_container", None)
119
+ container = getattr(host, "container", None)
120
120
  if container is not None:
121
121
  try:
122
122
  container.place(in_=self._stage, x=0, y=0, relwidth=1.0, relheight=1.0)
@@ -125,7 +125,7 @@ class DesktopApp:
125
125
  pass
126
126
 
127
127
  def _forget_container(self, host: Any) -> None:
128
- container = getattr(host, "_pn_container", None)
128
+ container = getattr(host, "container", None)
129
129
  if container is not None:
130
130
  try:
131
131
  container.place_forget()
@@ -136,19 +136,19 @@ class DesktopApp:
136
136
  """Make ``host`` the rendering target and reflow it to the viewport."""
137
137
  from .native_views import desktop as desktop_backend
138
138
 
139
- desktop_backend.set_root_container(getattr(host, "_pn_container", None))
139
+ desktop_backend.set_root_container(getattr(host, "container", None))
140
140
  self._show_container(host)
141
141
 
142
142
  # -- lifecycle ----------------------------------------------------
143
143
 
144
144
  def _make_host(self, component_path: str, args: Optional[dict] = None) -> Any:
145
- from . import screen as screen_module
145
+ from .hosts import create_screen
146
146
  from .native_views import desktop as desktop_backend
147
147
 
148
148
  container = self._new_container()
149
149
  desktop_backend.set_root_container(container)
150
- host = screen_module.create_screen(component_path, self)
151
- host._pn_container = container
150
+ host = create_screen(component_path, self)
151
+ host.container = container
152
152
  if args:
153
153
  host.set_args(args)
154
154
  return host
@@ -178,8 +178,8 @@ class DesktopApp:
178
178
  self._mount_failed = True
179
179
  self._show_error(traceback.format_exc())
180
180
 
181
- def push_screen(self, component_path: str, args: Optional[dict] = None) -> None:
182
- """Push a new screen, suspending the current one (declarative nav)."""
181
+ def push_screen(self, component_path: str, args: Optional[dict] = None, options: Optional[dict] = None) -> None:
182
+ """Push a new screen, suspending the current one (root stack navigation)."""
183
183
  if self._stack:
184
184
  current = self._stack[-1]
185
185
  try:
@@ -196,6 +196,8 @@ class DesktopApp:
196
196
  try:
197
197
  host.on_create()
198
198
  host.on_resume()
199
+ if options and options.get("title"):
200
+ self.set_title(str(options["title"]))
199
201
  except Exception:
200
202
  self._show_error(traceback.format_exc())
201
203
 
@@ -233,12 +235,12 @@ class DesktopApp:
233
235
  pass
234
236
  # ``on_destroy`` unmounts the host's reconciler: effect cleanups
235
237
  # run, native widgets are destroyed, and event registrations are
236
- # released (see ``screen._destroy_host``).
238
+ # released.
237
239
  try:
238
240
  host.on_destroy()
239
241
  except Exception:
240
242
  pass
241
- container = getattr(host, "_pn_container", None)
243
+ container = getattr(host, "container", None)
242
244
  if container is not None:
243
245
  try:
244
246
  container.destroy()
@@ -305,14 +307,14 @@ class DesktopApp:
305
307
 
306
308
  self._clear_error()
307
309
  for host in self._stack:
308
- desktop_backend.set_root_container(getattr(host, "_pn_container", None))
310
+ desktop_backend.set_root_container(getattr(host, "container", None))
309
311
  try:
310
312
  host.reload(changed_modules)
311
313
  except Exception:
312
314
  self._show_error(traceback.format_exc())
313
315
  active = self.active_host()
314
316
  if active is not None:
315
- desktop_backend.set_root_container(getattr(active, "_pn_container", None))
317
+ desktop_backend.set_root_container(getattr(active, "container", None))
316
318
 
317
319
  def _remount_root(self) -> None:
318
320
  for host in list(self._stack):
@@ -383,7 +385,7 @@ def run_preview(
383
385
  Args:
384
386
  component_path: Module path (``"app.main"`` → its ``App``) or a
385
387
  dotted ``module.Component`` path, same convention as
386
- [`create_screen`][pythonnative.create_screen].
388
+ [`create_screen`][pythonnative.hosts.create_screen].
387
389
  project_root: Directory added to ``sys.path`` so the component
388
390
  imports. Defaults to the current working directory.
389
391
  watch_dir: Directory watched for ``.py`` changes. Defaults to
@@ -449,7 +451,7 @@ def run_preview(
449
451
  watcher.start()
450
452
  print(f"[pn preview] watching {watched} for changes", file=sys.stderr)
451
453
 
452
- from . import screen as screen_module
454
+ from .hosts import drain_desktop_scheduled_renders
453
455
 
454
456
  def _poll() -> None:
455
457
  for _ in range(128):
@@ -462,7 +464,7 @@ def run_preview(
462
464
  except Exception:
463
465
  traceback.print_exc()
464
466
  try:
465
- screen_module.drain_desktop_scheduled_renders()
467
+ drain_desktop_scheduled_renders()
466
468
  except Exception:
467
469
  traceback.print_exc()
468
470
  try:
@@ -234,7 +234,7 @@ def configure_settings_gradle(project_dir: Path, config: AppConfig) -> None:
234
234
 
235
235
 
236
236
  def configure_strings(project_dir: Path, config: AppConfig) -> None:
237
- """Set the ``app_name`` string resource to the display name.
237
+ """Set the ``app_name`` and ``pn_entry_module`` string resources.
238
238
 
239
239
  Args:
240
240
  project_dir: The staged Android project root.
@@ -251,6 +251,12 @@ def configure_strings(project_dir: Path, config: AppConfig) -> None:
251
251
  content,
252
252
  flags=re.DOTALL,
253
253
  )
254
+ content = re.sub(
255
+ r'(<string name="pn_entry_module"[^>]*>)(.*?)(</string>)',
256
+ rf"\g<1>{_xml_escape(config.entry_module)}\g<3>",
257
+ content,
258
+ flags=re.DOTALL,
259
+ )
254
260
  strings_path.write_text(content, encoding="utf-8")
255
261
 
256
262
 
@@ -181,7 +181,7 @@ def check_ios(config: Optional[AppConfig]) -> List[CheckResult]:
181
181
  CheckResult(
182
182
  "iOS embedded Python",
183
183
  WARN,
184
- f"app.python_version={config.python_version}; pinned iOS builds " f"exist for {supported}",
184
+ f"app.python_version={config.python_version}; pinned iOS builds exist for {supported}",
185
185
  )
186
186
  )
187
187
  if config.ios.development_team:
@@ -126,6 +126,7 @@ def configure_info_plist(info_plist: Path, config: AppConfig) -> None:
126
126
 
127
127
  plist["CFBundleDisplayName"] = config.display_name
128
128
  plist["CFBundleName"] = config.name
129
+ plist["PNEntryModule"] = config.entry_module
129
130
 
130
131
  orientations = _ORIENTATIONS.get(config.orientation, _ORIENTATIONS["portrait"])
131
132
  plist["UISupportedInterfaceOrientations"] = list(orientations)
@@ -143,7 +143,7 @@ def prepare_ios_runtime(
143
143
  if pinned is None:
144
144
  supported = ", ".join(sorted(PINNED_ASSETS))
145
145
  raise RuntimeError(
146
- f"No pinned iOS runtime for Python {python_version}. " f"Set app.python_version to one of: {supported}."
146
+ f"No pinned iOS runtime for Python {python_version}. Set app.python_version to one of: {supported}."
147
147
  )
148
148
  tag, asset_name, expected_sha = pinned
149
149
 
@@ -0,0 +1,29 @@
1
+ """Reconciler package: turns element trees into batched native mutations.
2
+
3
+ The public surface is [`Reconciler`][pythonnative.reconciler.core.Reconciler] and
4
+ [`VNode`][pythonnative.reconciler.vnode.VNode]; the remaining modules are
5
+ implementation detail:
6
+
7
+ - ``core``: render, diff, commit.
8
+ - ``boundaries``: ``ErrorBoundary``
9
+ and ``Suspense``.
10
+ - ``layout_pass``: the flexbox
11
+ pass and frame diffing.
12
+ - ``children``: minimal move
13
+ planning for keyed child lists.
14
+ - ``vnode``: the mounted-tree node and
15
+ pure helpers.
16
+ """
17
+
18
+ from .children import plan_child_moves
19
+ from .core import Reconciler
20
+ from .vnode import VNode, next_tag, normalize_children, shallow_equal_props
21
+
22
+ __all__ = [
23
+ "Reconciler",
24
+ "VNode",
25
+ "next_tag",
26
+ "normalize_children",
27
+ "plan_child_moves",
28
+ "shallow_equal_props",
29
+ ]