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
pythonnative/screen.py DELETED
@@ -1,2068 +0,0 @@
1
- """Screen host: the bridge between native lifecycle and function components.
2
-
3
- Users do not write screen classes by hand. Instead they write
4
- ``@component`` functions and the native template calls
5
- [`create_screen`][pythonnative.create_screen] to obtain a host that
6
- manages the reconciler and lifecycle for that screen.
7
-
8
- The screen host owns:
9
-
10
- - A [`Reconciler`][pythonnative.reconciler.Reconciler] backed by the
11
- platform's native-view registry.
12
- - A [`NavigationHandle`][pythonnative.hooks.NavigationHandle] (delivered to
13
- components via the navigation context) so screens can push and pop
14
- without holding a direct reference to native classes.
15
- - Render scheduling. State changes during render are queued and drained
16
- in batches so the reconciler runs at most a bounded number of passes
17
- per user gesture.
18
-
19
- Example:
20
- User code defines a top-level component named ``App``:
21
-
22
- ```python
23
- import pythonnative as pn
24
-
25
- @pn.component
26
- def App():
27
- count, set_count = pn.use_state(0)
28
- return pn.Column(
29
- pn.Text(f"Count: {count}", style={"font_size": 24}),
30
- pn.Button("Tap me", on_press=lambda: set_count(count + 1)),
31
- style={"spacing": 12, "padding": 16},
32
- )
33
- ```
34
-
35
- The native template wires it in:
36
-
37
- ```python
38
- host = pythonnative.screen.create_screen(
39
- "app.main",
40
- native_instance,
41
- )
42
- host.on_create()
43
- ```
44
- """
45
-
46
- import importlib
47
- import json
48
- import os
49
- import sys
50
- import threading
51
- import traceback
52
- from typing import Any, Dict, Optional, Sequence, Tuple
53
-
54
- from . import diagnostics
55
- from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS, set_android_context
56
-
57
- _MAX_RENDER_PASSES = 25
58
- _DEBUG_ENV = "PYTHONNATIVE_DEBUG"
59
-
60
-
61
- def _debug_enabled() -> bool:
62
- return os.environ.get(_DEBUG_ENV, "").lower() in {"1", "true", "yes", "on"}
63
-
64
-
65
- def _log_pn(msg: str) -> None:
66
- """Emit optional diagnostics when ``PYTHONNATIVE_DEBUG`` is enabled."""
67
- if not _debug_enabled():
68
- return
69
- try:
70
- print(f"[PN] {msg}", flush=True)
71
- except Exception:
72
- pass
73
-
74
-
75
- # ======================================================================
76
- # Component path resolution
77
- # ======================================================================
78
-
79
-
80
- def _resolve_component_path(component_ref: Any) -> str:
81
- """Resolve a component function or string into a `module.name` path."""
82
- if isinstance(component_ref, str):
83
- return component_ref
84
- func = getattr(component_ref, "__wrapped__", component_ref)
85
- module = getattr(func, "__module__", None)
86
- name = getattr(func, "__name__", None)
87
- if module and name:
88
- return f"{module}.{name}"
89
- raise ValueError(f"Cannot resolve component path for {component_ref!r}")
90
-
91
-
92
- def _missing_module_is_target(exc: ModuleNotFoundError, dotted: str) -> bool:
93
- """Return ``True`` when ``exc`` means ``dotted`` itself is absent.
94
-
95
- Distinguishes "the component module/package cannot be found" (so the
96
- caller should fall through to the next resolution strategy) from "the
97
- module exists but raised :class:`ModuleNotFoundError` while importing
98
- one of *its own* dependencies". The latter must propagate so the
99
- developer sees the real missing import (e.g. ``No module named
100
- 'emoji'``) instead of a misleading "could not resolve component".
101
- """
102
- missing = exc.name or ""
103
- return missing == dotted or dotted.startswith(missing + ".")
104
-
105
-
106
- def _import_component(component_path: str) -> Any:
107
- """Import a component by module or dotted-attribute path.
108
-
109
- PythonNative's entry-point convention is "define a function named
110
- ``App`` at the top of your module, and the native templates will
111
- find it". So the templates pass a *module path* like
112
- ``"app.main"`` and this helper imports the module and returns its
113
- ``App`` attribute.
114
-
115
- A dotted ``module.Attribute`` path is also accepted as an escape
116
- hatch (e.g. ``"app.main.RootScreen"``) for users who want to
117
- expose a differently-named component without renaming it to
118
- ``App``.
119
-
120
- Resolution order:
121
-
122
- 1. If ``component_path`` resolves cleanly as a module, return its
123
- ``App`` attribute.
124
- 2. Otherwise split on the final ``.``: import the parent module
125
- and return the named attribute.
126
-
127
- Args:
128
- component_path: Either ``"app.main"`` (module path with an
129
- ``App`` attribute) or ``"app.main.SomeComponent"`` (dotted
130
- path to a specific component).
131
-
132
- Returns:
133
- The resolved component callable.
134
-
135
- Raises:
136
- ImportError: If the module or dotted path cannot be found.
137
- Errors raised *inside* a resolvable module (such as a
138
- missing third-party dependency it imports) propagate
139
- unchanged so the real cause stays visible.
140
- """
141
- try:
142
- module = importlib.import_module(component_path)
143
- except ModuleNotFoundError as exc:
144
- if not _missing_module_is_target(exc, component_path):
145
- raise
146
- module = None
147
- if module is not None:
148
- component = getattr(module, "App", None)
149
- if component is not None:
150
- return component
151
-
152
- if "." in component_path:
153
- module_path, attr = component_path.rsplit(".", 1)
154
- try:
155
- parent = importlib.import_module(module_path)
156
- except ModuleNotFoundError as exc:
157
- if not _missing_module_is_target(exc, module_path):
158
- raise
159
- parent = None
160
- if parent is not None:
161
- component = getattr(parent, attr, None)
162
- if component is not None:
163
- return component
164
-
165
- raise ImportError(
166
- f"Could not resolve component {component_path!r}. "
167
- "Define a top-level `App` function in the module (e.g. "
168
- "`app/main.py`) or pass an explicit dotted path like "
169
- "`app.main.RootScreen`."
170
- )
171
-
172
-
173
- # ======================================================================
174
- # Shared helpers
175
- # ======================================================================
176
-
177
-
178
- def _init_host_common(host: Any, component_path: str, component_func: Any) -> None:
179
- host._component_path = component_path
180
- host._component = component_func
181
- host._args = {}
182
- host._reconciler = None
183
- host._root_native_view = None
184
- host._nav_handle = None
185
- host._is_rendering = False
186
- host._render_queued = False
187
- host._render_scheduled = False
188
- host._hot_reload_manifest_path = None
189
- host._hot_reload_last_version = None
190
- host._layout_listener = None # retained on Android to prevent GC
191
- # RedBox state: a dedicated reconciler that mounts the dev error
192
- # overlay in place of the app tree (see ``_show_redbox``).
193
- host._redbox_reconciler = None
194
- host._redbox_root = None
195
- # Focus state: drives ``use_focus_effect``. Starts focused because
196
- # a host is only created when the screen is being presented; the
197
- # platform lifecycle hooks (``on_resume`` / ``on_pause``) flip this
198
- # when the user navigates to / from another screen.
199
- host._is_focused = True
200
- host._focus_subscribers = []
201
-
202
-
203
- def _set_host_focused(host: Any, focused: bool) -> None:
204
- """Update ``host._is_focused`` and notify ``use_focus_effect`` subscribers."""
205
- if getattr(host, "_is_focused", True) == focused:
206
- return
207
- host._is_focused = focused
208
- subscribers = list(getattr(host, "_focus_subscribers", ()) or ())
209
- for callback in subscribers:
210
- try:
211
- callback(focused)
212
- except Exception:
213
- pass
214
-
215
-
216
- def _destroy_host(host: Any) -> None:
217
- """Tear down a screen host when the native screen is destroyed for good.
218
-
219
- Called from each platform's ``on_destroy``. Unmounting the
220
- reconciler runs every pending effect cleanup, destroys the native
221
- views (releasing their event registrations), and clears back
222
- handlers, so a popped screen doesn't leak its whole tree. Also
223
- unregisters the host's RedBox reporter so runtime errors from other
224
- screens don't route to a dead overlay.
225
- """
226
- _clear_redbox(host, reattach=False)
227
- diagnostics.set_error_reporter(host, None)
228
- reconciler = host._reconciler
229
- host._reconciler = None
230
- if reconciler is not None:
231
- try:
232
- reconciler.unmount()
233
- except Exception:
234
- _log_pn("_destroy_host: reconciler.unmount() failed")
235
- root = host._root_native_view
236
- host._root_native_view = None
237
- if root is not None:
238
- try:
239
- host._detach_root(root)
240
- except Exception:
241
- pass
242
- host._nav_handle = None
243
- host._focus_subscribers = []
244
-
245
-
246
- def _host_back_pressed(host: Any) -> bool:
247
- """Offer the system back action to ``use_back_handler`` subscribers.
248
-
249
- Called by the native templates before running the platform's
250
- default back behavior. Returns ``True`` when a handler consumed
251
- the event, in which case the platform must *not* pop the screen.
252
- """
253
- reconciler = host._reconciler
254
- if reconciler is None:
255
- return False
256
- try:
257
- return bool(reconciler.dispatch_back_press())
258
- except Exception as exc:
259
- if not diagnostics.report_error(exc, phase="back handler"):
260
- traceback.print_exc()
261
- return False
262
-
263
-
264
- def _push_viewport_size(host: Any, width: float, height: float) -> None:
265
- """Forward a viewport-size change to the reconciler.
266
-
267
- Called by the native template (or our injected layout listener
268
- on Android, or `_attach_root` on iOS) whenever the screen
269
- container's bounds change. Coordinates must be in points (not
270
- raw pixels). Also publishes the new dimensions to
271
- `pythonnative.platform_metrics` so the
272
- [`use_window_dimensions`][pythonnative.use_window_dimensions]
273
- hook re-renders subscribers.
274
- """
275
- if host._reconciler is None:
276
- return
277
- if width <= 0 or height <= 0:
278
- return
279
- host._reconciler.set_viewport_size(float(width), float(height))
280
- redbox = getattr(host, "_redbox_reconciler", None)
281
- if redbox is not None:
282
- redbox.set_viewport_size(float(width), float(height))
283
- try:
284
- from . import platform_metrics
285
-
286
- platform_metrics.set_window_dimensions(float(width), float(height))
287
- except Exception:
288
- pass
289
-
290
-
291
- def _get_component(host: Any) -> Any:
292
- """Resolve the current component function from its dotted path."""
293
- host._component = _import_component(host._component_path)
294
- return host._component
295
-
296
-
297
- def _render_app(host: Any) -> Any:
298
- """Call the current root component and return its element tree."""
299
- return _get_component(host)()
300
-
301
-
302
- def _new_reconciler(host: Any) -> Any:
303
- from .native_views import get_registry
304
- from .reconciler import Reconciler
305
-
306
- reconciler = Reconciler(get_registry())
307
- reconciler._screen_re_render = lambda: _request_render(host)
308
- return reconciler
309
-
310
-
311
- def _schedule_render_async(host: Any) -> bool:
312
- """Schedule a render for a later platform turn, if supported."""
313
- return False
314
-
315
-
316
- def _flush_scheduled_renders(hosts: Sequence[Any]) -> None:
317
- """Run renders that were deferred out of a native event callback."""
318
- for host in hosts:
319
- host._render_scheduled = False
320
- if host._reconciler is None:
321
- continue
322
- if host._is_rendering:
323
- host._render_queued = True
324
- _schedule_render_async(host)
325
- continue
326
- _re_render(host)
327
-
328
-
329
- def _seed_initial_viewport(host: Any) -> None:
330
- """Give the reconciler a plausible viewport size *before* the first mount.
331
-
332
- The authoritative size arrives right after attach (Android's
333
- ``OnLayoutChangeListener``, iOS ``viewDidLayoutSubviews``, the
334
- desktop stage size), but by then the mount commit has already run:
335
- without a viewport its layout pass is skipped, so mount-time
336
- [`use_layout_effect`][pythonnative.use_layout_effect] callbacks
337
- would observe no committed frames. Hosts opt in by providing
338
- ``_initial_viewport_size()``; the estimate only needs to be
339
- plausible (screen-sized), not pixel-perfect.
340
- """
341
- getter = getattr(host, "_initial_viewport_size", None)
342
- if not callable(getter):
343
- return
344
- try:
345
- size = getter()
346
- except Exception:
347
- return
348
- if not size:
349
- return
350
- width, height = size
351
- if width > 0 and height > 0:
352
- host._reconciler.set_viewport_size(float(width), float(height))
353
-
354
-
355
- def _on_create(host: Any) -> None:
356
- from .hooks import NavigationHandle, Provider, _NavigationContext
357
-
358
- # ``on_create`` is idempotent across native-view recreations. On
359
- # Android the FragmentManager destroys and recreates a screen's
360
- # view every time the user pops back to it, and the platform
361
- # template calls ``screen.on_create()`` again from
362
- # ``onViewCreated``, but the Python screen object (and therefore
363
- # the reconciler, hook state, focus subscribers, etc.) persists
364
- # across that. Re-running the full mount path here would reset
365
- # use_state, clobber use_focus_effect subscriptions, and break
366
- # navigation handles held by existing components, which is why
367
- # the focus counter never advanced past ``1`` before this guard.
368
- # If we're already mounted, just re-attach the existing root view
369
- # to the (newly created) native container; ``on_resume`` will
370
- # fire the focus subscribers separately.
371
- _register_redbox_reporter(host)
372
- if host._reconciler is not None and host._root_native_view is not None:
373
- host._attach_root(host._root_native_view)
374
- return
375
-
376
- host._nav_handle = NavigationHandle(host)
377
- host._reconciler = _new_reconciler(host)
378
- _seed_initial_viewport(host)
379
-
380
- try:
381
- app_element = _render_app(host)
382
- provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
383
-
384
- host._is_rendering = True
385
- try:
386
- host._root_native_view = host._reconciler.mount(provider_element)
387
- host._attach_root(host._root_native_view)
388
- _drain_renders(host)
389
- finally:
390
- host._is_rendering = False
391
- except Exception as exc:
392
- if not diagnostics.is_dev():
393
- raise
394
- _show_redbox(host, exc, phase="mount")
395
-
396
-
397
- def _request_render(host: Any) -> None:
398
- """Request a render pass.
399
-
400
- If a render is already in progress (state changed mid-render or
401
- inside an effect), the request is queued and drained at the end of
402
- the current pass so the reconciler is never re-entered.
403
- """
404
- if host._reconciler is None:
405
- return
406
- if host._is_rendering:
407
- host._render_queued = True
408
- return
409
- if _schedule_render_async(host):
410
- return
411
- _re_render(host)
412
-
413
-
414
- def _re_render(host: Any) -> None:
415
- """Run one *local* render pass, then drain any renders queued during it.
416
-
417
- State setters mark only their own component subtree dirty (see
418
- [`mark_dirty`][pythonnative.reconciler.Reconciler.mark_dirty]), so
419
- this drains the reconciler's dirty set via
420
- [`flush_dirty`][pythonnative.reconciler.Reconciler.flush_dirty]
421
- instead of re-running the whole ``App`` from the root. The app's
422
- element tree is only rebuilt from scratch on mount, navigation, and
423
- hot reload.
424
- """
425
- _log_pn("_re_render: starting local render pass")
426
- try:
427
- host._is_rendering = True
428
- try:
429
- host._render_queued = False
430
- _commit_dirty(host)
431
- _drain_renders(host)
432
- finally:
433
- host._is_rendering = False
434
- except Exception as exc:
435
- if not diagnostics.is_dev():
436
- raise
437
- _show_redbox(host, exc, phase="render")
438
- _log_pn("_re_render: done")
439
-
440
-
441
- def _commit_dirty(host: Any) -> None:
442
- """Flush the reconciler's dirty components and re-attach the root if it changed."""
443
- new_root = host._reconciler.flush_dirty()
444
- if new_root is not host._root_native_view:
445
- _log_pn(f"_commit_dirty: ROOT VIEW CHANGED ({id(host._root_native_view)} -> {id(new_root)}); reattaching")
446
- host._detach_root(host._root_native_view)
447
- host._root_native_view = new_root
448
- host._attach_root(new_root)
449
-
450
-
451
- def _drain_renders(host: Any) -> None:
452
- """Flush additional renders queued by effects that set state.
453
-
454
- Capped at `_MAX_RENDER_PASSES` to break runaway feedback loops
455
- (e.g., an effect that unconditionally calls a setter).
456
- """
457
- for i in range(_MAX_RENDER_PASSES):
458
- if not host._render_queued:
459
- break
460
- _log_pn(f"_drain_renders: draining pass #{i + 1}")
461
- host._render_queued = False
462
- _commit_dirty(host)
463
-
464
-
465
- # ======================================================================
466
- # RedBox (dev-mode error overlay)
467
- # ======================================================================
468
- #
469
- # In dev mode (``pn preview``, ``pn run`` with hot reload, ``PN_DEV=1``)
470
- # an uncaught error from a mount, render, effect, or event handler
471
- # replaces the screen with a full-screen traceback instead of crashing
472
- # the process or dying silently in the log. The overlay is a normal
473
- # PythonNative element tree mounted by a *dedicated* reconciler so a
474
- # broken app tree can't take the error display down with it. Fixing the
475
- # code and saving clears it via hot reload; the Dismiss button restores
476
- # the previous native root (which is still mounted underneath).
477
-
478
-
479
- def _redbox_element(exc: BaseException, phase: str, on_dismiss: Any) -> Any:
480
- """Build the RedBox element tree for ``exc``."""
481
- from .components import Button, Column, ScrollView, Text
482
-
483
- trace = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
484
- message = str(exc) or "(no message)"
485
- return Column(
486
- Column(
487
- Text(
488
- f"{type(exc).__name__} in {phase}",
489
- style={"color": "#FFD3DA", "font_size": 13, "bold": True},
490
- ),
491
- Text(message, style={"color": "#FFFFFF", "font_size": 17, "bold": True}),
492
- style={
493
- "background_color": "#C4283C",
494
- "padding": 16,
495
- "padding_top": 56,
496
- "spacing": 6,
497
- },
498
- ),
499
- ScrollView(
500
- Text(trace, style={"color": "#FF9AA8", "font_size": 12}),
501
- style={"flex": 1, "padding": 12},
502
- ),
503
- Column(
504
- Button("Dismiss", on_press=on_dismiss, style={"color": "#FFFFFF"}),
505
- Text(
506
- "Fix the error and save to reload.",
507
- style={"color": "#8E8E93", "font_size": 12, "text_align": "center"},
508
- ),
509
- style={"padding": 12, "padding_bottom": 32, "spacing": 4},
510
- ),
511
- style={"flex": 1, "background_color": "#1C1C1E"},
512
- )
513
-
514
-
515
- def _show_redbox(host: Any, exc: BaseException, phase: str = "render") -> None:
516
- """Mount the dev error overlay over the host's screen.
517
-
518
- Safe to call repeatedly (a newer error replaces the current
519
- overlay) and from any thread (the mount hops to the main thread).
520
- Failures inside the RedBox itself fall back to printing both
521
- tracebacks so the original error is never lost.
522
- """
523
- _log_pn(f"_show_redbox: {type(exc).__name__} during {phase}")
524
- try:
525
- print(f"[PN] {phase} error:", file=sys.stderr)
526
- traceback.print_exception(type(exc), exc, exc.__traceback__)
527
- except Exception:
528
- pass
529
-
530
- def _mount() -> None:
531
- try:
532
- _clear_redbox(host, reattach=False)
533
- from .native_views import get_registry
534
- from .reconciler import Reconciler
535
-
536
- redbox = Reconciler(get_registry())
537
- redbox._screen_re_render = redbox.flush_dirty
538
- element = _redbox_element(exc, phase, lambda: _clear_redbox(host))
539
- root = redbox.mount(element)
540
- width, height = (0.0, 0.0)
541
- if host._reconciler is not None:
542
- width, height = host._reconciler._viewport_size
543
- if width <= 0 or height <= 0:
544
- from . import platform_metrics
545
-
546
- dims = platform_metrics.get_window_dimensions()
547
- width, height = dims.width, dims.height
548
- if width > 0 and height > 0:
549
- redbox.set_viewport_size(width, height)
550
- host._redbox_reconciler = redbox
551
- host._redbox_root = root
552
- if host._root_native_view is not None:
553
- host._detach_root(host._root_native_view)
554
- host._attach_root(root)
555
- except Exception:
556
- print("[PN] RedBox failed to mount:", file=sys.stderr)
557
- traceback.print_exc()
558
-
559
- from .runtime import call_on_main_thread
560
-
561
- call_on_main_thread(_mount)
562
-
563
-
564
- def _clear_redbox(host: Any, reattach: bool = True) -> None:
565
- """Tear down the RedBox overlay and optionally restore the app root."""
566
- redbox = getattr(host, "_redbox_reconciler", None)
567
- if redbox is None:
568
- return
569
- host._redbox_reconciler = None
570
- host._redbox_root = None
571
- try:
572
- redbox.unmount()
573
- except Exception:
574
- pass
575
- if reattach and host._root_native_view is not None:
576
- try:
577
- host._attach_root(host._root_native_view)
578
- except Exception:
579
- pass
580
-
581
-
582
- def _register_redbox_reporter(host: Any) -> None:
583
- """Route runtime errors (events, async) for this host to the RedBox."""
584
- if not diagnostics.is_dev():
585
- return
586
- diagnostics.set_error_reporter(host, lambda exc, phase: _show_redbox(host, exc, phase))
587
-
588
-
589
- def _set_args(host: Any, args: Any) -> None:
590
- if isinstance(args, str):
591
- try:
592
- host._args = json.loads(args) or {}
593
- except Exception:
594
- host._args = {}
595
- return
596
- host._args = args if isinstance(args, dict) else {}
597
-
598
-
599
- def _enable_hot_reload(host: Any, manifest_path: str) -> None:
600
- host._hot_reload_manifest_path = manifest_path
601
- host._hot_reload_last_version = None
602
- # Hot reload only runs on debug builds, so treat it as the on-device
603
- # dev-mode switch (validation warnings, hook-order checks, RedBox).
604
- diagnostics.set_dev_mode(True)
605
- _register_redbox_reporter(host)
606
-
607
-
608
- def _hot_reload_tick(host: Any) -> bool:
609
- manifest_path = getattr(host, "_hot_reload_manifest_path", None)
610
- if not manifest_path:
611
- return False
612
-
613
- from .hot_reload import ModuleReloader
614
-
615
- last = getattr(host, "_hot_reload_last_version", None)
616
- manifest_exists = os.path.exists(manifest_path)
617
- if not manifest_exists and last is None:
618
- return False
619
-
620
- # The iOS template polls every 0.5s per UIViewController, so this
621
- # tick fires several times per second per host. The per-tick log is
622
- # gated behind ``PYTHONNATIVE_DEBUG`` to keep normal output quiet
623
- # while preserving the breadcrumb when investigating reload races.
624
- if _debug_enabled():
625
- manifest_version: Optional[str] = None
626
- if manifest_exists:
627
- try:
628
- with open(manifest_path, encoding="utf-8") as f:
629
- raw_version = json.load(f).get("version", "")
630
- manifest_version = str(raw_version) if raw_version else None
631
- except Exception:
632
- manifest_version = None
633
- action = "reload" if (manifest_version is not None and manifest_version != last) else "skip"
634
- _log_pn(
635
- f"_hot_reload_tick: host=0x{id(host):x} component={host._component_path} "
636
- f"last={last!r} manifest={manifest_version!r} action={action}"
637
- )
638
-
639
- next_version = ModuleReloader.reload_from_manifest(
640
- host,
641
- manifest_path,
642
- last_version=last,
643
- )
644
- if next_version == last:
645
- return False
646
- host._hot_reload_last_version = next_version
647
- return True
648
-
649
-
650
- def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) -> None:
651
- """Reload modules and refresh the host's reconciler tree.
652
-
653
- Tries **Fast Refresh** first: the changed modules are reloaded
654
- and every ``element.type`` reference in the current ``VNode``
655
- tree is rewritten to point at the new module's functions. The
656
- next render then runs the new bodies through the existing hook
657
- slots, so component state survives.
658
-
659
- The reload set is **expanded** to include every currently-imported
660
- module under the entry-point's top-level package (see
661
- [`expand_reload_targets`][pythonnative.hot_reload.ModuleReloader.expand_reload_targets]).
662
- This catches transitive ``from ... import`` bindings that would
663
- otherwise remain stale: if ``app/main.py`` does
664
- ``from app.screens.home import HomeScreen`` and the user edits
665
- ``home.py``, reloading just ``app.screens.home`` leaves
666
- ``app.main.HomeScreen`` pointing at the pre-edit function, so the
667
- new render emits stale element types and the reconciler is forced
668
- to unmount and remount the screen (losing state and showing old
669
- code). Reloading every user-app module in dependency-friendly
670
- order, with the entry-point last, keeps every binding fresh.
671
-
672
- If Fast Refresh fails (the new module raised at import time, no
673
- replacements could be located, or the next render itself
674
- threw), the host falls back to a full remount: a brand-new
675
- reconciler tree is mounted into the same native root. State is
676
- lost but the app keeps running so the developer can fix the
677
- error and try again.
678
- """
679
- from .hot_reload import ModuleReloader
680
-
681
- requested = list(changed_modules or [])
682
- targets = ModuleReloader.expand_reload_targets(requested, host._component_path)
683
-
684
- pending_version = getattr(host, "_hot_reload_pending_version", None)
685
- already_loaded = pending_version is not None and pending_version == ModuleReloader._last_reloaded_version
686
- _log_pn(
687
- f"_reload_host: host=0x{id(host):x} component={host._component_path} "
688
- f"requested={requested!r} targets={len(targets)} version={pending_version!r} "
689
- f"action={'reuse_modules' if already_loaded else 'reload_modules'}"
690
- )
691
-
692
- reloaded = ModuleReloader.reload_modules_for_version(targets, pending_version)
693
- if not reloaded:
694
- _log_pn(f"_reload_host: no modules could be reloaded from {targets!r}; aborting")
695
- return
696
-
697
- try:
698
- new_component = _import_component(host._component_path)
699
- except Exception as exc:
700
- _log_pn(f"_reload_host: re-import failed: {exc!r}; aborting reload")
701
- if diagnostics.is_dev():
702
- # A syntax error (or import-time crash) in the edited file:
703
- # show it in the RedBox so the developer sees it on device
704
- # instead of the app silently keeping the old code.
705
- _show_redbox(host, exc, phase="hot reload import")
706
- return
707
- host._component = new_component
708
-
709
- if host._reconciler is None:
710
- _log_pn(f"_reload_host: host=0x{id(host):x} reconciler=None; skipping refresh")
711
- return
712
-
713
- # A reload always replaces whatever error state was on screen.
714
- # Re-attach the current root now: Fast Refresh patches that tree in
715
- # place (attach must happen regardless of whether it succeeds), and
716
- # a full remount swaps in its own fresh root anyway.
717
- _clear_redbox(host)
718
-
719
- if _try_fast_refresh(host, reloaded):
720
- print(f"[hot-reload] Fast Refresh: {', '.join(requested) or ', '.join(reloaded)}", file=sys.stderr)
721
- return
722
-
723
- try:
724
- _full_remount(host, reloaded)
725
- except Exception as exc:
726
- if not diagnostics.is_dev():
727
- raise
728
- _show_redbox(host, exc, phase="hot reload")
729
-
730
-
731
- def _try_fast_refresh(host: Any, reloaded_modules: Sequence[str]) -> bool:
732
- """Attempt an in-place component swap + re-render.
733
-
734
- Returns ``True`` only if the swap happened and the subsequent
735
- render completed without raising. On exception we restore the
736
- pre-render reconciler state so the caller can fall back to a
737
- full remount.
738
- """
739
- from .hooks import Provider, _NavigationContext
740
- from .hot_reload import ModuleReloader
741
-
742
- reconciler = host._reconciler
743
- if reconciler is None or reconciler._tree is None:
744
- return False
745
-
746
- rewrote = ModuleReloader.refresh_in_place(reconciler, reloaded_modules)
747
- if not rewrote:
748
- return False
749
-
750
- host._is_rendering = True
751
- try:
752
- app_element = _render_app(host)
753
- provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
754
- new_root = reconciler.reconcile(provider_element)
755
- if new_root is not host._root_native_view:
756
- host._detach_root(host._root_native_view)
757
- host._root_native_view = new_root
758
- host._attach_root(new_root)
759
- except Exception as e:
760
- _log_pn(f"_try_fast_refresh: render failed after swap: {e!r}; falling back to remount")
761
- return False
762
- finally:
763
- host._is_rendering = False
764
-
765
- _drain_renders(host)
766
- return True
767
-
768
-
769
- def _full_remount(host: Any, reloaded_modules: Sequence[str]) -> None:
770
- """Destroy the existing tree and mount a fresh one.
771
-
772
- Used by [`_reload_host`][pythonnative.screen._reload_host] as the
773
- fallback path when Fast Refresh cannot apply (e.g. the user
774
- deleted a component that was on screen).
775
- """
776
- from .hooks import NavigationHandle, Provider, _NavigationContext
777
-
778
- old_reconciler = host._reconciler
779
- old_root = host._root_native_view
780
- old_nav = host._nav_handle
781
-
782
- new_reconciler = _new_reconciler(host)
783
- host._reconciler = new_reconciler
784
- host._nav_handle = NavigationHandle(host)
785
- host._is_rendering = True
786
- try:
787
- app_element = _render_app(host)
788
- provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
789
- new_root = new_reconciler.mount(provider_element)
790
- except Exception:
791
- host._reconciler = old_reconciler
792
- host._nav_handle = old_nav
793
- raise
794
- finally:
795
- host._is_rendering = False
796
-
797
- if old_reconciler is not None:
798
- # ``unmount`` queues the DestroyOps *and* flushes them to the
799
- # backend in one batch (releasing native views and their event
800
- # registrations).
801
- old_reconciler.unmount()
802
- if old_root is not None:
803
- host._detach_root(old_root)
804
-
805
- host._root_native_view = new_root
806
- host._attach_root(new_root)
807
- _drain_renders(host)
808
- print(f"[hot-reload] Remounted: {', '.join(reloaded_modules)}", file=sys.stderr)
809
-
810
-
811
- # ======================================================================
812
- # Platform implementations
813
- # ======================================================================
814
-
815
- if IS_ANDROID:
816
- from java import dynamic_proxy, jclass
817
-
818
- _ANDROID_SCHEDULED_RENDER_HOSTS: Dict[int, Any] = {}
819
- _android_render_scheduler_handler: Any = None
820
- _android_render_scheduler_runnable: Any = None
821
- _android_main_looper: Any = None
822
-
823
- def _is_android_main_thread() -> bool:
824
- """Return True when running on Android's main looper thread."""
825
- global _android_main_looper
826
- try:
827
- Looper = jclass("android.os.Looper")
828
- if _android_main_looper is None:
829
- _android_main_looper = Looper.getMainLooper()
830
- return Looper.myLooper() == _android_main_looper
831
- except Exception:
832
- return threading.current_thread() is threading.main_thread()
833
-
834
- def _flush_android_scheduled_renders() -> None:
835
- hosts = list(_ANDROID_SCHEDULED_RENDER_HOSTS.values())
836
- _ANDROID_SCHEDULED_RENDER_HOSTS.clear()
837
- _flush_scheduled_renders(hosts)
838
-
839
- def _schedule_render_async(host: Any) -> bool:
840
- global _android_render_scheduler_handler, _android_render_scheduler_runnable
841
- if not IS_ANDROID:
842
- return False
843
- if getattr(host, "_render_scheduled", False):
844
- return True
845
- if _is_android_main_thread():
846
- return False
847
-
848
- host._render_scheduled = True
849
- _ANDROID_SCHEDULED_RENDER_HOSTS[id(host)] = host
850
- try:
851
- if _android_render_scheduler_handler is None:
852
- Handler = jclass("android.os.Handler")
853
- Looper = jclass("android.os.Looper")
854
- Runnable = jclass("java.lang.Runnable")
855
- _android_render_scheduler_handler = Handler(Looper.getMainLooper())
856
-
857
- class _PNRenderRunnable(dynamic_proxy(Runnable)): # type: ignore[misc]
858
- def run(self) -> None:
859
- _flush_android_scheduled_renders()
860
-
861
- _android_render_scheduler_runnable = _PNRenderRunnable()
862
- _android_render_scheduler_handler.post(_android_render_scheduler_runnable)
863
- return True
864
- except Exception:
865
- host._render_scheduled = False
866
- _ANDROID_SCHEDULED_RENDER_HOSTS.pop(id(host), None)
867
- return False
868
-
869
- def _android_publish_window_insets(view: Any) -> None:
870
- """Read system-bar insets from *view* and publish them to platform_metrics.
871
-
872
- Most production Android themes already exclude the system
873
- navigation bar from the activity content area, so the bottom
874
- inset reported here is typically ``0`` on classic devices.
875
- On edge-to-edge themes (or 3-button gesture nav strips), the
876
- bottom inset is non-zero and the tab bar needs to claim that
877
- space so the system gesture indicator does not overlap its
878
- labels.
879
-
880
- The function is best-effort: API levels < 30 expose
881
- ``getSystemWindowInsetBottom`` instead of the typed
882
- ``getInsets(systemBars())`` API, and very old phones may
883
- not expose ``getRootWindowInsets`` at all. All branches are
884
- wrapped in ``try/except`` because diagnostics here must
885
- never crash a screen host.
886
- """
887
- try:
888
- from . import platform_metrics
889
- except Exception:
890
- return
891
- try:
892
- insets_obj = view.getRootWindowInsets()
893
- if insets_obj is None:
894
- return
895
- density = float(view.getResources().getDisplayMetrics().density) or 1.0
896
- top_px = 0
897
- left_px = 0
898
- bottom_px = 0
899
- right_px = 0
900
- try:
901
- WindowInsets = jclass("android.view.WindowInsets")
902
- Type = WindowInsets.Type
903
- bars = Type.systemBars()
904
- typed = insets_obj.getInsets(bars)
905
- top_px = int(typed.top)
906
- left_px = int(typed.left)
907
- bottom_px = int(typed.bottom)
908
- right_px = int(typed.right)
909
- except Exception:
910
- top_px = int(insets_obj.getSystemWindowInsetTop() or 0)
911
- left_px = int(insets_obj.getSystemWindowInsetLeft() or 0)
912
- bottom_px = int(insets_obj.getSystemWindowInsetBottom() or 0)
913
- right_px = int(insets_obj.getSystemWindowInsetRight() or 0)
914
- platform_metrics.set_safe_area_insets(
915
- top_px / density,
916
- left_px / density,
917
- bottom_px / density,
918
- right_px / density,
919
- )
920
- # IME (soft keyboard) insets: API 30+ exposes them through
921
- # the typed getInsets API. The keyboard overlaps the system
922
- # navigation bar, so the visible keyboard height is the IME
923
- # inset minus the permanent bottom bar inset. Older API
924
- # levels don't report IME insets here; those devices rely
925
- # on ``adjustResize`` shrinking the window (the viewport
926
- # push handles the layout) and the height stays 0.
927
- try:
928
- ime = insets_obj.getInsets(Type.ime())
929
- ime_px = max(0, int(ime.bottom) - bottom_px)
930
- platform_metrics.set_keyboard_height(ime_px / density)
931
- except Exception:
932
- pass
933
- except Exception:
934
- pass
935
-
936
- def _android_publish_color_scheme(activity: Any) -> None:
937
- """Read the system night mode from the activity and publish it.
938
-
939
- Android delivers appearance changes as a configuration change,
940
- which recreates the activity by default, so publishing on
941
- ``on_create`` / ``on_resume`` covers both the initial value and
942
- subsequent flips.
943
- """
944
- try:
945
- from . import appearance
946
-
947
- Configuration = jclass("android.content.res.Configuration")
948
- ui_mode = int(activity.getResources().getConfiguration().uiMode)
949
- night = ui_mode & int(Configuration.UI_MODE_NIGHT_MASK)
950
- is_dark = night == int(Configuration.UI_MODE_NIGHT_YES)
951
- appearance.set_system_color_scheme("dark" if is_dark else "light")
952
- except Exception:
953
- pass
954
-
955
- def _android_register_insets_listener(host: Any, view: Any) -> None:
956
- """Re-publish insets whenever the window's insets change.
957
-
958
- The ``OnLayoutChangeListener`` in ``_android_register_layout_listener``
959
- only fires when the container is re-measured, which covers
960
- ``adjustResize`` keyboards but not inset-only changes (e.g. an
961
- edge-to-edge window where the IME floats over the content).
962
- ``setOnApplyWindowInsetsListener`` fires on every insets pass,
963
- so keyboard show/hide reaches ``platform_metrics`` immediately.
964
- """
965
- try:
966
- View = jclass("android.view.View")
967
-
968
- class _PNInsetsListener(dynamic_proxy(View.OnApplyWindowInsetsListener)): # type: ignore[misc]
969
- def onApplyWindowInsets(self, v: Any, insets: Any) -> Any:
970
- try:
971
- _android_publish_window_insets(v)
972
- except Exception:
973
- pass
974
- return insets
975
-
976
- listener = _PNInsetsListener()
977
- view.setOnApplyWindowInsetsListener(listener)
978
- host._insets_listener = listener # retain to prevent GC
979
- except Exception:
980
- pass
981
-
982
- def _android_register_layout_listener(host: Any, view: Any) -> None:
983
- """Push the container's measured size into the reconciler whenever it changes."""
984
- try:
985
- View = jclass("android.view.View")
986
-
987
- class _PNLayoutChangeListener(dynamic_proxy(View.OnLayoutChangeListener)): # type: ignore[misc]
988
- def __init__(self, host_obj: Any) -> None:
989
- super().__init__()
990
- self.host_obj = host_obj
991
-
992
- def onLayoutChange(
993
- self,
994
- v: Any,
995
- left: int,
996
- top: int,
997
- right: int,
998
- bottom: int,
999
- old_left: int,
1000
- old_top: int,
1001
- old_right: int,
1002
- old_bottom: int,
1003
- ) -> None:
1004
- try:
1005
- # Publish insets *before* the viewport push so
1006
- # the layout pass triggered by the size change
1007
- # sees the latest values; otherwise inset-aware
1008
- # handlers (e.g., a future ``SafeAreaView``)
1009
- # would lay out one frame stale and the user
1010
- # would see a flicker on first paint.
1011
- _android_publish_window_insets(v)
1012
- density = float(v.getResources().getDisplayMetrics().density) or 1.0
1013
- _push_viewport_size(self.host_obj, (right - left) / density, (bottom - top) / density)
1014
- except Exception:
1015
- pass
1016
-
1017
- listener = _PNLayoutChangeListener(host)
1018
- view.addOnLayoutChangeListener(listener)
1019
- host._layout_listener = listener # retain to prevent GC
1020
- except Exception:
1021
- pass
1022
-
1023
- def _android_push_initial_viewport(host: Any, view: Any) -> None:
1024
- """Push the current measured size if available (no-op until layout completes)."""
1025
- try:
1026
- # Publish insets first so the very first layout pass sees
1027
- # them. Otherwise handlers reading insets at first paint
1028
- # would get ``(0, 0, 0, 0)`` and re-measure once the
1029
- # ``OnLayoutChangeListener`` fires moments later, a
1030
- # measurable flicker (~50–200 ms on a stock Pixel
1031
- # emulator).
1032
- _android_publish_window_insets(view)
1033
- w = int(view.getWidth() or 0)
1034
- h = int(view.getHeight() or 0)
1035
- if w > 0 and h > 0:
1036
- density = float(view.getResources().getDisplayMetrics().density) or 1.0
1037
- _push_viewport_size(host, w / density, h / density)
1038
- else:
1039
- # Fall back to display metrics so we always have a non-zero
1040
- # viewport even before the first layout pass; the listener
1041
- # will refine it as soon as the container is measured.
1042
- metrics = view.getResources().getDisplayMetrics()
1043
- density = float(metrics.density) or 1.0
1044
- _push_viewport_size(host, metrics.widthPixels / density, metrics.heightPixels / density)
1045
- except Exception:
1046
- pass
1047
-
1048
- class _ScreenHost:
1049
- """Android host backed by an `Activity` and fragment-based navigation.
1050
-
1051
- Owned by the screen fragment template. Bridges Android lifecycle
1052
- callbacks (`onCreate`, `onPause`, etc.) to the reconciler and
1053
- the function component.
1054
- """
1055
-
1056
- def __init__(self, native_instance: Any, component_path: str, component_func: Any) -> None:
1057
- self.native_instance = native_instance
1058
- set_android_context(native_instance)
1059
- _init_host_common(self, component_path, component_func)
1060
-
1061
- def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
1062
- """Estimate the viewport from display metrics (pre-attach)."""
1063
- try:
1064
- metrics = self.native_instance.getResources().getDisplayMetrics()
1065
- density = float(metrics.density) or 1.0
1066
- return (metrics.widthPixels / density, metrics.heightPixels / density)
1067
- except Exception:
1068
- return None
1069
-
1070
- def on_create(self) -> None:
1071
- _android_publish_color_scheme(self.native_instance)
1072
- _on_create(self)
1073
-
1074
- def on_start(self) -> None:
1075
- pass
1076
-
1077
- def on_resume(self) -> None:
1078
- _android_publish_color_scheme(self.native_instance)
1079
- _set_host_focused(self, True)
1080
-
1081
- def on_layout(self) -> None:
1082
- # Android pushes viewport changes through the
1083
- # ``OnLayoutChangeListener`` registered in ``_attach_root``;
1084
- # this no-op exists so callers can fire the same lifecycle
1085
- # event on both platforms.
1086
- pass
1087
-
1088
- def on_pause(self) -> None:
1089
- _set_host_focused(self, False)
1090
-
1091
- def on_stop(self) -> None:
1092
- pass
1093
-
1094
- def on_destroy(self) -> None:
1095
- _destroy_host(self)
1096
-
1097
- def on_back_pressed(self) -> bool:
1098
- return _host_back_pressed(self)
1099
-
1100
- def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1101
- _enable_hot_reload(self, manifest_path)
1102
-
1103
- def hot_reload_tick(self) -> bool:
1104
- return _hot_reload_tick(self)
1105
-
1106
- def reload(self, changed_modules: Optional[Sequence[str]] = None) -> None:
1107
- _reload_host(self, changed_modules)
1108
-
1109
- def on_restart(self) -> None:
1110
- pass
1111
-
1112
- def on_save_instance_state(self) -> None:
1113
- pass
1114
-
1115
- def on_restore_instance_state(self) -> None:
1116
- pass
1117
-
1118
- def set_args(self, args: Any) -> None:
1119
- _set_args(self, args)
1120
-
1121
- def _get_nav_args(self) -> Dict[str, Any]:
1122
- return self._args
1123
-
1124
- def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None:
1125
- screen_path = _resolve_component_path(component)
1126
- Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator")
1127
- args_json = json.dumps(args) if args else None
1128
- Navigator.push(self.native_instance, screen_path, args_json)
1129
-
1130
- def _pop(self) -> None:
1131
- try:
1132
- Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator")
1133
- Navigator.pop(self.native_instance)
1134
- except Exception:
1135
- self.native_instance.finish()
1136
-
1137
- def _reset_to_root(self) -> None:
1138
- """Pop everything above the root view-controller (best-effort)."""
1139
- try:
1140
- Navigator = jclass(f"{self.native_instance.getPackageName()}.Navigator")
1141
- reset_fn = getattr(Navigator, "popToRoot", None)
1142
- if reset_fn is not None:
1143
- reset_fn(self.native_instance)
1144
- except Exception:
1145
- pass
1146
-
1147
- def _set_screen_options(self, options: Dict[str, Any]) -> None:
1148
- """Bind screen options (title, etc.) to the native action bar."""
1149
- title = options.get("title") if isinstance(options, dict) else None
1150
- try:
1151
- activity = self.native_instance
1152
- if hasattr(activity, "setTitle") and title:
1153
- activity.setTitle(title)
1154
- except Exception:
1155
- pass
1156
-
1157
- def _attach_root(self, native_view: Any) -> None:
1158
- container = None
1159
- try:
1160
- from .utils import get_android_fragment_container
1161
-
1162
- container = get_android_fragment_container()
1163
- try:
1164
- container.removeAllViews()
1165
- except Exception:
1166
- pass
1167
- # When the user pops back to a previously mounted screen,
1168
- # ``native_view`` is the root from the prior mount and may
1169
- # still be parented under the old (destroyed) FrameLayout.
1170
- # ViewGroup.addView() throws if a view already has a
1171
- # parent, so detach it from the old one before re-attaching
1172
- # to the freshly created container.
1173
- try:
1174
- old_parent = native_view.getParent()
1175
- if old_parent is not None:
1176
- old_parent.removeView(native_view)
1177
- except Exception:
1178
- pass
1179
- LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
1180
- lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
1181
- container.addView(native_view, lp)
1182
- except Exception:
1183
- self.native_instance.setContentView(native_view)
1184
- container = native_view
1185
-
1186
- if container is not None:
1187
- _android_register_layout_listener(self, container)
1188
- _android_register_insets_listener(self, container)
1189
- _android_push_initial_viewport(self, container)
1190
-
1191
- def _detach_root(self, native_view: Any) -> None:
1192
- # Remove this host's specific root view from whatever parent
1193
- # holds it. Never clear the shared fragment container: when a
1194
- # fragment is popped, its ``onDestroy`` (and therefore this
1195
- # detach) runs *after* the screen below has already re-attached
1196
- # its own root to the container, so a ``removeAllViews()`` here
1197
- # would blank the restored screen.
1198
- if native_view is None:
1199
- return
1200
- try:
1201
- parent = native_view.getParent()
1202
- if parent is not None:
1203
- parent.removeView(native_view)
1204
- except Exception:
1205
- pass
1206
-
1207
- def set_viewport_size(self, width: float, height: float) -> None:
1208
- """Public hook for native code to push viewport sizes (Maestro/tests)."""
1209
- _push_viewport_size(self, width, height)
1210
-
1211
- elif IS_DESKTOP:
1212
- # ------------------------------------------------------------------
1213
- # Desktop preview host (Tkinter), driven by ``pn preview``.
1214
- #
1215
- # The screen host owns the reconciler + lifecycle just like the
1216
- # device hosts; placement of the root view and the navigation stack
1217
- # are delegated to the ``DesktopApp`` controller in
1218
- # ``pythonnative.preview`` (passed in as ``native_instance``). The
1219
- # controller runs the Tk event loop on the main thread and polls
1220
- # ``drain_desktop_scheduled_renders`` so renders requested from the
1221
- # asyncio worker thread are applied on the main thread.
1222
- # ------------------------------------------------------------------
1223
-
1224
- _DESKTOP_SCHEDULED_RENDER_HOSTS: Dict[int, Any] = {}
1225
- _desktop_render_lock = threading.Lock()
1226
-
1227
- def _schedule_render_async(host: Any) -> bool:
1228
- """Queue an off-main-thread render for the Tk poll loop to drain.
1229
-
1230
- Renders requested on the Tk main thread (button handlers, etc.)
1231
- run synchronously (returns ``False``); requests from the asyncio
1232
- worker thread are queued and applied by
1233
- [`drain_desktop_scheduled_renders`][pythonnative.screen.drain_desktop_scheduled_renders].
1234
- """
1235
- if not IS_DESKTOP:
1236
- return False
1237
- if threading.current_thread() is threading.main_thread():
1238
- return False
1239
- if getattr(host, "_render_scheduled", False):
1240
- return True
1241
- host._render_scheduled = True
1242
- with _desktop_render_lock:
1243
- _DESKTOP_SCHEDULED_RENDER_HOSTS[id(host)] = host
1244
- return True
1245
-
1246
- def drain_desktop_scheduled_renders() -> None:
1247
- """Apply renders queued from worker threads (called on the main thread)."""
1248
- with _desktop_render_lock:
1249
- hosts = list(_DESKTOP_SCHEDULED_RENDER_HOSTS.values())
1250
- _DESKTOP_SCHEDULED_RENDER_HOSTS.clear()
1251
- _flush_scheduled_renders(hosts)
1252
-
1253
- class _ScreenHost:
1254
- """Desktop host backed by a Tk window and an in-process nav stack.
1255
-
1256
- Created by ``pythonnative.preview`` for
1257
- each screen on the navigation stack. ``native_instance`` is the
1258
- ``DesktopApp`` controller, which provides the stage frame,
1259
- viewport size, and push/pop primitives.
1260
- """
1261
-
1262
- def __init__(self, native_instance: Any = None, component_path: str = "", component_func: Any = None) -> None:
1263
- self.native_instance = native_instance
1264
- _init_host_common(self, component_path, component_func)
1265
-
1266
- def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
1267
- """Read the stage size from the DesktopApp controller (pre-attach)."""
1268
- app = self.native_instance
1269
- if app is None or not hasattr(app, "viewport_size"):
1270
- return None
1271
- try:
1272
- width, height = app.viewport_size()
1273
- return (float(width), float(height))
1274
- except Exception:
1275
- return None
1276
-
1277
- def on_create(self) -> None:
1278
- _on_create(self)
1279
-
1280
- def on_start(self) -> None:
1281
- pass
1282
-
1283
- def on_resume(self) -> None:
1284
- _set_host_focused(self, True)
1285
-
1286
- def on_layout(self) -> None:
1287
- pass
1288
-
1289
- def on_pause(self) -> None:
1290
- _set_host_focused(self, False)
1291
-
1292
- def on_stop(self) -> None:
1293
- pass
1294
-
1295
- def on_destroy(self) -> None:
1296
- _destroy_host(self)
1297
-
1298
- def on_back_pressed(self) -> bool:
1299
- return _host_back_pressed(self)
1300
-
1301
- def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1302
- _enable_hot_reload(self, manifest_path)
1303
-
1304
- def hot_reload_tick(self) -> bool:
1305
- return _hot_reload_tick(self)
1306
-
1307
- def reload(self, changed_modules: Optional[Sequence[str]] = None) -> None:
1308
- _reload_host(self, changed_modules)
1309
-
1310
- def on_restart(self) -> None:
1311
- pass
1312
-
1313
- def on_save_instance_state(self) -> None:
1314
- pass
1315
-
1316
- def on_restore_instance_state(self) -> None:
1317
- pass
1318
-
1319
- def set_args(self, args: Any) -> None:
1320
- _set_args(self, args)
1321
-
1322
- def _get_nav_args(self) -> Dict[str, Any]:
1323
- return self._args
1324
-
1325
- def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None:
1326
- screen_path = _resolve_component_path(component)
1327
- app = self.native_instance
1328
- if app is None or not hasattr(app, "push_screen"):
1329
- raise RuntimeError("desktop navigation requires a running `pn preview` session")
1330
- app.push_screen(screen_path, args)
1331
-
1332
- def _pop(self) -> None:
1333
- app = self.native_instance
1334
- if app is not None and hasattr(app, "pop_screen"):
1335
- app.pop_screen()
1336
-
1337
- def _reset_to_root(self) -> None:
1338
- app = self.native_instance
1339
- if app is not None and hasattr(app, "reset_to_root"):
1340
- try:
1341
- app.reset_to_root()
1342
- except Exception:
1343
- pass
1344
-
1345
- def _set_screen_options(self, options: Dict[str, Any]) -> None:
1346
- title = options.get("title") if isinstance(options, dict) else None
1347
- app = self.native_instance
1348
- if title and app is not None and hasattr(app, "set_title"):
1349
- try:
1350
- app.set_title(str(title))
1351
- except Exception:
1352
- pass
1353
-
1354
- def _attach_root(self, native_view: Any) -> None:
1355
- from .native_views import desktop as _desktop_backend
1356
-
1357
- stage = _desktop_backend.get_root_container()
1358
- if stage is not None and native_view is not None:
1359
- try:
1360
- native_view.place(in_=stage, x=0, y=0, relwidth=1.0, relheight=1.0)
1361
- native_view.lift()
1362
- except Exception:
1363
- pass
1364
- app = self.native_instance
1365
- if app is not None and hasattr(app, "viewport_size"):
1366
- try:
1367
- width, height = app.viewport_size()
1368
- if width > 0 and height > 0:
1369
- _push_viewport_size(self, float(width), float(height))
1370
- except Exception:
1371
- pass
1372
-
1373
- def _detach_root(self, native_view: Any) -> None:
1374
- if native_view is not None:
1375
- try:
1376
- native_view.place_forget()
1377
- except Exception:
1378
- pass
1379
-
1380
- def set_viewport_size(self, width: float, height: float) -> None:
1381
- """Push a viewport-size change (called on window resize)."""
1382
- _push_viewport_size(self, width, height)
1383
-
1384
- else:
1385
- from typing import Dict as _Dict
1386
-
1387
- _rubicon_available = False
1388
- try:
1389
- from rubicon.objc import SEL, ObjCClass, ObjCInstance, objc_method
1390
-
1391
- _rubicon_available = True
1392
-
1393
- import gc as _gc
1394
-
1395
- _gc.disable()
1396
- except ImportError:
1397
- pass
1398
-
1399
- # Redirect Python's stdout/stderr through fd 2 so ``print()`` output is
1400
- # visible via ``xcrun simctl launch --console-pty``. This runs at
1401
- # ``pythonnative.screen`` import time, i.e. before any user app module
1402
- # (e.g. ``app.main``) is imported, so their top-level ``print()``
1403
- # calls are captured too. Gated on ``IS_IOS`` rather than rubicon-objc
1404
- # being importable, so installing the ``[ios]`` extra on macOS does
1405
- # not silently swap ``sys.stdout`` on a dev machine.
1406
- if IS_IOS:
1407
- try:
1408
- from . import _ios_log
1409
-
1410
- _ios_log.install()
1411
- except Exception:
1412
- pass
1413
-
1414
- _IOS_SCREEN_REGISTRY: _Dict[int, Any] = {}
1415
- _IOS_SCHEDULED_RENDER_HOSTS: _Dict[int, Any] = {}
1416
- _ios_render_scheduler_target: Any = None
1417
- _ios_native_render_scheduler: Any = None
1418
-
1419
- def _objc_addr(obj: Any) -> Optional[int]:
1420
- """Return the underlying address of an ``ObjCInstance`` as an int.
1421
-
1422
- rubicon-objc exposes the pointer as ``ObjCInstance.ptr``, but
1423
- the concrete type varies between releases:
1424
-
1425
- - On rubicon-objc 0.5.x ``ptr`` is ``bytes`` (the raw 8-byte,
1426
- little-endian address); ``int(ptr)`` raises ``ValueError``
1427
- because Python tries to parse the bytes as a decimal string.
1428
- - Older releases return a ``c_void_p`` for which ``int(ptr)``
1429
- works.
1430
- - Pure-Python integers also occur (e.g., when the caller has
1431
- already converted).
1432
-
1433
- This helper covers all three so the screen-host registry is
1434
- keyed under the same integer Swift sends back via
1435
- ``forward_lifecycle``. Returns ``None`` only if every conversion
1436
- path fails, in which case the caller logs a diagnostic.
1437
- """
1438
- ptr = getattr(obj, "ptr", None)
1439
- if ptr is None:
1440
- return None
1441
- if isinstance(ptr, (bytes, bytearray)):
1442
- try:
1443
- return int.from_bytes(ptr, byteorder=sys.byteorder, signed=False)
1444
- except Exception:
1445
- return None
1446
- if isinstance(ptr, int):
1447
- return ptr
1448
- value = getattr(ptr, "value", None)
1449
- if isinstance(value, int):
1450
- return value
1451
- try:
1452
- return int(ptr)
1453
- except Exception:
1454
- return None
1455
-
1456
- def _log_pn(msg: str) -> None:
1457
- """Emit optional diagnostics when ``PYTHONNATIVE_DEBUG`` is enabled."""
1458
- if not _debug_enabled():
1459
- return
1460
- try:
1461
- print(f"[PN] {msg}", flush=True)
1462
- except Exception:
1463
- pass
1464
-
1465
- def _ios_publish_color_scheme() -> None:
1466
- """Read the current UIKit interface style and publish it.
1467
-
1468
- ``UITraitCollection.currentTraitCollection`` reflects the
1469
- active appearance; ``userInterfaceStyle`` is 1 for light and
1470
- 2 for dark (0 = unspecified, treated as light). Called from
1471
- the lifecycle callbacks that fire on appearance flips
1472
- (``viewDidLayoutSubviews`` / ``viewDidAppear``).
1473
- """
1474
- try:
1475
- from . import appearance
1476
-
1477
- UITraitCollection = ObjCClass("UITraitCollection")
1478
- style_value = int(UITraitCollection.currentTraitCollection.userInterfaceStyle)
1479
- appearance.set_system_color_scheme("dark" if style_value == 2 else "light")
1480
- except Exception:
1481
- pass
1482
-
1483
- def _ios_register_screen(vc_instance: Any, host_obj: Any) -> None:
1484
- ptr = _objc_addr(vc_instance)
1485
- if ptr is None:
1486
- _log_pn(f"register_screen: could not extract address from {type(vc_instance).__name__}")
1487
- return
1488
- _IOS_SCREEN_REGISTRY[ptr] = host_obj
1489
- _log_pn(f"register_screen: addr={ptr} (registry size={len(_IOS_SCREEN_REGISTRY)})")
1490
-
1491
- def _ios_unregister_screen(vc_instance: Any) -> None:
1492
- ptr = _objc_addr(vc_instance)
1493
- if ptr is None:
1494
- return
1495
- _IOS_SCREEN_REGISTRY.pop(ptr, None)
1496
-
1497
- def _flush_ios_scheduled_renders() -> None:
1498
- hosts = list(_IOS_SCHEDULED_RENDER_HOSTS.values())
1499
- _IOS_SCHEDULED_RENDER_HOSTS.clear()
1500
- if hosts:
1501
- _log_pn(f"render_scheduler: flushing {len(hosts)} host(s)")
1502
- _flush_scheduled_renders(hosts)
1503
-
1504
- def drain_ios_scheduled_renders() -> None:
1505
- """Entry point used by the iOS template to drain pending renders."""
1506
- _flush_ios_scheduled_renders()
1507
-
1508
- def _schedule_ios_native_render_drain() -> bool:
1509
- """Wake the iOS template so it drains renders on the main thread."""
1510
- global _ios_native_render_scheduler
1511
- try:
1512
- if _ios_native_render_scheduler is None:
1513
- import ctypes as _ct
1514
-
1515
- scheduler = _ct.CDLL(None).pn_schedule_render_drain
1516
- scheduler.restype = None
1517
- scheduler.argtypes = []
1518
- _ios_native_render_scheduler = scheduler
1519
- _ios_native_render_scheduler()
1520
- return True
1521
- except Exception as exc:
1522
- _log_pn(f"render_scheduler: native iOS wake failed: {exc!r}")
1523
- return False
1524
-
1525
- def forward_lifecycle(native_addr: int, event: str) -> None:
1526
- """Forward a Swift `UIViewController` lifecycle event to its host.
1527
-
1528
- Args:
1529
- native_addr: Pointer (`int`) of the calling
1530
- `UIViewController` instance, used to look up the
1531
- registered host.
1532
- event: Lifecycle method name (e.g., `"on_resume"`).
1533
- """
1534
- try:
1535
- key = int(native_addr)
1536
- except Exception as e:
1537
- _log_pn(f"forward_lifecycle: bad native_addr={native_addr!r}: {e!r}")
1538
- return
1539
- host = _IOS_SCREEN_REGISTRY.get(key)
1540
- if host is None:
1541
- _log_pn(
1542
- f"forward_lifecycle: NO HOST for event={event!r} addr={key} "
1543
- f"(registry has {len(_IOS_SCREEN_REGISTRY)} entry(ies): "
1544
- f"{list(_IOS_SCREEN_REGISTRY.keys())})"
1545
- )
1546
- return
1547
- handler = getattr(host, event, None)
1548
- if handler is None:
1549
- _log_pn(f"forward_lifecycle: host has no '{event}' attr")
1550
- return
1551
- try:
1552
- handler()
1553
- except Exception as e:
1554
- _log_pn(f"forward_lifecycle: '{event}' handler raised: {e!r}")
1555
-
1556
- if _rubicon_available and IS_IOS:
1557
- NSObject = ObjCClass("NSObject")
1558
-
1559
- class _PNRenderSchedulerTarget(NSObject): # type: ignore[misc, valid-type]
1560
- @objc_method
1561
- def onRenderTimer_(self, timer: object) -> None:
1562
- _flush_ios_scheduled_renders()
1563
-
1564
- def _ensure_ios_render_scheduler_target() -> Any:
1565
- global _ios_render_scheduler_target
1566
-
1567
- if _ios_render_scheduler_target is None:
1568
- target = _PNRenderSchedulerTarget.new()
1569
- try:
1570
- target.retain()
1571
- except Exception:
1572
- pass
1573
- _ios_render_scheduler_target = target
1574
- return _ios_render_scheduler_target
1575
-
1576
- def _schedule_render_async(host: Any) -> bool:
1577
- if not IS_IOS:
1578
- return False
1579
- if getattr(host, "_render_scheduled", False):
1580
- return True
1581
-
1582
- host._render_scheduled = True
1583
- _IOS_SCHEDULED_RENDER_HOSTS[id(host)] = host
1584
- if threading.current_thread() is not threading.main_thread():
1585
- if _schedule_ios_native_render_drain():
1586
- _log_pn("_request_render: deferred iOS render via native scheduler")
1587
- return True
1588
- _log_pn("_request_render: native iOS scheduler unavailable; render remains queued")
1589
- return True
1590
- # Defer via the main dispatch queue rather than an NSTimer.
1591
- # GCD main-queue blocks are serviced in the common run-loop
1592
- # modes, so they still run while UIKit is in tracking mode
1593
- # (scrolls, synthesized test touches). A default-mode NSTimer
1594
- # is starved for the whole gesture, which delayed renders
1595
- # long enough on slow CI simulators for Maestro asserts to
1596
- # time out, and let stale flushes land mid-touch.
1597
- from .runtime import _ensure_libdispatch_loaded, _ios_dispatch_async
1598
-
1599
- if _ensure_libdispatch_loaded():
1600
- _ios_dispatch_async(_flush_ios_scheduled_renders)
1601
- _log_pn("_request_render: deferred iOS render to next main-queue turn")
1602
- return True
1603
- try:
1604
- NSTimer = ObjCClass("NSTimer")
1605
- target = _ensure_ios_render_scheduler_target()
1606
- NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
1607
- 0.0,
1608
- target,
1609
- SEL("onRenderTimer:"),
1610
- None,
1611
- False,
1612
- )
1613
- _log_pn("_request_render: deferred iOS render to next run-loop turn")
1614
- return True
1615
- except Exception as e:
1616
- host._render_scheduled = False
1617
- _IOS_SCHEDULED_RENDER_HOSTS.pop(id(host), None)
1618
- _log_pn(f"_request_render: iOS defer failed ({e!r}); rendering synchronously")
1619
- return False
1620
-
1621
- class _ScreenHost:
1622
- """iOS host backed by a `UIViewController`.
1623
-
1624
- Owned by the screen view-controller template. Bridges iOS
1625
- lifecycle callbacks (`viewDidLoad`, `viewWillDisappear`,
1626
- etc.) to the reconciler and the function component.
1627
- """
1628
-
1629
- def __init__(self, native_instance: Any, component_path: str, component_func: Any) -> None:
1630
- if isinstance(native_instance, int):
1631
- try:
1632
- native_instance = ObjCInstance(native_instance)
1633
- except Exception:
1634
- native_instance = None
1635
- self.native_instance = native_instance
1636
- _init_host_common(self, component_path, component_func)
1637
- if self.native_instance is not None:
1638
- _ios_register_screen(self.native_instance, self)
1639
-
1640
- def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
1641
- """Estimate the viewport from the VC's view bounds (pre-attach)."""
1642
- try:
1643
- if self.native_instance is not None:
1644
- bounds = self.native_instance.view.bounds
1645
- width = float(bounds.size.width)
1646
- height = float(bounds.size.height)
1647
- if width > 0 and height > 0:
1648
- return (width, height)
1649
- except Exception:
1650
- pass
1651
- try:
1652
- UIScreen = ObjCClass("UIScreen")
1653
- screen_bounds = UIScreen.mainScreen.bounds
1654
- return (float(screen_bounds.size.width), float(screen_bounds.size.height))
1655
- except Exception:
1656
- return None
1657
-
1658
- def on_create(self) -> None:
1659
- _ios_publish_color_scheme()
1660
- _on_create(self)
1661
-
1662
- def on_start(self) -> None:
1663
- pass
1664
-
1665
- def on_pause(self) -> None:
1666
- _set_host_focused(self, False)
1667
-
1668
- def on_stop(self) -> None:
1669
- pass
1670
-
1671
- def on_destroy(self) -> None:
1672
- if self.native_instance is not None:
1673
- _ios_unregister_screen(self.native_instance)
1674
- _destroy_host(self)
1675
-
1676
- def on_back_pressed(self) -> bool:
1677
- return _host_back_pressed(self)
1678
-
1679
- def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1680
- _enable_hot_reload(self, manifest_path)
1681
-
1682
- def hot_reload_tick(self) -> bool:
1683
- return _hot_reload_tick(self)
1684
-
1685
- def reload(self, changed_modules: Optional[Sequence[str]] = None) -> None:
1686
- _reload_host(self, changed_modules)
1687
-
1688
- def on_restart(self) -> None:
1689
- pass
1690
-
1691
- def on_save_instance_state(self) -> None:
1692
- pass
1693
-
1694
- def on_restore_instance_state(self) -> None:
1695
- pass
1696
-
1697
- def set_args(self, args: Any) -> None:
1698
- _set_args(self, args)
1699
-
1700
- def _get_nav_args(self) -> Dict[str, Any]:
1701
- return self._args
1702
-
1703
- def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None:
1704
- screen_path = _resolve_component_path(component)
1705
- ViewController = None
1706
- try:
1707
- ViewController = ObjCClass("ViewController")
1708
- except Exception:
1709
- try:
1710
- NSBundle = ObjCClass("NSBundle")
1711
- bundle = NSBundle.mainBundle
1712
- module_name = bundle.objectForInfoDictionaryKey_("CFBundleName")
1713
- if module_name is None:
1714
- module_name = bundle.objectForInfoDictionaryKey_("CFBundleExecutable")
1715
- if module_name:
1716
- ViewController = ObjCClass(f"{module_name}.ViewController")
1717
- except Exception:
1718
- pass
1719
-
1720
- if ViewController is None:
1721
- raise NameError("ViewController class not found; ensure Swift class is ObjC-visible")
1722
-
1723
- next_vc = ViewController.alloc().init()
1724
- try:
1725
- next_vc.setValue_forKey_(screen_path, "requestedScreenPath")
1726
- if args:
1727
- next_vc.setValue_forKey_(json.dumps(args), "requestedScreenArgsJSON")
1728
- except Exception:
1729
- pass
1730
- nav = getattr(self.native_instance, "navigationController", None)
1731
- if nav is None:
1732
- raise RuntimeError(
1733
- "No UINavigationController available; ensure template embeds root in navigation controller"
1734
- )
1735
- self._run_nav_op(lambda n: n.pushViewController_animated_(next_vc, True))
1736
-
1737
- def _pop(self) -> None:
1738
- self._run_nav_op(lambda n: n.popViewControllerAnimated_(True))
1739
-
1740
- def _reset_to_root(self) -> None:
1741
- """Pop everything above the root view-controller."""
1742
-
1743
- def op(n: Any) -> None:
1744
- try:
1745
- n.popToRootViewControllerAnimated_(True)
1746
- except Exception:
1747
- pass
1748
-
1749
- self._run_nav_op(op)
1750
-
1751
- def _run_nav_op(self, op: Any) -> None:
1752
- """Run ``op(nav)`` unless a stack transition is mid-flight.
1753
-
1754
- UIKit does not queue navigation calls: a
1755
- ``pushViewController`` / ``popViewController`` issued
1756
- while the previous transition is still animating is
1757
- silently ignored or, worse, misapplies and pops past
1758
- the intended screen. A fast tap right after a push
1759
- (easy for a user, common for a UI test on a slow
1760
- machine) lands exactly in that window. Dropping the
1761
- operation keeps the stack consistent: the tap simply
1762
- does nothing, and a retry lands on a settled stack.
1763
- (Deferring the operation instead was tried and is
1764
- worse: the user retries, then the deferred operation
1765
- also fires, popping one screen too many.)
1766
- """
1767
- nav = getattr(self.native_instance, "navigationController", None)
1768
- if nav is None:
1769
- return
1770
- try:
1771
- # rubicon resolves ``transitionCoordinator`` as a bound
1772
- # method rather than a property on some classes; call
1773
- # it to get the actual (possibly nil -> None) value.
1774
- coord = nav.transitionCoordinator
1775
- if callable(coord):
1776
- coord = coord()
1777
- if coord is not None:
1778
- _log_pn("_run_nav_op: transition in flight; dropping nav op")
1779
- return
1780
- except Exception:
1781
- pass
1782
- op(nav)
1783
-
1784
- def _set_screen_options(self, options: Dict[str, Any]) -> None:
1785
- """Bind screen options (e.g. title) to the native nav bar.
1786
-
1787
- Setting ``UIViewController.title`` propagates to the
1788
- view controller's ``navigationItem.title`` so the
1789
- surrounding ``UINavigationController`` picks up the
1790
- new title on its next layout pass.
1791
- """
1792
- title = options.get("title") if isinstance(options, dict) else None
1793
- if title is None or self.native_instance is None:
1794
- return
1795
- try:
1796
- self.native_instance.setTitle_(str(title))
1797
- except Exception as e:
1798
- _log_pn(f"_set_screen_options: setTitle failed: {e!r}")
1799
-
1800
- def _attach_root(self, native_view: Any) -> None:
1801
- root_view = self.native_instance.view
1802
- root_view.addSubview_(native_view)
1803
- # Use classic frame-based layout for the root container so
1804
- # the layout engine's per-child frames inside it are honored
1805
- # without competing with Auto Layout constraints.
1806
- try:
1807
- native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
1808
- native_view.setAutoresizingMask_(2 | 16) # FlexibleWidth | FlexibleHeight
1809
- except Exception:
1810
- pass
1811
- self._sync_root_frame(native_view)
1812
- self._push_viewport_from_root(native_view)
1813
-
1814
- def _detach_root(self, native_view: Any) -> None:
1815
- try:
1816
- native_view.removeFromSuperview()
1817
- except Exception:
1818
- pass
1819
-
1820
- def _sync_root_frame(self, native_view: Any) -> None:
1821
- """Position the root view below the top safe area, full-bleed at the bottom.
1822
-
1823
- The frame intentionally extends *past* the bottom
1824
- safe-area inset so a tab bar can reach the home
1825
- indicator (otherwise it floats with an empty 34 pt
1826
- gap below it). The bottom inset itself is published
1827
- via `pythonnative.platform_metrics` so handlers like
1828
- [`TabBarHandler`][pythonnative.native_views.ios.TabBarHandler]
1829
- can absorb it into their intrinsic height. Apps that
1830
- render content directly at the bottom (no tab bar)
1831
- opt back into safe-area padding via ``SafeAreaView``
1832
- or by reading the insets explicitly.
1833
-
1834
- Uses ``safeAreaInsets`` rather than
1835
- ``safeAreaLayoutGuide.layoutFrame`` because the
1836
- latter returns ``CGRectZero`` until UIKit has run its
1837
- first layout pass; the insets are populated as soon
1838
- as the controller's view is in a window, which is
1839
- reliably true by the time ``viewDidLayoutSubviews``
1840
- fires.
1841
- """
1842
- root_view = self.native_instance.view
1843
- if root_view is None:
1844
- _log_pn("sync_root_frame: root_view is None, skipping")
1845
- return
1846
- try:
1847
- bounds = root_view.bounds
1848
- insets = root_view.safeAreaInsets
1849
- bw = float(bounds.size.width)
1850
- bh = float(bounds.size.height)
1851
- top = float(insets.top)
1852
- left = float(insets.left)
1853
- right = float(insets.right)
1854
- bottom = float(insets.bottom)
1855
- w = max(0.0, bw - left - right)
1856
- h = max(0.0, bh - top)
1857
- _log_pn(
1858
- "sync_root_frame: "
1859
- f"root.bounds=({bw:.1f},{bh:.1f}) "
1860
- f"insets=(t{top:.1f},l{left:.1f},b{bottom:.1f},r{right:.1f}) "
1861
- f"-> child frame=({left:.1f},{top:.1f},{w:.1f},{h:.1f}) "
1862
- f"(bottom inset {bottom:.1f} published to platform_metrics)"
1863
- )
1864
- try:
1865
- from . import platform_metrics
1866
-
1867
- platform_metrics.set_safe_area_insets(0.0, left, bottom, right)
1868
- except Exception as e:
1869
- _log_pn(f"sync_root_frame: publish insets failed: {e!r}")
1870
- if w > 0 and h > 0:
1871
- native_view.setFrame_(((left, top), (w, h)))
1872
- return
1873
- except Exception as e:
1874
- _log_pn(f"sync_root_frame: insets-path failed: {e!r}")
1875
- try:
1876
- bounds = root_view.bounds
1877
- bw2 = float(bounds.size.width)
1878
- bh2 = float(bounds.size.height)
1879
- _log_pn(f"sync_root_frame: fallback to bounds=({bw2:.1f},{bh2:.1f})")
1880
- native_view.setFrame_(((0, 0), (bw2, bh2)))
1881
- except Exception as e:
1882
- _log_pn(f"sync_root_frame: bounds fallback failed: {e!r}")
1883
-
1884
- def _push_viewport_from_root(self, native_view: Any) -> None:
1885
- """Push the root view's measured size into the reconciler."""
1886
- try:
1887
- bounds = native_view.bounds
1888
- w = float(bounds.size.width)
1889
- h = float(bounds.size.height)
1890
- source = "native_view.bounds"
1891
- if w <= 0 or h <= 0:
1892
- UIScreen = ObjCClass("UIScreen")
1893
- screen_bounds = UIScreen.mainScreen.bounds
1894
- w = float(screen_bounds.size.width)
1895
- h = float(screen_bounds.size.height)
1896
- source = "UIScreen.mainScreen.bounds"
1897
- _log_pn(f"push_viewport: ({w:.1f},{h:.1f}) from {source}")
1898
- _push_viewport_size(self, w, h)
1899
- except Exception as e:
1900
- _log_pn(f"push_viewport: failed: {e!r}")
1901
-
1902
- def on_layout(self) -> None:
1903
- # Forwarded from ``viewDidLayoutSubviews``: the safe area
1904
- # insets are now valid (initial layout, rotation,
1905
- # multitasking, …). Re-sync the root frame and push the
1906
- # viewport so the layout engine matches the visible area.
1907
- # Appearance flips also trigger a layout pass, so the
1908
- # color scheme is re-published here too.
1909
- _ios_publish_color_scheme()
1910
- if self._root_native_view is None:
1911
- _log_pn("on_layout: no root_native_view yet, skipping")
1912
- return
1913
- _log_pn("on_layout: re-syncing")
1914
- self._sync_root_frame(self._root_native_view)
1915
- self._push_viewport_from_root(self._root_native_view)
1916
-
1917
- def on_resume(self) -> None:
1918
- # ``viewDidAppear`` always follows ``viewDidLayoutSubviews``,
1919
- # but trigger one extra sync here for safety in case a
1920
- # template overrides the layout call without forwarding.
1921
- _ios_publish_color_scheme()
1922
- _set_host_focused(self, True)
1923
- if self._root_native_view is None:
1924
- _log_pn("on_resume: no root_native_view yet, skipping")
1925
- return
1926
- _log_pn("on_resume: re-syncing")
1927
- self._sync_root_frame(self._root_native_view)
1928
- self._push_viewport_from_root(self._root_native_view)
1929
-
1930
- def set_viewport_size(self, width: float, height: float) -> None:
1931
- """Public hook for native code (Swift) to push viewport sizes."""
1932
- _push_viewport_size(self, width, height)
1933
-
1934
- else:
1935
-
1936
- class _ScreenHost:
1937
- """Desktop stub used when no native runtime is available.
1938
-
1939
- Fully functional for unit tests when a mock backend is
1940
- installed via
1941
- [`set_registry`][pythonnative.native_views.set_registry].
1942
- Calls to navigation methods raise `RuntimeError` because
1943
- there is no native navigation stack to push onto.
1944
- """
1945
-
1946
- def __init__(
1947
- self,
1948
- native_instance: Any = None,
1949
- component_path: str = "",
1950
- component_func: Any = None,
1951
- ) -> None:
1952
- self.native_instance = native_instance
1953
- _init_host_common(self, component_path, component_func)
1954
-
1955
- def on_create(self) -> None:
1956
- _on_create(self)
1957
-
1958
- def on_start(self) -> None:
1959
- pass
1960
-
1961
- def on_resume(self) -> None:
1962
- pass
1963
-
1964
- def on_layout(self) -> None:
1965
- pass
1966
-
1967
- def on_pause(self) -> None:
1968
- pass
1969
-
1970
- def on_stop(self) -> None:
1971
- pass
1972
-
1973
- def on_destroy(self) -> None:
1974
- _destroy_host(self)
1975
-
1976
- def on_back_pressed(self) -> bool:
1977
- return _host_back_pressed(self)
1978
-
1979
- def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1980
- _enable_hot_reload(self, manifest_path)
1981
-
1982
- def hot_reload_tick(self) -> bool:
1983
- return _hot_reload_tick(self)
1984
-
1985
- def reload(self, changed_modules: Optional[Sequence[str]] = None) -> None:
1986
- _reload_host(self, changed_modules)
1987
-
1988
- def on_restart(self) -> None:
1989
- pass
1990
-
1991
- def on_save_instance_state(self) -> None:
1992
- pass
1993
-
1994
- def on_restore_instance_state(self) -> None:
1995
- pass
1996
-
1997
- def set_args(self, args: Any) -> None:
1998
- _set_args(self, args)
1999
-
2000
- def _get_nav_args(self) -> Dict[str, Any]:
2001
- return self._args
2002
-
2003
- def _push(self, component: Any, args: Optional[Dict[str, Any]] = None) -> None:
2004
- raise RuntimeError("navigate() requires a native runtime (iOS or Android)")
2005
-
2006
- def _pop(self) -> None:
2007
- raise RuntimeError("go_back() requires a native runtime (iOS or Android)")
2008
-
2009
- def _reset_to_root(self) -> None:
2010
- pass
2011
-
2012
- def _set_screen_options(self, options: Dict[str, Any]) -> None:
2013
- """No-op on desktop; native hosts override this."""
2014
- return
2015
-
2016
- def _attach_root(self, native_view: Any) -> None:
2017
- pass
2018
-
2019
- def _detach_root(self, native_view: Any) -> None:
2020
- pass
2021
-
2022
-
2023
- # ======================================================================
2024
- # Public factory
2025
- # ======================================================================
2026
-
2027
-
2028
- def create_screen(
2029
- component_path: str,
2030
- native_instance: Any = None,
2031
- args_json: Optional[str] = None,
2032
- ) -> _ScreenHost:
2033
- """Create a screen host for a function component.
2034
-
2035
- Called by native templates (`ScreenFragment.kt` on Android,
2036
- `ViewController.swift` on iOS) to bridge the native lifecycle to a
2037
- [`@component`][pythonnative.component] function.
2038
-
2039
- Args:
2040
- component_path: Either a module path like `"app.main"` (the
2041
- module's top-level ``App`` attribute is used) or a dotted
2042
- attribute path like `"app.main.RootScreen"`. The function
2043
- is imported lazily so user modules can be reloaded by the
2044
- dev server.
2045
- native_instance: The native `Activity` (Android) or
2046
- `UIViewController` (iOS) pointer that owns this screen.
2047
- args_json: Optional JSON string of navigation arguments to pass
2048
- to the component on first render.
2049
-
2050
- Returns:
2051
- A `_ScreenHost` ready to receive lifecycle callbacks (`on_create`,
2052
- `on_pause`, etc.) from the platform.
2053
-
2054
- Example:
2055
- ```python
2056
- host = pythonnative.screen.create_screen(
2057
- "app.main",
2058
- native_instance,
2059
- args_json='{"id": 42}',
2060
- )
2061
- host.on_create()
2062
- ```
2063
- """
2064
- component_func = _import_component(component_path)
2065
- host = _ScreenHost(native_instance, component_path, component_func)
2066
- if args_json:
2067
- _set_args(host, args_json)
2068
- return host