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.
- pythonnative/__init__.py +27 -15
- pythonnative/animated.py +7 -13
- pythonnative/cli/pn.py +1 -2
- pythonnative/component.py +255 -0
- pythonnative/components/__init__.py +106 -0
- pythonnative/components/_base.py +132 -0
- pythonnative/components/controls.py +515 -0
- pythonnative/components/layout.py +459 -0
- pythonnative/components/lists.py +809 -0
- pythonnative/components/media.py +204 -0
- pythonnative/components/overlays.py +107 -0
- pythonnative/components/pressable.py +252 -0
- pythonnative/components/structural.py +167 -0
- pythonnative/components/text.py +291 -0
- pythonnative/diagnostics.py +1 -1
- pythonnative/element.py +108 -29
- pythonnative/gestures.py +1 -1
- pythonnative/hooks.py +366 -671
- pythonnative/hosts/__init__.py +85 -0
- pythonnative/hosts/android.py +269 -0
- pythonnative/hosts/base.py +665 -0
- pythonnative/hosts/desktop.py +107 -0
- pythonnative/hosts/ios.py +404 -0
- pythonnative/hot_reload.py +16 -24
- pythonnative/layout.py +372 -66
- pythonnative/native_modules/__init__.py +23 -0
- pythonnative/native_modules/net_info.py +5 -1
- pythonnative/native_views/__init__.py +1 -1
- pythonnative/native_views/android.py +50 -6
- pythonnative/native_views/base.py +64 -1
- pythonnative/native_views/desktop.py +11 -3
- pythonnative/native_views/ios.py +72 -12
- pythonnative/navigation/__init__.py +102 -0
- pythonnative/navigation/container.py +154 -0
- pythonnative/navigation/handle.py +573 -0
- pythonnative/navigation/hooks.py +94 -0
- pythonnative/navigation/host.py +58 -0
- pythonnative/navigation/linking.py +200 -0
- pythonnative/navigation/navigators.py +637 -0
- pythonnative/navigation/screen.py +149 -0
- pythonnative/navigation/state.py +248 -0
- pythonnative/net.py +4 -0
- pythonnative/platform_metrics.py +1 -1
- pythonnative/preview.py +21 -19
- pythonnative/project/android.py +7 -1
- pythonnative/project/doctor.py +1 -1
- pythonnative/project/ios.py +1 -0
- pythonnative/project/runtime_assets.py +1 -1
- pythonnative/reconciler/__init__.py +29 -0
- pythonnative/reconciler/boundaries.py +365 -0
- pythonnative/reconciler/children.py +88 -0
- pythonnative/reconciler/core.py +1153 -0
- pythonnative/reconciler/layout_pass.py +359 -0
- pythonnative/reconciler/vnode.py +266 -0
- pythonnative/scheduler.py +159 -0
- pythonnative/sdk/_components.py +2 -4
- pythonnative/style.py +95 -13
- pythonnative/suspense.py +8 -12
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +85 -1
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +6 -7
- pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
- pythonnative/templates/android_template/app/src/main/res/values/strings.xml +3 -1
- pythonnative/templates/ios_template/ios_template/AppDelegate.swift +33 -0
- pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +1 -1
- pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -10
- pythonnative/testing/__init__.py +53 -0
- pythonnative/testing/backend.py +276 -0
- pythonnative/testing/harness.py +391 -0
- {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/METADATA +2 -21
- {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/RECORD +74 -43
- pythonnative/components.py +0 -2856
- pythonnative/navigation.py +0 -1031
- pythonnative/reconciler.py +0 -2298
- pythonnative/screen.py +0 -2068
- {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
pythonnative/platform_metrics.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Platform-level metrics shared between screen hosts and view handlers.
|
|
2
2
|
|
|
3
|
-
The screen host (`pythonnative.
|
|
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 [`
|
|
16
|
-
primitives
|
|
17
|
-
|
|
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
|
-
[`
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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
|
|
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 =
|
|
151
|
-
host.
|
|
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 (
|
|
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
|
|
238
|
+
# released.
|
|
237
239
|
try:
|
|
238
240
|
host.on_destroy()
|
|
239
241
|
except Exception:
|
|
240
242
|
pass
|
|
241
|
-
container = getattr(host, "
|
|
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, "
|
|
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, "
|
|
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
|
|
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
|
-
|
|
467
|
+
drain_desktop_scheduled_renders()
|
|
466
468
|
except Exception:
|
|
467
469
|
traceback.print_exc()
|
|
468
470
|
try:
|
pythonnative/project/android.py
CHANGED
|
@@ -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``
|
|
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
|
|
pythonnative/project/doctor.py
CHANGED
|
@@ -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
|
|
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:
|
pythonnative/project/ios.py
CHANGED
|
@@ -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}.
|
|
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
|
+
]
|