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.
- pythonnative/__init__.py +43 -50
- pythonnative/animated.py +1 -1
- pythonnative/appearance.py +124 -0
- pythonnative/cli/pn.py +3 -3
- pythonnative/components.py +873 -466
- pythonnative/diagnostics.py +214 -0
- pythonnative/element.py +5 -2
- pythonnative/events.py +13 -8
- pythonnative/hooks.py +454 -49
- pythonnative/hot_reload.py +9 -1
- pythonnative/images.py +196 -0
- pythonnative/mutations.py +3 -12
- pythonnative/native_views/__init__.py +1 -7
- pythonnative/native_views/android.py +651 -46
- pythonnative/native_views/desktop.py +116 -20
- pythonnative/native_views/ios.py +974 -52
- pythonnative/navigation.py +41 -17
- pythonnative/preview.py +74 -7
- pythonnative/project/config.py +1 -9
- pythonnative/reconciler.py +863 -441
- pythonnative/screen.py +409 -27
- pythonnative/storage.py +3 -3
- pythonnative/style.py +148 -6
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
- pythonnative/virtual_rows.py +137 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/METADATA +5 -4
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/RECORD +35 -28
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/licenses/LICENSE +0 -0
- {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",
|
|
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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
399
|
+
if loaded.current is True:
|
|
400
400
|
run_async(AsyncStorage.set_json(key, new_value))
|
|
401
401
|
return new_value
|
|
402
402
|
|