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,665 @@
|
|
|
1
|
+
"""Platform-independent screen host.
|
|
2
|
+
|
|
3
|
+
A host bridges one native screen (an Android fragment, an iOS view
|
|
4
|
+
controller, a desktop preview page) to a [`Reconciler`][pythonnative.reconciler.Reconciler]
|
|
5
|
+
rendering the app's root component. It owns:
|
|
6
|
+
|
|
7
|
+
- **Lifecycle**: ``on_create`` mounts the tree, ``on_resume`` /
|
|
8
|
+
``on_pause`` track focus, ``on_destroy`` unmounts.
|
|
9
|
+
- **Render scheduling**: state changes during a render are queued and
|
|
10
|
+
drained in bounded batches; platforms hop off-main-thread requests
|
|
11
|
+
onto the UI thread.
|
|
12
|
+
- **Navigation bridging**: the host implements
|
|
13
|
+
[`HostNavigator`][pythonnative.navigation.HostNavigator], so a root
|
|
14
|
+
``Stack.Navigator`` can push real native screens. Each pushed screen
|
|
15
|
+
runs the same root component with its navigation history in
|
|
16
|
+
``args["pn_nav"]``.
|
|
17
|
+
- **Dev tooling**: the RedBox error overlay and hot reload (Fast Refresh
|
|
18
|
+
with a full-remount fallback).
|
|
19
|
+
|
|
20
|
+
Subclasses implement the handful of ``_native_*`` primitives for their
|
|
21
|
+
platform (attach a root view, push a screen, set the title, ...).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import traceback
|
|
31
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
32
|
+
|
|
33
|
+
from .. import diagnostics
|
|
34
|
+
from ..element import Element
|
|
35
|
+
|
|
36
|
+
__all__ = ["ScreenHost", "import_component", "log_pn", "debug_enabled"]
|
|
37
|
+
|
|
38
|
+
MAX_RENDER_PASSES = 25
|
|
39
|
+
_DEBUG_ENV = "PYTHONNATIVE_DEBUG"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def debug_enabled() -> bool:
|
|
43
|
+
"""Return whether the ``PYTHONNATIVE_DEBUG`` environment variable turns on host diagnostics."""
|
|
44
|
+
return os.environ.get(_DEBUG_ENV, "").lower() in {"1", "true", "yes", "on"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def log_pn(msg: str) -> None:
|
|
48
|
+
"""Emit optional diagnostics when ``PYTHONNATIVE_DEBUG`` is enabled."""
|
|
49
|
+
if not debug_enabled():
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
print(f"[PN] {msg}", flush=True)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ======================================================================
|
|
58
|
+
# Component resolution
|
|
59
|
+
# ======================================================================
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _missing_module_is_target(exc: ModuleNotFoundError, dotted: str) -> bool:
|
|
63
|
+
"""Whether ``exc`` means ``dotted`` itself is absent (vs. one of its imports)."""
|
|
64
|
+
missing = exc.name or ""
|
|
65
|
+
return missing == dotted or dotted.startswith(missing + ".")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def import_component(component_path: str) -> Any:
|
|
69
|
+
"""Import a root component by module path or dotted attribute path.
|
|
70
|
+
|
|
71
|
+
``"app.main"`` imports the module and returns its ``App`` attribute;
|
|
72
|
+
``"app.main.RootScreen"`` returns the named attribute. Errors raised
|
|
73
|
+
*inside* a resolvable module (a missing third-party dependency, a
|
|
74
|
+
syntax error) propagate unchanged so the real cause stays visible.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ImportError: When neither form resolves.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
module = importlib.import_module(component_path)
|
|
81
|
+
except ModuleNotFoundError as exc:
|
|
82
|
+
if not _missing_module_is_target(exc, component_path):
|
|
83
|
+
raise
|
|
84
|
+
module = None
|
|
85
|
+
if module is not None:
|
|
86
|
+
component = getattr(module, "App", None)
|
|
87
|
+
if component is not None:
|
|
88
|
+
return component
|
|
89
|
+
|
|
90
|
+
if "." in component_path:
|
|
91
|
+
module_path, attr = component_path.rsplit(".", 1)
|
|
92
|
+
try:
|
|
93
|
+
parent = importlib.import_module(module_path)
|
|
94
|
+
except ModuleNotFoundError as exc:
|
|
95
|
+
if not _missing_module_is_target(exc, module_path):
|
|
96
|
+
raise
|
|
97
|
+
parent = None
|
|
98
|
+
if parent is not None:
|
|
99
|
+
component = getattr(parent, attr, None)
|
|
100
|
+
if component is not None:
|
|
101
|
+
return component
|
|
102
|
+
|
|
103
|
+
raise ImportError(
|
|
104
|
+
f"Could not resolve component {component_path!r}. Define a top-level `App` function in the "
|
|
105
|
+
"module (e.g. `app/main.py`) or pass an explicit dotted path like `app.main.RootScreen`."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ======================================================================
|
|
110
|
+
# Host
|
|
111
|
+
# ======================================================================
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ScreenHost:
|
|
115
|
+
"""Base screen host; see the module docstring.
|
|
116
|
+
|
|
117
|
+
Attributes:
|
|
118
|
+
native_instance: The platform object owning this screen
|
|
119
|
+
(``Activity``, ``UIViewController``, ``DesktopApp``).
|
|
120
|
+
component_path: Import path of the root component.
|
|
121
|
+
args: Launch arguments (``set_args``), including the serialized
|
|
122
|
+
navigation state under ``"pn_nav"`` for pushed screens.
|
|
123
|
+
reconciler: The mounted reconciler, or ``None`` before
|
|
124
|
+
``on_create`` / after ``on_destroy``.
|
|
125
|
+
is_focused: Whether the screen is presented (``on_resume`` /
|
|
126
|
+
``on_pause``).
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, native_instance: Any, component_path: str, component: Any) -> None:
|
|
130
|
+
self.native_instance = native_instance
|
|
131
|
+
self.component_path = component_path
|
|
132
|
+
self.component = component
|
|
133
|
+
self.args: Dict[str, Any] = {}
|
|
134
|
+
self.reconciler: Any = None
|
|
135
|
+
self.root_native_view: Any = None
|
|
136
|
+
self.is_focused = True
|
|
137
|
+
self._focus_listeners: List[Callable[[bool], None]] = []
|
|
138
|
+
self._is_rendering = False
|
|
139
|
+
self._render_queued = False
|
|
140
|
+
self._render_scheduled = False
|
|
141
|
+
self._hot_reload_manifest_path: Optional[str] = None
|
|
142
|
+
self._hot_reload_last_version: Optional[str] = None
|
|
143
|
+
self._hot_reload_pending_version: Optional[str] = None
|
|
144
|
+
self._redbox_reconciler: Any = None
|
|
145
|
+
self._redbox_root: Any = None
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Platform primitives (override)
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _attach_root(self, native_view: Any) -> None:
|
|
152
|
+
"""Place ``native_view`` into the platform container."""
|
|
153
|
+
|
|
154
|
+
def _detach_root(self, native_view: Any) -> None:
|
|
155
|
+
"""Remove ``native_view`` from the platform container."""
|
|
156
|
+
|
|
157
|
+
def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
|
|
158
|
+
"""A plausible viewport size before the first layout (see ``_seed_viewport``)."""
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def _schedule_render_async(self) -> bool:
|
|
162
|
+
"""Defer a render to the platform's next UI turn; ``False`` renders inline."""
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def _native_push(self, component_path: str, args: Dict[str, Any], options: Dict[str, Any]) -> None:
|
|
166
|
+
raise RuntimeError("Pushing native screens requires a native runtime (iOS, Android, or `pn preview`)")
|
|
167
|
+
|
|
168
|
+
def _native_pop(self, count: int) -> None:
|
|
169
|
+
raise RuntimeError("Popping native screens requires a native runtime (iOS, Android, or `pn preview`)")
|
|
170
|
+
|
|
171
|
+
def _native_replace(self, component_path: str, args: Dict[str, Any], options: Dict[str, Any]) -> None:
|
|
172
|
+
self._native_pop(1)
|
|
173
|
+
self._native_push(component_path, args, options)
|
|
174
|
+
|
|
175
|
+
def _native_reset(self, component_path: str, screens: Sequence[Tuple[Dict[str, Any], Dict[str, Any]]]) -> None:
|
|
176
|
+
"""Pop to the root native screen, then push ``screens`` (``(args, options)`` pairs)."""
|
|
177
|
+
self._native_pop_to_root()
|
|
178
|
+
for args, options in screens:
|
|
179
|
+
self._native_push(component_path, args, options)
|
|
180
|
+
|
|
181
|
+
def _native_pop_to_root(self) -> None:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
def _native_set_options(self, options: Dict[str, Any]) -> None:
|
|
185
|
+
"""Apply header options (``title`` at minimum) to the native chrome."""
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# HostNavigator protocol
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def initial_navigation_state(self) -> Optional[Dict[str, Any]]:
|
|
192
|
+
"""Return the serialized navigation state from ``args["pn_nav"]``, or ``None`` for the first screen."""
|
|
193
|
+
from ..navigation.host import initial_state_from_args
|
|
194
|
+
|
|
195
|
+
return initial_state_from_args(self.args)
|
|
196
|
+
|
|
197
|
+
def push_screen(self, state: Dict[str, Any], options: Dict[str, Any]) -> None:
|
|
198
|
+
"""Push a native screen running the same root component, seeded with ``state``."""
|
|
199
|
+
from ..navigation.host import NAV_STATE_ARG
|
|
200
|
+
|
|
201
|
+
self._native_push(self.component_path, {NAV_STATE_ARG: state}, options)
|
|
202
|
+
|
|
203
|
+
def pop_screens(self, count: int) -> None:
|
|
204
|
+
"""Pop ``count`` native screens (at least one)."""
|
|
205
|
+
self._native_pop(max(1, int(count)))
|
|
206
|
+
|
|
207
|
+
def replace_screen(self, state: Dict[str, Any], options: Dict[str, Any]) -> None:
|
|
208
|
+
"""Replace the current native screen with one seeded with ``state``."""
|
|
209
|
+
from ..navigation.host import NAV_STATE_ARG
|
|
210
|
+
|
|
211
|
+
self._native_replace(self.component_path, {NAV_STATE_ARG: state}, options)
|
|
212
|
+
|
|
213
|
+
def reset_screens(self, state: Dict[str, Any], options: Dict[str, Any]) -> None:
|
|
214
|
+
"""Rebuild the native stack for ``state``.
|
|
215
|
+
|
|
216
|
+
The root native screen stays; every route above the first gets
|
|
217
|
+
its own native screen carrying the history up to it, so the
|
|
218
|
+
back button walks the new stack.
|
|
219
|
+
"""
|
|
220
|
+
from ..navigation.host import NAV_STATE_ARG
|
|
221
|
+
|
|
222
|
+
routes = list(state.get("routes") or [])
|
|
223
|
+
screens: List[Tuple[Dict[str, Any], Dict[str, Any]]] = []
|
|
224
|
+
for depth in range(2, len(routes) + 1):
|
|
225
|
+
partial = {"routes": routes[:depth], "index": depth - 1}
|
|
226
|
+
screens.append(({NAV_STATE_ARG: partial}, options if depth == len(routes) else {}))
|
|
227
|
+
self._native_reset(self.component_path, screens)
|
|
228
|
+
|
|
229
|
+
def set_screen_options(self, options: Dict[str, Any]) -> None:
|
|
230
|
+
"""Apply header ``options`` (``title`` and friends) to the native chrome."""
|
|
231
|
+
self._native_set_options(dict(options))
|
|
232
|
+
|
|
233
|
+
def add_focus_listener(self, callback: Callable[[bool], None]) -> Callable[[], None]:
|
|
234
|
+
"""Subscribe to focus changes (``on_resume`` / ``on_pause``); returns an unsubscribe callable."""
|
|
235
|
+
self._focus_listeners.append(callback)
|
|
236
|
+
|
|
237
|
+
def remove() -> None:
|
|
238
|
+
try:
|
|
239
|
+
self._focus_listeners.remove(callback)
|
|
240
|
+
except ValueError:
|
|
241
|
+
pass
|
|
242
|
+
|
|
243
|
+
return remove
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Lifecycle (called by the platform)
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def on_create(self) -> None:
|
|
250
|
+
"""Mount the root component (idempotent across native view recreation).
|
|
251
|
+
|
|
252
|
+
Android destroys and recreates a fragment's view when the user
|
|
253
|
+
pops back to it and calls ``on_create`` again; the Python host
|
|
254
|
+
persists, so an already-mounted tree is simply re-attached.
|
|
255
|
+
"""
|
|
256
|
+
self._register_redbox_reporter()
|
|
257
|
+
if self.reconciler is not None and self.root_native_view is not None:
|
|
258
|
+
self._attach_root(self.root_native_view)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
self.reconciler = self._new_reconciler()
|
|
262
|
+
self._seed_viewport()
|
|
263
|
+
try:
|
|
264
|
+
self._is_rendering = True
|
|
265
|
+
try:
|
|
266
|
+
self.root_native_view = self.reconciler.mount(self._root_element())
|
|
267
|
+
self._attach_root(self.root_native_view)
|
|
268
|
+
self._drain_renders()
|
|
269
|
+
finally:
|
|
270
|
+
self._is_rendering = False
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
if not diagnostics.is_dev():
|
|
273
|
+
raise
|
|
274
|
+
self.show_redbox(exc, phase="mount")
|
|
275
|
+
|
|
276
|
+
def on_start(self) -> None:
|
|
277
|
+
"""Handle the platform's start event (no-op by default)."""
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
def on_resume(self) -> None:
|
|
281
|
+
"""Mark the screen focused and notify focus listeners."""
|
|
282
|
+
self.set_focused(True)
|
|
283
|
+
|
|
284
|
+
def on_layout(self) -> None:
|
|
285
|
+
"""Handle a native layout pass (no-op by default; platforms sync the viewport here)."""
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def on_pause(self) -> None:
|
|
289
|
+
"""Mark the screen unfocused and notify focus listeners."""
|
|
290
|
+
self.set_focused(False)
|
|
291
|
+
|
|
292
|
+
def on_stop(self) -> None:
|
|
293
|
+
"""Handle the platform's stop event (no-op by default)."""
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
def on_restart(self) -> None:
|
|
297
|
+
"""Handle the platform's restart event (no-op by default)."""
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
def on_save_instance_state(self) -> None:
|
|
301
|
+
"""Handle the platform's save-state request (no-op by default)."""
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
def on_restore_instance_state(self) -> None:
|
|
305
|
+
"""Handle the platform's restore-state event (no-op by default)."""
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
def on_destroy(self) -> None:
|
|
309
|
+
"""Tear down: unmount (running effect cleanups), release native views."""
|
|
310
|
+
self.clear_redbox(reattach=False)
|
|
311
|
+
diagnostics.set_error_reporter(self, None)
|
|
312
|
+
reconciler, self.reconciler = self.reconciler, None
|
|
313
|
+
if reconciler is not None:
|
|
314
|
+
try:
|
|
315
|
+
reconciler.unmount()
|
|
316
|
+
except Exception:
|
|
317
|
+
log_pn("on_destroy: reconciler.unmount() failed")
|
|
318
|
+
root, self.root_native_view = self.root_native_view, None
|
|
319
|
+
if root is not None:
|
|
320
|
+
try:
|
|
321
|
+
self._detach_root(root)
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
self._focus_listeners = []
|
|
325
|
+
|
|
326
|
+
def on_back_pressed(self) -> bool:
|
|
327
|
+
"""Offer the system back action to ``use_back_handler`` subscribers.
|
|
328
|
+
|
|
329
|
+
Returns ``True`` when a handler consumed the event, in which
|
|
330
|
+
case the platform must not pop the screen.
|
|
331
|
+
"""
|
|
332
|
+
if self.reconciler is None:
|
|
333
|
+
return False
|
|
334
|
+
try:
|
|
335
|
+
return bool(self.reconciler.dispatch_back_press())
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
if not diagnostics.report_error(exc, phase="back handler"):
|
|
338
|
+
traceback.print_exc()
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
def set_args(self, args: Any) -> None:
|
|
342
|
+
"""Record launch arguments (a dict or a JSON string)."""
|
|
343
|
+
if isinstance(args, str):
|
|
344
|
+
try:
|
|
345
|
+
parsed = json.loads(args) or {}
|
|
346
|
+
except Exception:
|
|
347
|
+
parsed = {}
|
|
348
|
+
self.args = parsed if isinstance(parsed, dict) else {}
|
|
349
|
+
return
|
|
350
|
+
self.args = args if isinstance(args, dict) else {}
|
|
351
|
+
|
|
352
|
+
def set_focused(self, focused: bool) -> None:
|
|
353
|
+
"""Update ``is_focused`` and notify focus listeners when the value changes."""
|
|
354
|
+
if self.is_focused == focused:
|
|
355
|
+
return
|
|
356
|
+
self.is_focused = focused
|
|
357
|
+
for callback in list(self._focus_listeners):
|
|
358
|
+
try:
|
|
359
|
+
callback(focused)
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
def set_viewport_size(self, width: float, height: float) -> None:
|
|
364
|
+
"""Forward a viewport-size change (in points) to the reconciler."""
|
|
365
|
+
if self.reconciler is None or width <= 0 or height <= 0:
|
|
366
|
+
return
|
|
367
|
+
self.reconciler.set_viewport_size(float(width), float(height))
|
|
368
|
+
if self._redbox_reconciler is not None:
|
|
369
|
+
self._redbox_reconciler.set_viewport_size(float(width), float(height))
|
|
370
|
+
try:
|
|
371
|
+
from .. import platform_metrics
|
|
372
|
+
|
|
373
|
+
platform_metrics.set_window_dimensions(float(width), float(height))
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
# Rendering
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
def _root_element(self) -> Element:
|
|
382
|
+
from ..navigation.host import HostRoot
|
|
383
|
+
|
|
384
|
+
self.component = import_component(self.component_path)
|
|
385
|
+
return HostRoot(self.component(), host=self)
|
|
386
|
+
|
|
387
|
+
def _new_reconciler(self) -> Any:
|
|
388
|
+
from ..native_views import get_registry
|
|
389
|
+
from ..reconciler import Reconciler
|
|
390
|
+
|
|
391
|
+
reconciler = Reconciler(get_registry())
|
|
392
|
+
reconciler.on_render_requested = self.request_render
|
|
393
|
+
return reconciler
|
|
394
|
+
|
|
395
|
+
def _seed_viewport(self) -> None:
|
|
396
|
+
"""Give the reconciler a plausible viewport before the first mount.
|
|
397
|
+
|
|
398
|
+
The authoritative size arrives right after attach, but the mount
|
|
399
|
+
commit has already run by then; without a viewport its layout
|
|
400
|
+
pass is skipped and mount-time ``use_layout_effect`` callbacks
|
|
401
|
+
would observe no frames.
|
|
402
|
+
"""
|
|
403
|
+
try:
|
|
404
|
+
size = self._initial_viewport_size()
|
|
405
|
+
except Exception:
|
|
406
|
+
return
|
|
407
|
+
if size and size[0] > 0 and size[1] > 0:
|
|
408
|
+
self.reconciler.set_viewport_size(float(size[0]), float(size[1]))
|
|
409
|
+
|
|
410
|
+
def request_render(self) -> None:
|
|
411
|
+
"""Request a render pass (queued if one is in progress)."""
|
|
412
|
+
if self.reconciler is None:
|
|
413
|
+
return
|
|
414
|
+
if self._is_rendering:
|
|
415
|
+
self._render_queued = True
|
|
416
|
+
return
|
|
417
|
+
if self._schedule_render_async():
|
|
418
|
+
return
|
|
419
|
+
self._re_render()
|
|
420
|
+
|
|
421
|
+
def flush_scheduled_render(self) -> None:
|
|
422
|
+
"""Run a render deferred by ``_schedule_render_async`` (platform UI turn)."""
|
|
423
|
+
self._render_scheduled = False
|
|
424
|
+
if self.reconciler is None:
|
|
425
|
+
return
|
|
426
|
+
if self._is_rendering:
|
|
427
|
+
self._render_queued = True
|
|
428
|
+
self._schedule_render_async()
|
|
429
|
+
return
|
|
430
|
+
self._re_render()
|
|
431
|
+
|
|
432
|
+
def _re_render(self) -> None:
|
|
433
|
+
log_pn("_re_render: starting local render pass")
|
|
434
|
+
try:
|
|
435
|
+
self._is_rendering = True
|
|
436
|
+
try:
|
|
437
|
+
self._render_queued = False
|
|
438
|
+
self._commit_dirty()
|
|
439
|
+
self._drain_renders()
|
|
440
|
+
finally:
|
|
441
|
+
self._is_rendering = False
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
if not diagnostics.is_dev():
|
|
444
|
+
raise
|
|
445
|
+
self.show_redbox(exc, phase="render")
|
|
446
|
+
log_pn("_re_render: done")
|
|
447
|
+
|
|
448
|
+
def _commit_dirty(self) -> None:
|
|
449
|
+
new_root = self.reconciler.flush_dirty()
|
|
450
|
+
if new_root is not self.root_native_view:
|
|
451
|
+
log_pn("_commit_dirty: root view changed; reattaching")
|
|
452
|
+
self._detach_root(self.root_native_view)
|
|
453
|
+
self.root_native_view = new_root
|
|
454
|
+
self._attach_root(new_root)
|
|
455
|
+
|
|
456
|
+
def _drain_renders(self) -> None:
|
|
457
|
+
"""Flush renders queued by effects; capped to break runaway loops."""
|
|
458
|
+
for i in range(MAX_RENDER_PASSES):
|
|
459
|
+
if not self._render_queued:
|
|
460
|
+
break
|
|
461
|
+
log_pn(f"_drain_renders: pass #{i + 1}")
|
|
462
|
+
self._render_queued = False
|
|
463
|
+
self._commit_dirty()
|
|
464
|
+
|
|
465
|
+
# ------------------------------------------------------------------
|
|
466
|
+
# RedBox (dev-mode error overlay)
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
def _register_redbox_reporter(self) -> None:
|
|
470
|
+
if diagnostics.is_dev():
|
|
471
|
+
diagnostics.set_error_reporter(self, lambda exc, phase: self.show_redbox(exc, phase))
|
|
472
|
+
|
|
473
|
+
def show_redbox(self, exc: BaseException, phase: str = "render") -> None:
|
|
474
|
+
"""Mount the dev error overlay over this screen (from any thread)."""
|
|
475
|
+
log_pn(f"show_redbox: {type(exc).__name__} during {phase}")
|
|
476
|
+
try:
|
|
477
|
+
print(f"[PN] {phase} error:", file=sys.stderr)
|
|
478
|
+
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
|
479
|
+
except Exception:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
def mount() -> None:
|
|
483
|
+
try:
|
|
484
|
+
self.clear_redbox(reattach=False)
|
|
485
|
+
from ..native_views import get_registry
|
|
486
|
+
from ..reconciler import Reconciler
|
|
487
|
+
|
|
488
|
+
redbox = Reconciler(get_registry())
|
|
489
|
+
element = _redbox_element(exc, phase, lambda: self.clear_redbox())
|
|
490
|
+
root = redbox.mount(element)
|
|
491
|
+
width, height = self.reconciler.viewport_size if self.reconciler is not None else (0.0, 0.0)
|
|
492
|
+
if width <= 0 or height <= 0:
|
|
493
|
+
from .. import platform_metrics
|
|
494
|
+
|
|
495
|
+
dims = platform_metrics.get_window_dimensions()
|
|
496
|
+
width, height = dims.width, dims.height
|
|
497
|
+
if width > 0 and height > 0:
|
|
498
|
+
redbox.set_viewport_size(width, height)
|
|
499
|
+
self._redbox_reconciler = redbox
|
|
500
|
+
self._redbox_root = root
|
|
501
|
+
if self.root_native_view is not None:
|
|
502
|
+
self._detach_root(self.root_native_view)
|
|
503
|
+
self._attach_root(root)
|
|
504
|
+
except Exception:
|
|
505
|
+
print("[PN] RedBox failed to mount:", file=sys.stderr)
|
|
506
|
+
traceback.print_exc()
|
|
507
|
+
|
|
508
|
+
from ..runtime import call_on_main_thread
|
|
509
|
+
|
|
510
|
+
call_on_main_thread(mount)
|
|
511
|
+
|
|
512
|
+
def clear_redbox(self, reattach: bool = True) -> None:
|
|
513
|
+
"""Dismiss the dev error overlay, reattaching the app's root view unless ``reattach`` is ``False``."""
|
|
514
|
+
redbox, self._redbox_reconciler = self._redbox_reconciler, None
|
|
515
|
+
self._redbox_root = None
|
|
516
|
+
if redbox is None:
|
|
517
|
+
return
|
|
518
|
+
try:
|
|
519
|
+
redbox.unmount()
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
if reattach and self.root_native_view is not None:
|
|
523
|
+
try:
|
|
524
|
+
self._attach_root(self.root_native_view)
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
# ------------------------------------------------------------------
|
|
529
|
+
# Hot reload
|
|
530
|
+
# ------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
|
|
533
|
+
"""Start polling ``manifest_path`` for reloads (see ``hot_reload_tick``) and switch on dev mode.
|
|
534
|
+
|
|
535
|
+
``source_root`` is accepted for the native templates, which pass the
|
|
536
|
+
dev directory alongside the manifest; the reloader derives module
|
|
537
|
+
paths from the manifest itself, so it is currently unused.
|
|
538
|
+
"""
|
|
539
|
+
self._hot_reload_manifest_path = manifest_path
|
|
540
|
+
self._hot_reload_last_version = None
|
|
541
|
+
# Hot reload only runs on debug builds, so it doubles as the
|
|
542
|
+
# on-device dev-mode switch (validation warnings, RedBox).
|
|
543
|
+
diagnostics.set_dev_mode(True)
|
|
544
|
+
self._register_redbox_reporter()
|
|
545
|
+
|
|
546
|
+
def hot_reload_tick(self) -> bool:
|
|
547
|
+
"""Poll the reload manifest; returns whether a reload was applied."""
|
|
548
|
+
manifest_path = self._hot_reload_manifest_path
|
|
549
|
+
if not manifest_path:
|
|
550
|
+
return False
|
|
551
|
+
from ..hot_reload import ModuleReloader
|
|
552
|
+
|
|
553
|
+
last = self._hot_reload_last_version
|
|
554
|
+
if not os.path.exists(manifest_path) and last is None:
|
|
555
|
+
return False
|
|
556
|
+
next_version = ModuleReloader.reload_from_manifest(self, manifest_path, last_version=last)
|
|
557
|
+
if next_version == last:
|
|
558
|
+
return False
|
|
559
|
+
self._hot_reload_last_version = next_version
|
|
560
|
+
return True
|
|
561
|
+
|
|
562
|
+
def reload(self, changed_modules: Optional[Sequence[str]] = None) -> None:
|
|
563
|
+
"""Reload modules and refresh the tree (Fast Refresh, else full remount)."""
|
|
564
|
+
from ..hot_reload import ModuleReloader
|
|
565
|
+
|
|
566
|
+
requested = list(changed_modules or [])
|
|
567
|
+
targets = ModuleReloader.expand_reload_targets(requested, self.component_path)
|
|
568
|
+
reloaded = ModuleReloader.reload_modules_for_version(targets, self._hot_reload_pending_version)
|
|
569
|
+
if not reloaded:
|
|
570
|
+
log_pn(f"reload: no modules could be reloaded from {targets!r}")
|
|
571
|
+
return
|
|
572
|
+
try:
|
|
573
|
+
self.component = import_component(self.component_path)
|
|
574
|
+
except Exception as exc:
|
|
575
|
+
if diagnostics.is_dev():
|
|
576
|
+
self.show_redbox(exc, phase="hot reload import")
|
|
577
|
+
return
|
|
578
|
+
if self.reconciler is None:
|
|
579
|
+
return
|
|
580
|
+
self.clear_redbox()
|
|
581
|
+
if self._try_fast_refresh(reloaded):
|
|
582
|
+
print(f"[hot-reload] Fast Refresh: {', '.join(requested) or ', '.join(reloaded)}", file=sys.stderr)
|
|
583
|
+
return
|
|
584
|
+
try:
|
|
585
|
+
self._full_remount(reloaded)
|
|
586
|
+
except Exception as exc:
|
|
587
|
+
if not diagnostics.is_dev():
|
|
588
|
+
raise
|
|
589
|
+
self.show_redbox(exc, phase="hot reload")
|
|
590
|
+
|
|
591
|
+
def _try_fast_refresh(self, reloaded_modules: Sequence[str]) -> bool:
|
|
592
|
+
from ..hot_reload import ModuleReloader
|
|
593
|
+
|
|
594
|
+
reconciler = self.reconciler
|
|
595
|
+
if reconciler is None or reconciler.root is None:
|
|
596
|
+
return False
|
|
597
|
+
if not ModuleReloader.refresh_in_place(reconciler, reloaded_modules):
|
|
598
|
+
return False
|
|
599
|
+
self._is_rendering = True
|
|
600
|
+
try:
|
|
601
|
+
new_root = reconciler.reconcile(self._root_element())
|
|
602
|
+
if new_root is not self.root_native_view:
|
|
603
|
+
self._detach_root(self.root_native_view)
|
|
604
|
+
self.root_native_view = new_root
|
|
605
|
+
self._attach_root(new_root)
|
|
606
|
+
except Exception as exc:
|
|
607
|
+
log_pn(f"fast refresh: render failed after swap: {exc!r}; falling back to remount")
|
|
608
|
+
return False
|
|
609
|
+
finally:
|
|
610
|
+
self._is_rendering = False
|
|
611
|
+
self._drain_renders()
|
|
612
|
+
return True
|
|
613
|
+
|
|
614
|
+
def _full_remount(self, reloaded_modules: Sequence[str]) -> None:
|
|
615
|
+
old_reconciler, old_root = self.reconciler, self.root_native_view
|
|
616
|
+
new_reconciler = self._new_reconciler()
|
|
617
|
+
self.reconciler = new_reconciler
|
|
618
|
+
self._is_rendering = True
|
|
619
|
+
try:
|
|
620
|
+
new_root = new_reconciler.mount(self._root_element())
|
|
621
|
+
except Exception:
|
|
622
|
+
self.reconciler = old_reconciler
|
|
623
|
+
raise
|
|
624
|
+
finally:
|
|
625
|
+
self._is_rendering = False
|
|
626
|
+
if old_reconciler is not None:
|
|
627
|
+
old_reconciler.unmount()
|
|
628
|
+
if old_root is not None:
|
|
629
|
+
self._detach_root(old_root)
|
|
630
|
+
self.root_native_view = new_root
|
|
631
|
+
self._attach_root(new_root)
|
|
632
|
+
self._drain_renders()
|
|
633
|
+
print(f"[hot-reload] Remounted: {', '.join(reloaded_modules)}", file=sys.stderr)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _redbox_element(exc: BaseException, phase: str, on_dismiss: Callable[[], None]) -> Element:
|
|
637
|
+
from ..components import Button, Column, ScrollView, Text
|
|
638
|
+
|
|
639
|
+
trace = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
640
|
+
message = str(exc) or "(no message)"
|
|
641
|
+
return Column(
|
|
642
|
+
Column(
|
|
643
|
+
Text(f"{type(exc).__name__} in {phase}", style={"color": "#FFD3DA", "font_size": 13, "bold": True}),
|
|
644
|
+
Text(message, style={"color": "#FFFFFF", "font_size": 17, "bold": True}),
|
|
645
|
+
style={"background_color": "#C4283C", "padding": 16, "padding_top": 56, "spacing": 6},
|
|
646
|
+
),
|
|
647
|
+
ScrollView(
|
|
648
|
+
Text(trace, style={"color": "#FF9AA8", "font_size": 12}),
|
|
649
|
+
style={"flex": 1, "padding": 12},
|
|
650
|
+
),
|
|
651
|
+
Column(
|
|
652
|
+
Button("Dismiss", on_press=on_dismiss, style={"color": "#FFFFFF"}),
|
|
653
|
+
Text(
|
|
654
|
+
"Fix the error and save to reload.", style={"color": "#8E8E93", "font_size": 12, "text_align": "center"}
|
|
655
|
+
),
|
|
656
|
+
style={"padding": 12, "padding_bottom": 32, "spacing": 4},
|
|
657
|
+
),
|
|
658
|
+
style={"flex": 1, "background_color": "#1C1C1E"},
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def flush_hosts(hosts: Sequence[ScreenHost]) -> None:
|
|
663
|
+
"""Run deferred renders for ``hosts`` (platform UI-thread drains call this)."""
|
|
664
|
+
for host in hosts:
|
|
665
|
+
host.flush_scheduled_render()
|