pythonnative 0.32.0__py3-none-any.whl → 0.34.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. pythonnative/__init__.py +27 -15
  2. pythonnative/animated.py +7 -13
  3. pythonnative/cli/pn.py +1 -2
  4. pythonnative/component.py +255 -0
  5. pythonnative/components/__init__.py +106 -0
  6. pythonnative/components/_base.py +132 -0
  7. pythonnative/components/controls.py +515 -0
  8. pythonnative/components/layout.py +459 -0
  9. pythonnative/components/lists.py +809 -0
  10. pythonnative/components/media.py +204 -0
  11. pythonnative/components/overlays.py +107 -0
  12. pythonnative/components/pressable.py +252 -0
  13. pythonnative/components/structural.py +167 -0
  14. pythonnative/components/text.py +291 -0
  15. pythonnative/diagnostics.py +1 -1
  16. pythonnative/element.py +108 -29
  17. pythonnative/gestures.py +1 -1
  18. pythonnative/hooks.py +366 -671
  19. pythonnative/hosts/__init__.py +85 -0
  20. pythonnative/hosts/android.py +269 -0
  21. pythonnative/hosts/base.py +665 -0
  22. pythonnative/hosts/desktop.py +107 -0
  23. pythonnative/hosts/ios.py +404 -0
  24. pythonnative/hot_reload.py +16 -24
  25. pythonnative/layout.py +372 -66
  26. pythonnative/native_modules/__init__.py +23 -0
  27. pythonnative/native_modules/net_info.py +5 -1
  28. pythonnative/native_views/__init__.py +1 -1
  29. pythonnative/native_views/android.py +50 -6
  30. pythonnative/native_views/base.py +64 -1
  31. pythonnative/native_views/desktop.py +11 -3
  32. pythonnative/native_views/ios.py +72 -12
  33. pythonnative/navigation/__init__.py +102 -0
  34. pythonnative/navigation/container.py +154 -0
  35. pythonnative/navigation/handle.py +573 -0
  36. pythonnative/navigation/hooks.py +94 -0
  37. pythonnative/navigation/host.py +58 -0
  38. pythonnative/navigation/linking.py +200 -0
  39. pythonnative/navigation/navigators.py +637 -0
  40. pythonnative/navigation/screen.py +149 -0
  41. pythonnative/navigation/state.py +248 -0
  42. pythonnative/net.py +4 -0
  43. pythonnative/platform_metrics.py +1 -1
  44. pythonnative/preview.py +21 -19
  45. pythonnative/project/android.py +7 -1
  46. pythonnative/project/doctor.py +1 -1
  47. pythonnative/project/ios.py +1 -0
  48. pythonnative/project/runtime_assets.py +1 -1
  49. pythonnative/reconciler/__init__.py +29 -0
  50. pythonnative/reconciler/boundaries.py +365 -0
  51. pythonnative/reconciler/children.py +88 -0
  52. pythonnative/reconciler/core.py +1153 -0
  53. pythonnative/reconciler/layout_pass.py +359 -0
  54. pythonnative/reconciler/vnode.py +266 -0
  55. pythonnative/scheduler.py +159 -0
  56. pythonnative/sdk/_components.py +2 -4
  57. pythonnative/style.py +95 -13
  58. pythonnative/suspense.py +8 -12
  59. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +85 -1
  60. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +6 -7
  61. pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
  62. pythonnative/templates/android_template/app/src/main/res/values/strings.xml +3 -1
  63. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +33 -0
  64. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +1 -1
  65. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -10
  66. pythonnative/testing/__init__.py +53 -0
  67. pythonnative/testing/backend.py +276 -0
  68. pythonnative/testing/harness.py +391 -0
  69. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/METADATA +2 -21
  70. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/RECORD +74 -43
  71. pythonnative/components.py +0 -2856
  72. pythonnative/navigation.py +0 -1031
  73. pythonnative/reconciler.py +0 -2298
  74. pythonnative/screen.py +0 -2068
  75. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/WHEEL +0 -0
  76. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/entry_points.txt +0 -0
  77. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/licenses/LICENSE +0 -0
  78. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,159 @@
