pythonnative 0.22.1__py3-none-any.whl → 0.24.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 (35) hide show
  1. pythonnative/__init__.py +43 -50
  2. pythonnative/animated.py +1 -1
  3. pythonnative/appearance.py +124 -0
  4. pythonnative/cli/pn.py +3 -3
  5. pythonnative/components.py +873 -466
  6. pythonnative/diagnostics.py +214 -0
  7. pythonnative/element.py +5 -2
  8. pythonnative/events.py +13 -8
  9. pythonnative/hooks.py +454 -49
  10. pythonnative/hot_reload.py +9 -1
  11. pythonnative/images.py +196 -0
  12. pythonnative/mutations.py +3 -12
  13. pythonnative/native_views/__init__.py +1 -7
  14. pythonnative/native_views/android.py +651 -46
  15. pythonnative/native_views/desktop.py +116 -20
  16. pythonnative/native_views/ios.py +974 -52
  17. pythonnative/navigation.py +41 -17
  18. pythonnative/preview.py +74 -7
  19. pythonnative/project/config.py +1 -9
  20. pythonnative/reconciler.py +863 -441
  21. pythonnative/screen.py +409 -27
  22. pythonnative/storage.py +3 -3
  23. pythonnative/style.py +148 -6
  24. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
  25. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
  26. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
  27. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
  28. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
  29. pythonnative/virtual_rows.py +137 -0
  30. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/METADATA +5 -4
  31. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/RECORD +35 -28
  32. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/WHEEL +1 -1
  33. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/entry_points.txt +0 -0
  34. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/licenses/LICENSE +0 -0
  35. {pythonnative-0.22.1.dist-info → pythonnative-0.24.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", on_click=lambda: set_count(count + 1)),
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
- from typing import Any, Dict, Optional, Sequence
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
- host._root_native_view = host._reconciler.mount(provider_element)
301
- host._attach_root(host._root_native_view)
302
- _drain_renders(host)
303
- finally:
304
- host._is_rendering = False
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._render_queued = False
339
- _commit_dirty(host)
340
- _drain_renders(host)
341
- finally:
342
- host._is_rendering = False
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 e:
477
- _log_pn(f"_reload_host: re-import failed: {e!r}; aborting reload")
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
- _full_remount(host, reloaded)
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:
@@ -678,6 +917,65 @@ if IS_ANDROID:
678
917
  bottom_px / density,
679
918
  right_px / density,
680
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
681
979
  except Exception:
682
980
  pass
683
981
 
@@ -760,13 +1058,24 @@ if IS_ANDROID:
760
1058
  set_android_context(native_instance)
761
1059
  _init_host_common(self, component_path, component_func)
762
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
+
763
1070
  def on_create(self) -> None:
1071
+ _android_publish_color_scheme(self.native_instance)
764
1072
  _on_create(self)
765
1073
 
766
1074
  def on_start(self) -> None:
767
1075
  pass
768
1076
 
769
1077
  def on_resume(self) -> None:
1078
+ _android_publish_color_scheme(self.native_instance)
770
1079
  _set_host_focused(self, True)
771
1080
 
772
1081
  def on_layout(self) -> None:
@@ -783,7 +1092,10 @@ if IS_ANDROID:
783
1092
  pass
784
1093
 
785
1094
  def on_destroy(self) -> None:
786
- pass
1095
+ _destroy_host(self)
1096
+
1097
+ def on_back_pressed(self) -> bool:
1098
+ return _host_back_pressed(self)
787
1099
 
788
1100
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
789
1101
  _enable_hot_reload(self, manifest_path)
@@ -873,14 +1185,22 @@ if IS_ANDROID:
873
1185
 
874
1186
  if container is not None:
875
1187
  _android_register_layout_listener(self, container)
1188
+ _android_register_insets_listener(self, container)
876
1189
  _android_push_initial_viewport(self, container)
877
1190
 
878
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
879
1200
  try:
880
- from .utils import get_android_fragment_container
881
-
882
- container = get_android_fragment_container()
883
- container.removeAllViews()
1201
+ parent = native_view.getParent()
1202
+ if parent is not None:
1203
+ parent.removeView(native_view)
884
1204
  except Exception:
885
1205
  pass
886
1206
 
@@ -943,6 +1263,17 @@ elif IS_DESKTOP:
943
1263
  self.native_instance = native_instance
944
1264
  _init_host_common(self, component_path, component_func)
945
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
+
946
1277
  def on_create(self) -> None:
947
1278
  _on_create(self)
948
1279
 
@@ -962,7 +1293,10 @@ elif IS_DESKTOP:
962
1293
  pass
963
1294
 
964
1295
  def on_destroy(self) -> None:
965
- pass
1296
+ _destroy_host(self)
1297
+
1298
+ def on_back_pressed(self) -> bool:
1299
+ return _host_back_pressed(self)
966
1300
 
967
1301
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
968
1302
  _enable_hot_reload(self, manifest_path)
@@ -1128,6 +1462,24 @@ else:
1128
1462
  except Exception:
1129
1463
  pass
1130
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
+
1131
1483
  def _ios_register_screen(vc_instance: Any, host_obj: Any) -> None:
1132
1484
  ptr = _objc_addr(vc_instance)
1133
1485
  if ptr is None:
@@ -1272,7 +1624,26 @@ else:
1272
1624
  if self.native_instance is not None:
1273
1625
  _ios_register_screen(self.native_instance, self)
1274
1626
 
1627
+ def _initial_viewport_size(self) -> Optional[Tuple[float, float]]:
1628
+ """Estimate the viewport from the VC's view bounds (pre-attach)."""
1629
+ try:
1630
+ if self.native_instance is not None:
1631
+ bounds = self.native_instance.view.bounds
1632
+ width = float(bounds.size.width)
1633
+ height = float(bounds.size.height)
1634
+ if width > 0 and height > 0:
1635
+ return (width, height)
1636
+ except Exception:
1637
+ pass
1638
+ try:
1639
+ UIScreen = ObjCClass("UIScreen")
1640
+ screen_bounds = UIScreen.mainScreen.bounds
1641
+ return (float(screen_bounds.size.width), float(screen_bounds.size.height))
1642
+ except Exception:
1643
+ return None
1644
+
1275
1645
  def on_create(self) -> None:
1646
+ _ios_publish_color_scheme()
1276
1647
  _on_create(self)
1277
1648
 
1278
1649
  def on_start(self) -> None:
@@ -1287,6 +1658,10 @@ else:
1287
1658
  def on_destroy(self) -> None:
1288
1659
  if self.native_instance is not None:
1289
1660
  _ios_unregister_screen(self.native_instance)
1661
+ _destroy_host(self)
1662
+
1663
+ def on_back_pressed(self) -> bool:
1664
+ return _host_back_pressed(self)
1290
1665
 
1291
1666
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1292
1667
  _enable_hot_reload(self, manifest_path)
@@ -1483,6 +1858,9 @@ else:
1483
1858
  # insets are now valid (initial layout, rotation,
1484
1859
  # multitasking, …). Re-sync the root frame and push the
1485
1860
  # viewport so the layout engine matches the visible area.
1861
+ # Appearance flips also trigger a layout pass, so the
1862
+ # color scheme is re-published here too.
1863
+ _ios_publish_color_scheme()
1486
1864
  if self._root_native_view is None:
1487
1865
  _log_pn("on_layout: no root_native_view yet, skipping")
1488
1866
  return
@@ -1494,6 +1872,7 @@ else:
1494
1872
  # ``viewDidAppear`` always follows ``viewDidLayoutSubviews``,
1495
1873
  # but trigger one extra sync here for safety in case a
1496
1874
  # template overrides the layout call without forwarding.
1875
+ _ios_publish_color_scheme()
1497
1876
  _set_host_focused(self, True)
1498
1877
  if self._root_native_view is None:
1499
1878
  _log_pn("on_resume: no root_native_view yet, skipping")
@@ -1546,7 +1925,10 @@ else:
1546
1925
  pass
1547
1926
 
1548
1927
  def on_destroy(self) -> None:
1549
- pass
1928
+ _destroy_host(self)
1929
+
1930
+ def on_back_pressed(self) -> bool:
1931
+ return _host_back_pressed(self)
1550
1932
 
1551
1933
  def enable_hot_reload(self, manifest_path: str, source_root: Optional[str] = None) -> None:
1552
1934
  _enable_hot_reload(self, manifest_path)
pythonnative/storage.py CHANGED
@@ -375,7 +375,7 @@ 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
- on_click=lambda: set_theme("dark" if theme == "light" else "light"),
378
+ on_press=lambda: set_theme("dark" if theme == "light" else "light"),
379
379
  )
380
380
  ```
381
381
  """
@@ -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["current"] = True
392
+ loaded.current = True
393
393
 
394
394
  use_async_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["current"] is True:
399
+ if loaded.current is True:
400
400
  run_async(AsyncStorage.set_json(key, new_value))
401
401
  return new_value
402
402