pythonnative 0.23.0__py3-none-any.whl → 0.25.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonnative/__init__.py +44 -53
- pythonnative/animated.py +3 -3
- pythonnative/cli/pn.py +3 -3
- pythonnative/components.py +309 -513
- pythonnative/diagnostics.py +214 -0
- pythonnative/element.py +5 -2
- pythonnative/events.py +13 -8
- pythonnative/hooks.py +761 -114
- pythonnative/hot_reload.py +9 -1
- pythonnative/native_modules/notifications.py +36 -4
- pythonnative/native_modules/permissions.py +24 -1
- pythonnative/native_views/android.py +85 -3
- pythonnative/native_views/desktop.py +38 -1
- pythonnative/native_views/ios.py +252 -7
- pythonnative/navigation.py +41 -17
- pythonnative/preview.py +43 -7
- pythonnative/reconciler.py +1360 -457
- pythonnative/runtime.py +407 -215
- pythonnative/screen.py +377 -34
- pythonnative/storage.py +5 -5
- pythonnative/style.py +83 -0
- pythonnative/suspense.py +417 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/METADATA +6 -4
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/RECORD +29 -27
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/top_level.txt +0 -0
pythonnative/screen.py
CHANGED
|
@@ -27,7 +27,7 @@ Example:
|
|
|
27
27
|
count, set_count = pn.use_state(0)
|
|
28
28
|
return pn.Column(
|
|
29
29
|
pn.Text(f"Count: {count}", style={"font_size": 24}),
|
|
30
|
-
pn.Button("Tap me",
|
|
30
|
+
pn.Button("Tap me", on_press=lambda: set_count(count + 1)),
|
|
31
31
|
style={"spacing": 12, "padding": 16},
|
|
32
32
|
)
|
|
33
33
|
```
|
|
@@ -48,8 +48,10 @@ import json
|
|
|
48
48
|
import os
|
|
49
49
|
import sys
|
|
50
50
|
import threading
|
|
51
|
-
|
|
51
|
+
import traceback
|
|
52
|
+
from typing import Any, Dict, Optional, Sequence, Tuple
|
|
52
53
|
|
|
54
|
+
from . import diagnostics
|
|
53
55
|
from .utils import IS_ANDROID, IS_DESKTOP, IS_IOS, set_android_context
|
|
54
56
|
|
|
55
57
|
_MAX_RENDER_PASSES = 25
|
|
@@ -186,6 +188,10 @@ def _init_host_common(host: Any, component_path: str, component_func: Any) -> No
|
|
|
186
188
|
host._hot_reload_manifest_path = None
|
|
187
189
|
host._hot_reload_last_version = None
|
|
188
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
|
|
189
195
|
# Focus state: drives ``use_focus_effect``. Starts focused because
|
|
190
196
|
# a host is only created when the screen is being presented; the
|
|
191
197
|
# platform lifecycle hooks (``on_resume`` / ``on_pause``) flip this
|
|
@@ -207,6 +213,54 @@ def _set_host_focused(host: Any, focused: bool) -> None:
|
|
|
207
213
|
pass
|
|
208
214
|
|
|
209
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
|
+
|
|
210
264
|
def _push_viewport_size(host: Any, width: float, height: float) -> None:
|
|
211
265
|
"""Forward a viewport-size change to the reconciler.
|
|
212
266
|
|
|
@@ -223,6 +277,9 @@ def _push_viewport_size(host: Any, width: float, height: float) -> None:
|
|
|
223
277
|
if width <= 0 or height <= 0:
|
|
224
278
|
return
|
|
225
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))
|
|
226
283
|
try:
|
|
227
284
|
from . import platform_metrics
|
|
228
285
|
|
|
@@ -269,6 +326,32 @@ def _flush_scheduled_renders(hosts: Sequence[Any]) -> None:
|
|
|
269
326
|
_re_render(host)
|
|
270
327
|
|
|
271
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
|
+
|
|
272
355
|
def _on_create(host: Any) -> None:
|
|
273
356
|
from .hooks import NavigationHandle, Provider, _NavigationContext
|
|
274
357
|
|
|
@@ -285,23 +368,30 @@ def _on_create(host: Any) -> None:
|
|
|
285
368
|
# If we're already mounted, just re-attach the existing root view
|
|
286
369
|
# to the (newly created) native container; ``on_resume`` will
|
|
287
370
|
# fire the focus subscribers separately.
|
|
371
|
+
_register_redbox_reporter(host)
|
|
288
372
|
if host._reconciler is not None and host._root_native_view is not None:
|
|
289
373
|
host._attach_root(host._root_native_view)
|
|
290
374
|
return
|
|
291
375
|
|
|
292
376
|
host._nav_handle = NavigationHandle(host)
|
|
293
377
|
host._reconciler = _new_reconciler(host)
|
|
378
|
+
_seed_initial_viewport(host)
|
|
294
379
|
|
|
295
|
-
app_element = _render_app(host)
|
|
296
|
-
provider_element = Provider(_NavigationContext, host._nav_handle, app_element)
|
|
297
|
-
|
|
298
|
-
host._is_rendering = True
|
|
299
380
|
try:
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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")
|
|
305
395
|
|
|
306
396
|
|
|
307
397
|
def _request_render(host: Any) -> None:
|
|
@@ -333,13 +423,18 @@ def _re_render(host: Any) -> None:
|
|
|
333
423
|
hot reload.
|
|
334
424
|
"""
|
|
335
425
|
_log_pn("_re_render: starting local render pass")
|
|
336
|
-
host._is_rendering = True
|
|
337
426
|
try:
|
|
338
|
-
host.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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")
|
|
343
438
|
_log_pn("_re_render: done")
|
|
344
439
|
|
|
345
440
|
|
|
@@ -367,6 +462,130 @@ def _drain_renders(host: Any) -> None:
|
|
|
367
462
|
_commit_dirty(host)
|
|
368
463
|
|
|
369
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
|
+
|
|
370
589
|
def _set_args(host: Any, args: Any) -> None:
|
|
371
590
|
if isinstance(args, str):
|
|
372
591
|
try:
|
|
@@ -380,6 +599,10 @@ def _set_args(host: Any, args: Any) -> None:
|
|
|
380
599
|
def _enable_hot_reload(host: Any, manifest_path: str) -> None:
|
|
381
600
|
host._hot_reload_manifest_path = manifest_path
|
|
382
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)
|
|
383
606
|
|
|
384
607
|
|
|
385
608
|
def _hot_reload_tick(host: Any) -> bool:
|
|
@@ -473,8 +696,13 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
|
|
|
473
696
|
|
|
474
697
|
try:
|
|
475
698
|
new_component = _import_component(host._component_path)
|
|
476
|
-
except Exception as
|
|
477
|
-
_log_pn(f"_reload_host: re-import failed: {
|
|
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")
|
|
478
706
|
return
|
|
479
707
|
host._component = new_component
|
|
480
708
|
|
|
@@ -482,11 +710,22 @@ def _reload_host(host: Any, changed_modules: Optional[Sequence[str]] = None) ->
|
|
|
482
710
|
_log_pn(f"_reload_host: host=0x{id(host):x} reconciler=None; skipping refresh")
|
|
483
711
|
return
|
|
484
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
|
+
|
|
485
719
|
if _try_fast_refresh(host, reloaded):
|
|
486
720
|
print(f"[hot-reload] Fast Refresh: {', '.join(requested) or ', '.join(reloaded)}", file=sys.stderr)
|
|
487
721
|
return
|
|
488
722
|
|
|
489
|
-
|
|
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")
|
|
490
729
|
|
|
491
730
|
|
|
492
731
|
def _try_fast_refresh(host: Any, reloaded_modules: Sequence[str]) -> bool:
|
|
@@ -819,6 +1058,15 @@ if IS_ANDROID:
|
|
|
819
1058
|
set_android_context(native_instance)
|
|
820
1059
|
_init_host_common(self, component_path, component_func)
|
|
821
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
|
+
|
|
822
1070
|
def on_create(self) -> None:
|
|
823
1071
|
_android_publish_color_scheme(self.native_instance)
|
|
824
1072
|
_on_create(self)
|
|
@@ -844,7 +1092,10 @@ if IS_ANDROID:
|
|
|
844
1092
|
pass
|
|
845
1093
|
|
|
846
1094
|
def on_destroy(self) -> None:
|
|
847
|
-
|
|
1095
|
+
_destroy_host(self)
|
|
1096
|
+
|
|
1097
|
+
def on_back_pressed(self) -> bool:
|
|
1098
|
+
return _host_back_pressed(self)
|
|
848
1099
|
|
|
849
1100
|
def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
|
|
850
1101
|
_enable_hot_reload(self, manifest_path)
|
|
@@ -938,11 +1189,18 @@ if IS_ANDROID:
|
|
|
938
1189
|
_android_push_initial_viewport(self, container)
|
|
939
1190
|
|
|
940
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
|
|
941
1200
|
try:
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
container.removeAllViews()
|
|
1201
|
+
parent = native_view.getParent()
|
|
1202
|
+
if parent is not None:
|
|
1203
|
+
parent.removeView(native_view)
|
|
946
1204
|
except Exception:
|
|
947
1205
|
pass
|
|
948
1206
|
|
|
@@ -1005,6 +1263,17 @@ elif IS_DESKTOP:
|
|
|
1005
1263
|
self.native_instance = native_instance
|
|
1006
1264
|
_init_host_common(self, component_path, component_func)
|
|
1007
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
|
+
|
|
1008
1277
|
def on_create(self) -> None:
|
|
1009
1278
|
_on_create(self)
|
|
1010
1279
|
|
|
@@ -1024,7 +1293,10 @@ elif IS_DESKTOP:
|
|
|
1024
1293
|
pass
|
|
1025
1294
|
|
|
1026
1295
|
def on_destroy(self) -> None:
|
|
1027
|
-
|
|
1296
|
+
_destroy_host(self)
|
|
1297
|
+
|
|
1298
|
+
def on_back_pressed(self) -> bool:
|
|
1299
|
+
return _host_back_pressed(self)
|
|
1028
1300
|
|
|
1029
1301
|
def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
|
|
1030
1302
|
_enable_hot_reload(self, manifest_path)
|
|
@@ -1315,6 +1587,19 @@ else:
|
|
|
1315
1587
|
return True
|
|
1316
1588
|
_log_pn("_request_render: native iOS scheduler unavailable; render remains queued")
|
|
1317
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
|
|
1318
1603
|
try:
|
|
1319
1604
|
NSTimer = ObjCClass("NSTimer")
|
|
1320
1605
|
target = _ensure_ios_render_scheduler_target()
|
|
@@ -1352,6 +1637,24 @@ else:
|
|
|
1352
1637
|
if self.native_instance is not None:
|
|
1353
1638
|
_ios_register_screen(self.native_instance, self)
|
|
1354
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
|
+
|
|
1355
1658
|
def on_create(self) -> None:
|
|
1356
1659
|
_ios_publish_color_scheme()
|
|
1357
1660
|
_on_create(self)
|
|
@@ -1368,6 +1671,10 @@ else:
|
|
|
1368
1671
|
def on_destroy(self) -> None:
|
|
1369
1672
|
if self.native_instance is not None:
|
|
1370
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)
|
|
1371
1678
|
|
|
1372
1679
|
def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
|
|
1373
1680
|
_enable_hot_reload(self, manifest_path)
|
|
@@ -1425,22 +1732,55 @@ else:
|
|
|
1425
1732
|
raise RuntimeError(
|
|
1426
1733
|
"No UINavigationController available; ensure template embeds root in navigation controller"
|
|
1427
1734
|
)
|
|
1428
|
-
|
|
1735
|
+
self._run_nav_op(lambda n: n.pushViewController_animated_(next_vc, True))
|
|
1429
1736
|
|
|
1430
1737
|
def _pop(self) -> None:
|
|
1431
|
-
|
|
1432
|
-
if nav is not None:
|
|
1433
|
-
nav.popViewControllerAnimated_(True)
|
|
1738
|
+
self._run_nav_op(lambda n: n.popViewControllerAnimated_(True))
|
|
1434
1739
|
|
|
1435
1740
|
def _reset_to_root(self) -> None:
|
|
1436
1741
|
"""Pop everything above the root view-controller."""
|
|
1437
|
-
|
|
1438
|
-
|
|
1742
|
+
|
|
1743
|
+
def op(n: Any) -> None:
|
|
1439
1744
|
try:
|
|
1440
|
-
|
|
1745
|
+
n.popToRootViewControllerAnimated_(True)
|
|
1441
1746
|
except Exception:
|
|
1442
1747
|
pass
|
|
1443
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
|
+
|
|
1444
1784
|
def _set_screen_options(self, options: Dict[str, Any]) -> None:
|
|
1445
1785
|
"""Bind screen options (e.g. title) to the native nav bar.
|
|
1446
1786
|
|
|
@@ -1631,7 +1971,10 @@ else:
|
|
|
1631
1971
|
pass
|
|
1632
1972
|
|
|
1633
1973
|
def on_destroy(self) -> None:
|
|
1634
|
-
|
|
1974
|
+
_destroy_host(self)
|
|
1975
|
+
|
|
1976
|
+
def on_back_pressed(self) -> bool:
|
|
1977
|
+
return _host_back_pressed(self)
|
|
1635
1978
|
|
|
1636
1979
|
def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
|
|
1637
1980
|
_enable_hot_reload(self, manifest_path)
|
pythonnative/storage.py
CHANGED
|
@@ -375,11 +375,11 @@ def use_persisted_state(
|
|
|
375
375
|
theme, set_theme = pn.use_persisted_state("settings.theme", "light")
|
|
376
376
|
return pn.Button(
|
|
377
377
|
f"Theme: {theme}",
|
|
378
|
-
|
|
378
|
+
on_press=lambda: set_theme("dark" if theme == "light" else "light"),
|
|
379
379
|
)
|
|
380
380
|
```
|
|
381
381
|
"""
|
|
382
|
-
from .hooks import
|
|
382
|
+
from .hooks import use_callback, use_effect, use_ref, use_state
|
|
383
383
|
from .runtime import run_async
|
|
384
384
|
|
|
385
385
|
state, set_state = use_state(initial)
|
|
@@ -389,14 +389,14 @@ def use_persisted_state(
|
|
|
389
389
|
stored = await AsyncStorage.get_json(key)
|
|
390
390
|
if stored is not None:
|
|
391
391
|
set_state(stored)
|
|
392
|
-
loaded
|
|
392
|
+
loaded.current = True
|
|
393
393
|
|
|
394
|
-
|
|
394
|
+
use_effect(_load, [key])
|
|
395
395
|
|
|
396
396
|
def setter(value_or_updater: Any) -> None:
|
|
397
397
|
def _reducer(current: Any) -> Any:
|
|
398
398
|
new_value = value_or_updater(current) if callable(value_or_updater) else value_or_updater
|
|
399
|
-
if loaded
|
|
399
|
+
if loaded.current is True:
|
|
400
400
|
run_async(AsyncStorage.set_json(key, new_value))
|
|
401
401
|
return new_value
|
|
402
402
|
|