1
+ """Update scheduling primitives shared by hooks and the reconciler.
2
+
3
+ Two pieces live here:
4
+
5
+ - [`batch_updates`][pythonnative.scheduler.batch_updates], a context manager that
6
+ coalesces the render triggers fired by several state setters into
7
+ one, tracked per execution context (so it composes with ``async``
8
+ code).
9
+ - [`TransitionQueue`][pythonnative.scheduler.TransitionQueue], the
10
+ per-reconciler queue behind
11
+ [`use_transition`][pythonnative.use_transition]: renders marked as
12
+ transitions are deferred to a later turn of the framework loop so
13
+ urgent updates (typing, presses) stay responsive.
14
+
15
+ Nothing here is global mutable state beyond context variables, so
16
+ several reconcilers (screens, list rows, tests) never interfere.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from contextlib import contextmanager
22
+ from contextvars import ContextVar
23
+ from typing import Callable, Generator, List, Optional
24
+
25
+ from . import diagnostics
26
+
27
+ __all__ = ["TransitionQueue", "batch_updates", "in_transition", "run_in_transition", "schedule_trigger"]
28
+
29
+ # Depth of nested ``batch_updates`` blocks plus the triggers deferred by
30
+ # the outermost block, per execution context.
31
+ _batch_depth: ContextVar[int] = ContextVar("pn_batch_depth", default=0)
32
+ _batch_pending: ContextVar[Optional[List[Callable[[], None]]]] = ContextVar("pn_batch_pending", default=None)
33
+
34
+ # Whether state updates in the current execution context are marked as
35
+ # transitions (see ``use_transition``).
36
+ _transition_var: ContextVar[bool] = ContextVar("pn_transition", default=False)
37
+
38
+
39
+ def schedule_trigger(trigger: Callable[[], None]) -> None:
40
+ """Run ``trigger`` now, or defer it to the end of the enclosing ``batch_updates`` block."""
41
+ pending = _batch_pending.get()
42
+ if _batch_depth.get() > 0 and pending is not None:
43
+ if trigger not in pending:
44
+ pending.append(trigger)
45
+ else:
46
+ trigger()
47
+
48
+
49
+ @contextmanager
50
+ def batch_updates() -> Generator[None, None, None]:
51
+ """Coalesce multiple state updates into a single re-render.
52
+
53
+ State setters called inside the ``with`` block defer their
54
+ re-render trigger until the block exits, so any number of
55
+ ``set_*`` calls produce at most one render pass.
56
+
57
+ Example:
58
+ ```python
59
+ import pythonnative as pn
60
+
61
+ with pn.batch_updates():
62
+ set_count(1)
63
+ set_name("hello")
64
+ ```
65
+ """
66
+ depth = _batch_depth.get()
67
+ depth_token = _batch_depth.set(depth + 1)
68
+ pending_token = None
69
+ if depth == 0:
70
+ pending_token = _batch_pending.set([])
71
+ try:
72
+ yield
73
+ finally:
74
+ _batch_depth.reset(depth_token)
75
+ if pending_token is not None:
76
+ triggers = _batch_pending.get() or []
77
+ _batch_pending.reset(pending_token)
78
+ for trigger in triggers:
79
+ trigger()
80
+
81
+
82
+ def in_transition() -> bool:
83
+ """Whether state updates in the current context are transitions."""
84
+ return _transition_var.get()
85
+
86
+
87
+ def run_in_transition(fn: Callable[[], None]) -> None:
88
+ """Run ``fn`` with its state updates marked as transitions."""
89
+ token = _transition_var.set(True)
90
+ try:
91
+ fn()
92
+ finally:
93
+ _transition_var.reset(token)
94
+
95
+
96
+ class TransitionQueue:
97
+ """Deferred render triggers and completion callbacks for one reconciler.
98
+
99
+ Triggers added via [`defer`][pythonnative.scheduler.TransitionQueue.defer]
100
+ run together on a later loop turn; callbacks added via
101
+ [`on_complete`][pythonnative.scheduler.TransitionQueue.on_complete]
102
+ run right after them (this is how ``use_transition`` flips its
103
+ ``is_pending`` flag back off once the deferred render committed).
104
+ """
105
+
106
+ __slots__ = ("_triggers", "_callbacks", "_scheduled")
107
+
108
+ def __init__(self) -> None:
109
+ self._triggers: List[Callable[[], None]] = []
110
+ self._callbacks: List[Callable[[], None]] = []
111
+ self._scheduled = False
112
+
113
+ def defer(self, trigger: Callable[[], None]) -> None:
114
+ """Queue ``trigger`` for the next flush."""
115
+ if trigger not in self._triggers:
116
+ self._triggers.append(trigger)
117
+ self._ensure_scheduled()
118
+
119
+ def on_complete(self, callback: Callable[[], None]) -> None:
120
+ """Queue ``callback`` to run after the next flush's triggers."""
121
+ self._callbacks.append(callback)
122
+ self._ensure_scheduled()
123
+
124
+ @property
125
+ def pending(self) -> bool:
126
+ """Whether a flush is scheduled or work is queued."""
127
+ return self._scheduled or bool(self._triggers) or bool(self._callbacks)
128
+
129
+ def flush(self) -> None:
130
+ """Run every deferred trigger, then the completion callbacks."""
131
+ self._scheduled = False
132
+ triggers = self._triggers
133
+ callbacks = self._callbacks
134
+ self._triggers = []
135
+ self._callbacks = []
136
+ for fn in triggers + callbacks:
137
+ try:
138
+ fn()
139
+ except Exception as exc:
140
+ if not diagnostics.report_error(exc, phase="transition"):
141
+ raise
142
+
143
+ def clear(self) -> None:
144
+ """Drop queued work (used on unmount)."""
145
+ self._triggers = []
146
+ self._callbacks = []
147
+ self._scheduled = False
148
+
149
+ def _ensure_scheduled(self) -> None:
150
+ if self._scheduled:
151
+ return
152
+ self._scheduled = True
153
+ from .runtime import get_loop
154
+
155
+ get_loop().call_soon(self._flush_if_scheduled)
156
+
157
+ def _flush_if_scheduled(self) -> None:
158
+ if self._scheduled:
159
+ self.flush()
@@ -170,9 +170,7 @@ def native_component(
170
170
 
171
171
  def decorator(handler_cls: Type[H]) -> Type[H]:
172
172
  if not isinstance(handler_cls, type) or not issubclass(handler_cls, ViewHandler):
173
- raise TypeError(
174
- f"@native_component({name!r}) must decorate a ViewHandler subclass; " f"got {handler_cls!r}"
175
- )
173
+ raise TypeError(f"@native_component({name!r}) must decorate a ViewHandler subclass; got {handler_cls!r}")
176
174
  register_component(name=name, props=props, handlers={plat: handler_cls() for plat in plats})
177
175
  return handler_cls
178
176
 
@@ -385,7 +383,7 @@ def element_factory(name: str) -> Callable[..., Element]:
385
383
  """
386
384
  if name not in _REGISTRY:
387
385
  raise KeyError(
388
- f"No component registered under name {name!r}. " "Use @native_component or register_component first."
386
+ f"No component registered under name {name!r}. Use @native_component or register_component first."
389
387
  )
390
388
 
391
389
  def factory(*children: Element, key: Optional[str] = None, props: Any = None, **kwargs: Any) -> Element:
pythonnative/style.py CHANGED
@@ -35,7 +35,7 @@ Example:
35
35
  """
36
36
 
37
37
  import difflib
38
- from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union
38
+ from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict, Union, get_args
39
39
 
40
40
  from . import diagnostics
41
41
  from .hooks import Context, create_context, use_color_scheme, use_context
@@ -107,6 +107,11 @@ class ShadowOffset(TypedDict):
107
107
  height: float
108
108
 
109
109
 
110
+ ShadowOffsetValue = Union[ShadowOffset, Tuple[float, float], List[float]]
111
+ """``shadow_offset`` / ``text_shadow_offset`` value: an
112
+ [`ShadowOffset`][pythonnative.ShadowOffset] dict or an ``(x, y)`` pair."""
113
+
114
+
110
115
  # ----------------------------------------------------------------------
111
116
  # Transforms
112
117
  # ----------------------------------------------------------------------
@@ -192,6 +197,7 @@ AlignItems = Literal[
192
197
  "flex_start",
193
198
  "center",
194
199
  "flex_end",
200
+ "baseline",
195
201
  "auto",
196
202
  "start",
197
203
  "leading",
@@ -203,9 +209,18 @@ AlignItems = Literal[
203
209
  ]
204
210
  AlignSelf = AlignItems
205
211
  Position = Literal["relative", "absolute"]
212
+ Display = Literal["flex", "none"]
213
+ """``display`` style value. ``"none"`` removes the node and its subtree
214
+ from layout entirely (no size, gap, or margin contribution; every frame
215
+ in the subtree is ``(0, 0, 0, 0)``)."""
206
216
  Overflow = Literal["visible", "hidden", "scroll"]
207
217
  TextAlign = Literal["left", "center", "right", "justify", "start", "end"]
208
218
  TextDecoration = Literal["none", "underline", "line_through"]
219
+ TextTransform = Literal["none", "uppercase", "lowercase", "capitalize"]
220
+ """``text_transform`` style value. Applied in Python before the string
221
+ reaches the native label, so measurement and rendering agree."""
222
+ MarginValue = Union[Dimension, Literal["auto"]]
223
+ """A margin length: points, a ``"%"`` string, or ``"auto"`` to absorb free space."""
209
224
  FontWeight = Literal[
210
225
  "normal",
211
226
  "bold",
@@ -294,6 +309,7 @@ class Style(TypedDict, total=False):
294
309
  align_self: AlignSelf
295
310
  align_content: AlignContent
296
311
  direction: LayoutDirection
312
+ display: Display
297
313
 
298
314
  # --- Layout: position ---
299
315
  position: Position
@@ -314,15 +330,15 @@ class Style(TypedDict, total=False):
314
330
  padding_end: Dimension
315
331
  padding_horizontal: Dimension
316
332
  padding_vertical: Dimension
317
- margin: EdgeValue
318
- margin_top: Dimension
319
- margin_bottom: Dimension
320
- margin_left: Dimension
321
- margin_right: Dimension
322
- margin_start: Dimension
323
- margin_end: Dimension
324
- margin_horizontal: Dimension
325
- margin_vertical: Dimension
333
+ margin: Union[MarginValue, EdgeInsets]
334
+ margin_top: MarginValue
335
+ margin_bottom: MarginValue
336
+ margin_left: MarginValue
337
+ margin_right: MarginValue
338
+ margin_start: MarginValue
339
+ margin_end: MarginValue
340
+ margin_horizontal: MarginValue
341
+ margin_vertical: MarginValue
326
342
  spacing: float
327
343
  gap: float
328
344
  row_gap: float
@@ -362,13 +378,17 @@ class Style(TypedDict, total=False):
362
378
  italic: bool
363
379
  text_align: TextAlign
364
380
  text_decoration: TextDecoration
381
+ text_transform: TextTransform
365
382
  line_height: float
366
383
  letter_spacing: float
367
384
  max_lines: int
385
+ text_shadow_color: Color
386
+ text_shadow_offset: ShadowOffsetValue
387
+ text_shadow_radius: float
368
388
 
369
389
  # --- Visual: shadows / effects ---
370
390
  shadow_color: Color
371
- shadow_offset: Union[ShadowOffset, Tuple[float, float], List[float]]
391
+ shadow_offset: ShadowOffsetValue
372
392
  shadow_opacity: float
373
393
  shadow_radius: float
374
394
  elevation: float
@@ -447,6 +467,49 @@ def resolve_style(value: StyleProp) -> Dict[str, Any]:
447
467
  _KNOWN_STYLE_KEYS = frozenset(Style.__annotations__)
448
468
  """Every key declared on the [`Style`][pythonnative.Style] TypedDict."""
449
469
 
470
+ _STYLE_VALUE_CHOICES: Dict[str, frozenset] = {
471
+ "display": frozenset(get_args(Display)),
472
+ "position": frozenset(get_args(Position)),
473
+ "direction": frozenset(get_args(LayoutDirection)),
474
+ "text_decoration": frozenset(get_args(TextDecoration)),
475
+ "text_transform": frozenset(get_args(TextTransform)),
476
+ "pointer_events": frozenset(get_args(PointerEvents)),
477
+ }
478
+ """Keys whose string values are checked against their ``Literal`` choices
479
+ in dev mode. Only keys whose handlers accept exactly the declared
480
+ literals are listed, so friendly aliases (``align_items: "leading"``,
481
+ ``font_weight: "semibold"``) never trigger false positives."""
482
+
483
+
484
+ def _is_number(value: Any) -> bool:
485
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
486
+
487
+
488
+ def _text_shadow_offset_ok(value: Any) -> bool:
489
+ if isinstance(value, dict):
490
+ return all(_is_number(value.get(k, 0)) for k in ("width", "height"))
491
+ if isinstance(value, (tuple, list)) and len(value) == 2:
492
+ return all(_is_number(v) for v in value)
493
+ return False
494
+
495
+
496
+ def _bad_value_message(key: str, value: Any) -> Optional[str]:
497
+ """Return a description of why ``value`` is invalid for ``key``, or ``None``."""
498
+ choices = _STYLE_VALUE_CHOICES.get(key)
499
+ if choices is not None:
500
+ if not isinstance(value, str) or value not in choices:
501
+ allowed = ", ".join(repr(c) for c in sorted(choices))
502
+ return f"Invalid value {value!r} for style key {key!r}; expected one of {allowed}."
503
+ return None
504
+ if key == "text_shadow_offset" and not _text_shadow_offset_ok(value):
505
+ return (
506
+ f"Invalid value {value!r} for style key 'text_shadow_offset'; "
507
+ "expected {'width': x, 'height': y} or an (x, y) pair."
508
+ )
509
+ if key == "text_shadow_radius" and (not _is_number(value) or value < 0):
510
+ return f"Invalid value {value!r} for style key 'text_shadow_radius'; expected a non-negative number."
511
+ return None
512
+
450
513
 
451
514
  def validate_style_keys(style_dict: Dict[str, Any], owner: str = "") -> None:
452
515
  """Warn (once per key) about style keys no built-in handler reads.
@@ -457,6 +520,13 @@ def validate_style_keys(style_dict: Dict[str, Any], owner: str = "") -> None:
457
520
  to handlers untouched, so custom components that read extra keys
458
521
  keep working (at the cost of one dev warning).
459
522
 
523
+ A small set of enum-shaped keys (``display``, ``position``,
524
+ ``direction``, ``text_decoration``, ``text_transform``,
525
+ ``pointer_events``) and the ``text_shadow_*`` keys also have their
526
+ values checked; a bad value warns once per key/value pair and is
527
+ otherwise passed through (the engine and handlers ignore values
528
+ they don't recognize).
529
+
460
530
  Args:
461
531
  style_dict: The flattened style dict about to be merged into an
462
532
  element's props.
@@ -465,8 +535,16 @@ def validate_style_keys(style_dict: Dict[str, Any], owner: str = "") -> None:
465
535
  """
466
536
  if not diagnostics.is_dev() or not style_dict:
467
537
  return
468
- for key in style_dict:
538
+ for key, value in style_dict.items():
469
539
  if key in _KNOWN_STYLE_KEYS:
540
+ if value is None:
541
+ continue
542
+ problem = _bad_value_message(key, value)
543
+ if problem is not None:
544
+ diagnostics.warn_once(
545
+ problem + (f" (on {owner})" if owner else ""),
546
+ key=f"style-value:{owner}:{key}:{value!r}",
547
+ )
470
548
  continue
471
549
  matches = difflib.get_close_matches(str(key), _KNOWN_STYLE_KEYS, n=1)
472
550
  hint = f" Did you mean {matches[0]!r}?" if matches else ""
@@ -621,7 +699,7 @@ Without a provider, [`use_theme`][pythonnative.use_theme] resolves to
621
699
  [`DEFAULT_LIGHT_THEME`][pythonnative.style.DEFAULT_LIGHT_THEME] or
622
700
  [`DEFAULT_DARK_THEME`][pythonnative.style.DEFAULT_DARK_THEME] based on
623
701
  the current appearance. Wrap a subtree in
624
- [`Provider(ThemeContext, my_theme, ...)`][pythonnative.Provider] to
702
+ [`ThemeContext.Provider(my_theme, ...)`][pythonnative.hooks.Context.Provider] to
625
703
  pin an explicit theme for that subtree, then read it inside
626
704
  descendants via [`use_theme`][pythonnative.use_theme] (or
627
705
  [`use_context(ThemeContext)`][pythonnative.use_context]).
@@ -676,23 +754,27 @@ __all__ = [
676
754
  "DEFAULT_DARK_THEME",
677
755
  "DEFAULT_LIGHT_THEME",
678
756
  "Dimension",
757
+ "Display",
679
758
  "EdgeInsets",
680
759
  "EdgeValue",
681
760
  "FlexDirection",
682
761
  "FontWeight",
683
762
  "JustifyContent",
684
763
  "KeyboardType",
764
+ "MarginValue",
685
765
  "Overflow",
686
766
  "PointerEvents",
687
767
  "Position",
688
768
  "ReturnKeyType",
689
769
  "ScaleType",
690
770
  "ShadowOffset",
771
+ "ShadowOffsetValue",
691
772
  "Style",
692
773
  "StyleProp",
693
774
  "StyleSheet",
694
775
  "TextAlign",
695
776
  "TextDecoration",
777
+ "TextTransform",
696
778
  "ThemeContext",
697
779
  "TransformEntry",
698
780
  "TransformRotate",
pythonnative/suspense.py CHANGED
@@ -386,26 +386,22 @@ def lazy(loader: Callable[[], Any]) -> Callable[..., Any]:
386
386
  )
387
387
  ```
388
388
  """
389
- from .hooks import component
389
+ from .component import Component
390
390
 
391
391
  state: dict = {}
392
392
 
393
- @component
394
- def Lazy(**props: Any) -> Any:
393
+ def Lazy(*children: Any, **props: Any) -> Any:
395
394
  resource = state.get("resource")
396
395
  if resource is None:
397
396
  resource = start_resource(loader)
398
397
  state["resource"] = resource
399
398
  loaded = resource.read()
400
- children = props.pop("children", None)
401
- if children:
402
- return loaded(*children, **props)
403
- return loaded(**props)
404
-
405
- Lazy.__name__ = getattr(loader, "__name__", "Lazy")
406
- if Lazy.__name__ == "<lambda>":
407
- Lazy.__name__ = "Lazy"
408
- return Lazy
399
+ return loaded(*children, **props)
400
+
401
+ name = getattr(loader, "__name__", "Lazy")
402
+ if name == "<lambda>":
403
+ name = "Lazy"
404
+ return Component(Lazy, display_name=name)
409
405
 
410
406
 
411
407
  __all__ = [
@@ -1,7 +1,10 @@
1
1
  package com.pythonnative.android_template
2
2
 
3
+ import android.content.BroadcastReceiver
3
4
  import android.content.Context
4
5
  import android.content.Intent
6
+ import android.content.IntentFilter
7
+ import android.os.BatteryManager
5
8
  import android.graphics.Color
6
9
  import android.graphics.Typeface
7
10
  import android.net.ConnectivityManager
@@ -20,6 +23,7 @@ class MainActivity : AppCompatActivity() {
20
23
  private val TAG = javaClass.simpleName
21
24
  private var pythonReady = false
22
25
  private var networkCallback: ConnectivityManager.NetworkCallback? = null
26
+ private var batteryReceiver: BroadcastReceiver? = null
23
27
 
24
28
  override fun onCreate(savedInstanceState: Bundle?) {
25
29
  super.onCreate(savedInstanceState)
@@ -42,7 +46,7 @@ class MainActivity : AppCompatActivity() {
42
46
  }
43
47
  // Import the entry module now so a broken app fails here,
44
48
  // with a full traceback, instead of inside the first fragment.
45
- py.getModule("app.main")
49
+ py.getModule(getString(R.string.pn_entry_module))
46
50
  pythonReady = true
47
51
  } catch (e: Exception) {
48
52
  Log.e("PythonNative", "Bootstrap failed", e)
@@ -54,10 +58,19 @@ class MainActivity : AppCompatActivity() {
54
58
  intent?.dataString?.let { dispatchUrl(it) }
55
59
 
56
60
  registerNetworkCallback()
61
+ registerBatteryReceiver()
57
62
  }
58
63
 
59
64
  override fun onDestroy() {
60
65
  super.onDestroy()
66
+ batteryReceiver?.let {
67
+ try {
68
+ unregisterReceiver(it)
69
+ } catch (e: Exception) {
70
+ Log.e("PythonNative", "unregisterReceiver(battery) failed", e)
71
+ }
72
+ batteryReceiver = null
73
+ }
61
74
  networkCallback?.let {
62
75
  try {
63
76
  (getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager)
@@ -69,6 +82,35 @@ class MainActivity : AppCompatActivity() {
69
82
  }
70
83
  }
71
84
 
85
+ @Deprecated("Deprecated in Java")
86
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
87
+ super.onActivityResult(requestCode, resultCode, data)
88
+ if (!pythonReady) return
89
+ try {
90
+ Python.getInstance()
91
+ .getModule("pythonnative.native_modules")
92
+ .callAttr("dispatch_activity_result", requestCode, resultCode, data)
93
+ } catch (e: Exception) {
94
+ Log.e("PythonNative", "dispatch_activity_result failed", e)
95
+ }
96
+ }
97
+
98
+ override fun onRequestPermissionsResult(
99
+ requestCode: Int,
100
+ permissions: Array<out String>,
101
+ grantResults: IntArray
102
+ ) {
103
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
104
+ if (!pythonReady) return
105
+ try {
106
+ Python.getInstance()
107
+ .getModule("pythonnative.native_modules")
108
+ .callAttr("dispatch_permissions_result", requestCode, permissions.toList(), grantResults.toList())
109
+ } catch (e: Exception) {
110
+ Log.e("PythonNative", "dispatch_permissions_result failed", e)
111
+ }
112
+ }
113
+
72
114
  override fun onNewIntent(intent: Intent) {
73
115
  super.onNewIntent(intent)
74
116
  setIntent(intent)
@@ -143,6 +185,48 @@ class MainActivity : AppCompatActivity() {
143
185
  }
144
186
  }
145
187
 
188
+ // MARK: - Battery forwarding
189
+
190
+ // ACTION_BATTERY_CHANGED is a sticky broadcast, so registering also
191
+ // delivers the current level immediately.
192
+ private fun registerBatteryReceiver() {
193
+ if (!pythonReady || batteryReceiver != null) return
194
+ try {
195
+ val receiver = object : BroadcastReceiver() {
196
+ override fun onReceive(context: Context?, intent: Intent?) {
197
+ if (intent == null) return
198
+ val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
199
+ val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
200
+ val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
201
+ val fraction = if (level >= 0 && scale > 0) level.toDouble() / scale.toDouble() else -1.0
202
+ val state = when (status) {
203
+ BatteryManager.BATTERY_STATUS_CHARGING -> "charging"
204
+ BatteryManager.BATTERY_STATUS_FULL -> "full"
205
+ BatteryManager.BATTERY_STATUS_DISCHARGING,
206
+ BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "unplugged"
207
+ else -> "unknown"
208
+ }
209
+ dispatchBattery(fraction, state)
210
+ }
211
+ }
212
+ registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
213
+ batteryReceiver = receiver
214
+ } catch (e: Exception) {
215
+ Log.e("PythonNative", "registerReceiver(battery) failed", e)
216
+ }
217
+ }
218
+
219
+ private fun dispatchBattery(level: Double, state: String) {
220
+ if (!pythonReady) return
221
+ try {
222
+ Python.getInstance()
223
+ .getModule("pythonnative.native_modules.battery")
224
+ .callAttr("dispatch_battery", level, state)
225
+ } catch (e: Exception) {
226
+ Log.e("PythonNative", "dispatch_battery failed", e)
227
+ }
228
+ }
229
+
146
230
  // MARK: - Deep links
147
231
 
148
232
  private fun dispatchUrl(url: String) {
@@ -38,18 +38,17 @@ class ScreenFragment : Fragment() {
38
38
  }
39
39
  try {
40
40
  val py = Python.getInstance()
41
- // Default to "app.main": PythonNative imports the module
42
- // and looks up its top-level `App` attribute. Override
43
- // via fragment arguments / nav graph to load a different
44
- // module or a specific dotted-attribute path (e.g.
45
- // "app.main.RootScreen").
46
- val screenPath = arguments?.getString("screen_path") ?: "app.main"
41
+ // PythonNative imports the entry module (app.entry_point in
42
+ // pythonnative.toml, staged into R.string.pn_entry_module)
43
+ // and looks up its top-level `App` attribute. Pushed screens
44
+ // pass an explicit path in the fragment arguments.
45
+ val screenPath = arguments?.getString("screen_path") ?: getString(R.string.pn_entry_module)
47
46
  val argsJson = arguments?.getString("args_json")
48
47
  val filesRoot = requireContext().filesDir.absolutePath
49
48
  val devRoot = "$filesRoot/pythonnative_dev"
50
49
  val hotReload = py.getModule("pythonnative.hot_reload")
51
50
  hotReload.callAttr("configure_dev_environment", filesRoot)
52
- val pnScreen = py.getModule("pythonnative.screen")
51
+ val pnScreen = py.getModule("pythonnative.hosts")
53
52
  screen = pnScreen.callAttr("create_screen", screenPath, requireActivity(), argsJson)
54
53
  screen?.callAttr("enable_hot_reload", "$devRoot/reload.json", devRoot)
55
54
  } catch (e: Exception) {
@@ -12,7 +12,7 @@
12
12
  <argument
13
13
  android:name="screen_path"
14
14
  app:argType="string"
15
- android:defaultValue="app.main" />
15
+ app:nullable="true" />
16
16
  <argument
17
17
  android:name="args_json"
18
18
  app:argType="string"
@@ -1,3 +1,5 @@
1
1
  <resources>
2
2
  <string name="app_name">android_template</string>
3
- </resources>
3
+ <!-- Dotted module whose top-level `App` is mounted; set from app.entry_point by `pn build`. -->
4
+ <string name="pn_entry_module" translatable="false">app.main</string>
5
+ </resources>
@@ -16,9 +16,42 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
16
16
  _ application: UIApplication,
17
17
  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
18
18
  ) -> Bool {
19
+ registerBatteryObservers()
19
20
  return true
20
21
  }
21
22
 
23
+ // MARK: - Battery forwarding
24
+
25
+ // UIDevice only posts battery notifications while monitoring is on;
26
+ // Battery.add_listener subscribers receive {"level", "state"}.
27
+ private func registerBatteryObservers() {
28
+ UIDevice.current.isBatteryMonitoringEnabled = true
29
+ let center = NotificationCenter.default
30
+ for name in [UIDevice.batteryLevelDidChangeNotification, UIDevice.batteryStateDidChangeNotification] {
31
+ center.addObserver(forName: name, object: nil, queue: .main) { _ in
32
+ self.dispatchBattery()
33
+ }
34
+ }
35
+ }
36
+
37
+ private func dispatchBattery() {
38
+ guard PythonRuntime.shared.started else { return }
39
+ let device = UIDevice.current
40
+ let state: String
41
+ switch device.batteryState {
42
+ case .unplugged: state = "unplugged"
43
+ case .charging: state = "charging"
44
+ case .full: state = "full"
45
+ default: state = "unknown"
46
+ }
47
+ PythonRuntime.shared.notify(
48
+ module: "pythonnative.native_modules.battery",
49
+ function: "dispatch_battery",
50
+ Double(device.batteryLevel),
51
+ state
52
+ )
53
+ }
54
+
22
55
  // MARK: - Remote notifications (APNs)
23
56
 
24
57
  func application(
@@ -392,6 +392,6 @@ final class PythonRuntime {
392
392
  public func pn_schedule_render_drain() {
393
393
  DispatchQueue.main.async {
394
394
  guard PythonRuntime.shared.started else { return }
395
- PythonRuntime.shared.notify(module: "pythonnative.screen", function: "drain_ios_scheduled_renders")
395
+ PythonRuntime.shared.notify(module: "pythonnative.hosts", function: "drain_ios_scheduled_renders")
396
396
  }
397
397
  }