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,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()