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
@@ -43,6 +43,8 @@ Reactive state (with hooks):
43
43
  [`use_net_info`][pythonnative.use_net_info]: connectivity.
44
44
  """
45
45
 
46
+ from typing import Any, Sequence
47
+
46
48
  from .app_state import AppState, use_app_state
47
49
  from .battery import Battery
48
50
  from .biometrics import Biometrics
@@ -58,6 +60,25 @@ from .permissions import Permissions
58
60
  from .secure_store import SecureStore
59
61
  from .share import Share
60
62
 
63
+
64
+ def dispatch_activity_result(request_code: int, result_code: int, data: Any) -> bool:
65
+ """Route an Android ``onActivityResult`` to whichever module is waiting on it.
66
+
67
+ Called by the screen host. Returns ``True`` when a pending request
68
+ consumed the result.
69
+ """
70
+ from .camera import deliver_android_activity_result
71
+
72
+ return deliver_android_activity_result(request_code, result_code, data)
73
+
74
+
75
+ def dispatch_permissions_result(request_code: int, permissions: Sequence[str], grant_results: Sequence[int]) -> bool:
76
+ """Route an Android ``onRequestPermissionsResult`` to the pending ``Permissions.request``."""
77
+ from .permissions import deliver_android_permission_result
78
+
79
+ return deliver_android_permission_result(request_code, permissions, grant_results)
80
+
81
+
61
82
  __all__ = [
62
83
  "AppState",
63
84
  "Battery",
@@ -74,6 +95,8 @@ __all__ = [
74
95
  "SecureStore",
75
96
  "Share",
76
97
  "Vibration",
98
+ "dispatch_activity_result",
99
+ "dispatch_permissions_result",
77
100
  "use_app_state",
78
101
  "use_net_info",
79
102
  ]
@@ -151,7 +151,11 @@ def use_net_info() -> NetInfoState:
151
151
 
152
152
  def _subscribe() -> Callable[[], None]:
153
153
  set_state(NetInfo.fetch())
154
- return NetInfo.add_listener(set_state)
154
+
155
+ def on_change(snapshot: NetInfoState) -> None:
156
+ set_state(snapshot)
157
+
158
+ return NetInfo.add_listener(on_change)
155
159
 
156
160
  use_effect(_subscribe, [])
157
161
  return state
@@ -220,7 +220,7 @@ class NativeViewRegistry:
220
220
  if not finite:
221
221
  _tripwire_log(
222
222
  "set_frame:nan",
223
- f"[set_frame:nan] type={record.type_name!r} " f"x={op.x!r} y={op.y!r} w={op.width!r} h={op.height!r}",
223
+ f"[set_frame:nan] type={record.type_name!r} x={op.x!r} y={op.y!r} w={op.width!r} h={op.height!r}",
224
224
  )
225
225
  record.handler.set_frame(record.view, op.x, op.y, op.width, op.height)
226
226
 
@@ -60,7 +60,15 @@ from .. import diagnostics
60
60
  from ..events import dispatch_event, event_names
61
61
  from ..gestures import make_arbiter
62
62
  from ..utils import get_android_context
63
- from .base import ViewHandler, _safe_max, parse_color_int
63
+ from .base import (
64
+ TEXT_SHADOW_STYLE_KEYS,
65
+ ViewHandler,
66
+ _safe_max,
67
+ parse_color_int,
68
+ shadow_offset_xy,
69
+ transform_spans,
70
+ transform_text,
71
+ )
64
72
 
65
73
  _SIDE_BORDER_WIDTH_KEYS = (
66
74
  "border_left_width",
@@ -676,6 +684,15 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
676
684
  view.setClipToPadding(clip)
677
685
  except Exception:
678
686
  diagnostics.swallowed("android._apply_common_visual")
687
+ if "display" in props:
688
+ # Layout already zeroes the subtree; ``INVISIBLE`` (not ``GONE``,
689
+ # since Python owns layout) also drops it from touch delivery and
690
+ # the accessibility tree (inactive tabs, covered stack screens).
691
+ try:
692
+ View = jclass("android.view.View")
693
+ view.setVisibility(View.INVISIBLE if props["display"] == "none" else View.VISIBLE)
694
+ except Exception:
695
+ diagnostics.swallowed("android._apply_common_visual")
679
696
  if "opacity" in props and props["opacity"] is not None:
680
697
  try:
681
698
  view.setAlpha(float(props["opacity"]))
@@ -1305,21 +1322,21 @@ class TextHandler(AndroidViewHandler):
1305
1322
  return jclass("android.widget.TextView")(_ctx())
1306
1323
 
1307
1324
  def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
1308
- if "spans" in props or "text" in props:
1325
+ if "spans" in props or "text" in props or "text_transform" in props:
1309
1326
  # ``update()`` merges changed props into the view state
1310
1327
  # before calling ``_apply``, so the merged dict always has
1311
1328
  # the latest text/spans pair (covering rich -> plain
1312
1329
  # transitions where only ``spans`` changed).
1313
1330
  merged = _state_of(tv).get("props") or props
1331
+ transform = merged.get("text_transform")
1314
1332
  spans = merged.get("spans")
1315
1333
  if spans:
1316
1334
  try:
1317
- tv.setText(_build_spannable(spans))
1335
+ tv.setText(_build_spannable(transform_spans(spans, transform)))
1318
1336
  except Exception:
1319
- tv.setText(str(merged.get("text") or ""))
1337
+ tv.setText(transform_text(merged.get("text"), transform))
1320
1338
  else:
1321
- text = merged.get("text")
1322
- tv.setText(str(text) if text is not None else "")
1339
+ tv.setText(transform_text(merged.get("text"), transform))
1323
1340
  if "font_size" in props and props["font_size"] is not None:
1324
1341
  tv.setTextSize(float(props["font_size"]))
1325
1342
  if "color" in props and props["color"] is not None:
@@ -1372,8 +1389,35 @@ class TextHandler(AndroidViewHandler):
1372
1389
  tv.setPaintFlags(flags)
1373
1390
  except Exception:
1374
1391
  diagnostics.swallowed("android.TextHandler._apply")
1392
+ if any(k in props for k in TEXT_SHADOW_STYLE_KEYS):
1393
+ self._apply_text_shadow(tv, _state_of(tv).get("props") or props)
1375
1394
  _apply_common_visual(tv, props)
1376
1395
 
1396
+ @staticmethod
1397
+ def _apply_text_shadow(tv: Any, merged: Dict[str, Any]) -> None:
1398
+ """Map the ``text_shadow_*`` keys onto ``TextView.setShadowLayer``.
1399
+
1400
+ The offset and radius are in dp (converted to pixels like every
1401
+ other length); the platform disables the shadow when every
1402
+ parameter is zero, which is also what clearing the keys does.
1403
+ """
1404
+ try:
1405
+ if not any(merged.get(k) is not None for k in TEXT_SHADOW_STYLE_KEYS):
1406
+ tv.setShadowLayer(0.0, 0.0, 0.0, 0)
1407
+ return
1408
+ color = merged.get("text_shadow_color")
1409
+ argb = parse_color_int(color if color is not None else "#000000")
1410
+ dx, dy = shadow_offset_xy(merged.get("text_shadow_offset"))
1411
+ radius = merged.get("text_shadow_radius")
1412
+ radius_px = float(_dp(float(radius))) if radius is not None else 0.0
1413
+ # A zero radius with a non-zero offset would hide the shadow
1414
+ # on some renderers; use a hairline blur so it still draws.
1415
+ if radius_px <= 0.0 and (dx or dy):
1416
+ radius_px = 0.01
1417
+ tv.setShadowLayer(radius_px, float(_dp(dx)), float(_dp(dy)), argb)
1418
+ except Exception:
1419
+ diagnostics.swallowed("android.TextHandler._apply_text_shadow")
1420
+
1377
1421
 
1378
1422
  class ButtonHandler(AndroidViewHandler):
1379
1423
  def _build(self, props: Dict[str, Any]) -> Any:
@@ -15,7 +15,8 @@ for content-sized leaves (text, buttons, images).
15
15
  """
16
16
 
17
17
  import math
18
- from typing import Any, Dict, Tuple, Union
18
+ import re
19
+ from typing import Any, Dict, Optional, Tuple, Union
19
20
 
20
21
 
21
22
  class ViewHandler:
@@ -241,6 +242,68 @@ def parse_color_int(color: Union[str, int]) -> int:
241
242
  return val
242
243
 
243
244
 
245
+ # ======================================================================
246
+ # Text helpers shared by every backend's Text handler
247
+ # ======================================================================
248
+
249
+ _WORD_START = re.compile(r"(?<!\S)(\S)")
250
+
251
+
252
+ def transform_text(text: Any, mode: Optional[str]) -> str:
253
+ """Apply a ``text_transform`` style value to ``text``.
254
+
255
+ Runs in Python before the string is handed to the native label, so
256
+ the displayed text and the intrinsic measurement agree on every
257
+ backend. ``"capitalize"`` upper-cases the first character of each
258
+ whitespace-separated word and leaves the rest untouched (matching
259
+ CSS / React Native rather than ``str.title()``, which would
260
+ lower-case the remainder). Unknown modes and ``"none"`` return the
261
+ text unchanged.
262
+ """
263
+ s = "" if text is None else str(text)
264
+ if mode == "uppercase":
265
+ return s.upper()
266
+ if mode == "lowercase":
267
+ return s.lower()
268
+ if mode == "capitalize":
269
+ return _WORD_START.sub(lambda m: m.group(1).upper(), s)
270
+ return s
271
+
272
+
273
+ def transform_spans(spans: Any, mode: Optional[str]) -> Any:
274
+ """Return ``spans`` with `transform_text` applied to each span's ``text``.
275
+
276
+ Rich text is flattened into one native string, so a run-by-run
277
+ transform matches what a single-string transform would produce for
278
+ ``uppercase`` / ``lowercase``. ``capitalize`` is evaluated per span,
279
+ which is also how CSS treats inline boxes.
280
+ """
281
+ if not mode or mode == "none" or not isinstance(spans, list):
282
+ return spans
283
+ out = []
284
+ for span in spans:
285
+ if isinstance(span, dict) and "text" in span:
286
+ copy = dict(span)
287
+ copy["text"] = transform_text(span.get("text"), mode)
288
+ out.append(copy)
289
+ else:
290
+ out.append(span)
291
+ return out
292
+
293
+
294
+ def shadow_offset_xy(value: Any) -> Tuple[float, float]:
295
+ """Coerce a ``shadow_offset`` / ``text_shadow_offset`` value to ``(dx, dy)``."""
296
+ if isinstance(value, dict):
297
+ return (float(value.get("width", 0) or 0), float(value.get("height", 0) or 0))
298
+ if isinstance(value, (tuple, list)) and len(value) >= 2:
299
+ return (float(value[0]), float(value[1]))
300
+ return (0.0, 0.0)
301
+
302
+
303
+ TEXT_SHADOW_STYLE_KEYS = ("text_shadow_color", "text_shadow_offset", "text_shadow_radius")
304
+ """Style keys that together describe a text shadow."""
305
+
306
+
244
307
  # ======================================================================
245
308
  # Helpers shared by Android and iOS measure callbacks
246
309
  # ======================================================================
@@ -96,7 +96,7 @@ from typing import Any, Dict, List, Optional, Tuple
96
96
  from .. import diagnostics
97
97
  from ..events import dispatch_event, event_names
98
98
  from ..gestures import make_arbiter
99
- from .base import ViewHandler
99
+ from .base import ViewHandler, transform_text
100
100
 
101
101
  # ======================================================================
102
102
  # Stage / root container
@@ -1452,13 +1452,21 @@ _JUSTIFY_FOR_ALIGN = {"left": "left", "center": "center", "right": "right"}
1452
1452
 
1453
1453
 
1454
1454
  class TextHandler(DesktopViewHandler):
1455
+ """Tk ``Label`` text leaf.
1456
+
1457
+ ``text_transform`` is applied to the string before it is stored, so
1458
+ ``measure_intrinsic`` and the rendered label agree. The
1459
+ ``text_shadow_*`` keys are accepted but ignored: Tk has no text
1460
+ shadow primitive, and the preview stays faithful to layout rather
1461
+ than approximating one with a second label.
1462
+ """
1463
+
1455
1464
  def build(self, props: Dict[str, Any]) -> Any:
1456
1465
  return tk.Label(_master(), highlightthickness=0, bd=0, padx=0, pady=0)
1457
1466
 
1458
1467
  def apply(self, label: Any, props: Dict[str, Any]) -> None:
1459
1468
  merged = getattr(label, "_pn_props", props)
1460
- text = merged.get("text")
1461
- label._pn_text = "" if text is None else str(text)
1469
+ label._pn_text = transform_text(merged.get("text"), merged.get("text_transform"))
1462
1470
  font = _make_font(merged)
1463
1471
  label._pn_font = font
1464
1472
  opts: Dict[str, Any] = {"text": label._pn_text, "font": font}
@@ -63,7 +63,15 @@ from rubicon.objc import SEL, ObjCClass, ObjCInstance, objc_method
63
63
  from .. import diagnostics
64
64
  from ..events import dispatch_event, event_names
65
65
  from . import _tripwire_log
66
- from .base import ViewHandler, _safe_max, parse_color_int
66
+ from .base import (
67
+ TEXT_SHADOW_STYLE_KEYS,
68
+ ViewHandler,
69
+ _safe_max,
70
+ parse_color_int,
71
+ shadow_offset_xy,
72
+ transform_spans,
73
+ transform_text,
74
+ )
67
75
 
68
76
 
69
77
  def _safe_finite(value: Any, default: float = 0.0) -> float:
@@ -624,6 +632,30 @@ def _apply_shadow(view: Any, props: Dict[str, Any]) -> None:
624
632
  diagnostics.swallowed("ios._apply_shadow")
625
633
 
626
634
 
635
+ def _text_shadow(props: Dict[str, Any]) -> Any:
636
+ """Build an ``NSShadow`` from the ``text_shadow_*`` style keys, or ``None``.
637
+
638
+ Text shadows are attached to the attributed string
639
+ (``NSShadowAttributeName``) rather than the label's layer so they
640
+ follow the glyphs instead of the label's frame and don't interfere
641
+ with the view-level ``shadow_*`` props.
642
+ """
643
+ if not any(props.get(k) is not None for k in TEXT_SHADOW_STYLE_KEYS):
644
+ return None
645
+ try:
646
+ shadow = ObjCClass("NSShadow").alloc().init()
647
+ color = props.get("text_shadow_color")
648
+ shadow.setShadowColor_(_uicolor(color if color is not None else "#000000"))
649
+ dx, dy = shadow_offset_xy(props.get("text_shadow_offset"))
650
+ shadow.setShadowOffset_((dx, dy))
651
+ radius = props.get("text_shadow_radius")
652
+ shadow.setShadowBlurRadius_(float(radius) if radius is not None else 0.0)
653
+ return shadow
654
+ except Exception:
655
+ diagnostics.swallowed("ios._text_shadow")
656
+ return None
657
+
658
+
627
659
  def _make_transform(spec: Any) -> Any:
628
660
  """Build a `CGAffineTransform` from a list of transform dicts.
629
661
 
@@ -821,6 +853,14 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
821
853
  diagnostics.swallowed("ios._apply_common_visual")
822
854
  if "overflow" in props:
823
855
  view.setClipsToBounds_(props["overflow"] == "hidden")
856
+ if "display" in props:
857
+ # Layout already zeroes the subtree; hiding also removes it from
858
+ # hit testing and the accessibility tree (inactive tabs, covered
859
+ # stack screens).
860
+ try:
861
+ view.setHidden_(props["display"] == "none")
862
+ except Exception:
863
+ diagnostics.swallowed("ios._apply_common_visual")
824
864
  if "opacity" in props and props["opacity"] is not None:
825
865
  try:
826
866
  view.setAlpha_(float(props["opacity"]))
@@ -1969,25 +2009,32 @@ class TextHandler(IOSViewHandler):
1969
2009
  diagnostics.swallowed("ios.TextHandler._font_for")
1970
2010
  return font
1971
2011
 
2012
+ # Keys that need the plain string re-rendered as an attributed
2013
+ # string (plain ``setText_`` can't express them).
2014
+ _ATTRIBUTED_KEYS = (
2015
+ "letter_spacing",
2016
+ "line_height",
2017
+ "text_decoration",
2018
+ ) + TEXT_SHADOW_STYLE_KEYS
2019
+
1972
2020
  _SPAN_REBUILD_KEYS = (
1973
2021
  "spans",
1974
2022
  "text",
2023
+ "text_transform",
1975
2024
  "font_size",
1976
2025
  "font_weight",
1977
2026
  "font_family",
1978
2027
  "italic",
1979
2028
  "bold",
1980
2029
  "color",
1981
- "letter_spacing",
1982
- "line_height",
1983
- "text_decoration",
1984
- )
2030
+ ) + _ATTRIBUTED_KEYS
1985
2031
 
1986
2032
  def _apply(self, label: Any, props: Dict[str, Any], initial: bool) -> None:
1987
2033
  merged_state = _state_of(label).get("props") or props
1988
2034
  has_spans = bool(merged_state.get("spans"))
1989
- if "text" in props and not has_spans:
1990
- label.setText_(str(props["text"]) if props["text"] is not None else "")
2035
+ text_changed = "text" in props or "text_transform" in props
2036
+ if text_changed and not has_spans:
2037
+ label.setText_(transform_text(merged_state.get("text"), merged_state.get("text_transform")))
1991
2038
  # Font requires combining size + weight + family + italic + bold.
1992
2039
  font_keys_present = any(k in props for k in ("font_size", "font_weight", "font_family", "italic", "bold"))
1993
2040
  if font_keys_present:
@@ -2018,8 +2065,13 @@ class TextHandler(IOSViewHandler):
2018
2065
  self._apply_spans(label, merged_state)
2019
2066
  elif "spans" in props:
2020
2067
  # Rich -> plain transition: restore the plain string.
2021
- label.setText_(str(merged_state.get("text") or ""))
2022
- elif "letter_spacing" in props or "line_height" in props or "text_decoration" in props:
2068
+ label.setText_(transform_text(merged_state.get("text"), merged_state.get("text_transform")))
2069
+ elif any(k in props for k in self._ATTRIBUTED_KEYS) or (
2070
+ # ``setText_`` drops attributes, so a text change re-renders
2071
+ # the attributed string when any attributed key is active.
2072
+ text_changed
2073
+ and any(merged_state.get(k) is not None for k in self._ATTRIBUTED_KEYS)
2074
+ ):
2023
2075
  self._apply_attributed(label, merged_state)
2024
2076
  _apply_view_border(label, props)
2025
2077
  _apply_shadow(label, props)
@@ -2041,6 +2093,7 @@ class TextHandler(IOSViewHandler):
2041
2093
  """
2042
2094
  try:
2043
2095
  spans = [s for s in (merged.get("spans") or []) if isinstance(s, dict)]
2096
+ spans = transform_spans(spans, merged.get("text_transform"))
2044
2097
  full_text = "".join(str(s.get("text", "")) for s in spans)
2045
2098
  NSMutableAttributedString = ObjCClass("NSMutableAttributedString")
2046
2099
  NSMutableParagraphStyle = ObjCClass("NSMutableParagraphStyle")
@@ -2062,6 +2115,9 @@ class TextHandler(IOSViewHandler):
2062
2115
  attr.addAttribute_value_range_("NSUnderline", 1, full_range)
2063
2116
  elif merged.get("text_decoration") == "line_through":
2064
2117
  attr.addAttribute_value_range_("NSStrikethrough", 1, full_range)
2118
+ shadow = _text_shadow(merged)
2119
+ if shadow is not None:
2120
+ attr.addAttribute_value_range_("NSShadow", shadow, full_range)
2065
2121
 
2066
2122
  pos = 0
2067
2123
  for span in spans:
@@ -2095,7 +2151,7 @@ class TextHandler(IOSViewHandler):
2095
2151
  label.setAttributedText_(attr)
2096
2152
  except Exception:
2097
2153
  try:
2098
- label.setText_(str(merged.get("text") or ""))
2154
+ label.setText_(transform_text(merged.get("text"), merged.get("text_transform")))
2099
2155
  except Exception:
2100
2156
  diagnostics.swallowed("ios.TextHandler._apply_spans")
2101
2157
 
@@ -2103,8 +2159,9 @@ class TextHandler(IOSViewHandler):
2103
2159
  """Re-render the label's text as an NSAttributedString.
2104
2160
 
2105
2161
  Needed for ``letter_spacing`` (NSKernAttributeName),
2106
- ``line_height`` (paragraph style), and ``text_decoration``
2107
- (underline/strikethrough). Plain text setters do not support
2162
+ ``line_height`` (paragraph style), ``text_decoration``
2163
+ (underline/strikethrough), and the ``text_shadow_*`` keys
2164
+ (NSShadowAttributeName). Plain text setters do not support
2108
2165
  these attributes.
2109
2166
  """
2110
2167
  try:
@@ -2140,6 +2197,9 @@ class TextHandler(IOSViewHandler):
2140
2197
  attr.addAttribute_value_range_("NSUnderline", 1, full_range)
2141
2198
  elif decoration == "line_through":
2142
2199
  attr.addAttribute_value_range_("NSStrikethrough", 1, full_range)
2200
+ shadow = _text_shadow(props)
2201
+ if shadow is not None:
2202
+ attr.addAttribute_value_range_("NSShadow", shadow, full_range)
2143
2203
  label.setAttributedText_(attr)
2144
2204
  except Exception:
2145
2205
  diagnostics.swallowed("ios.TextHandler._apply_attributed")
@@ -0,0 +1,102 @@
1
+ """Navigation: stack, tab, and drawer navigators with a React Navigation-style API.
2
+
3
+ ```python
4
+ import pythonnative as pn
5
+
6
+ Stack = pn.create_stack_navigator()
7
+
8
+ @pn.component
9
+ def HomeScreen():
10
+ nav = pn.use_navigation()
11
+ return pn.Button("Open 42", on_press=lambda: nav.navigate("Detail", id=42))
12
+
13
+ @pn.component
14
+ def DetailScreen():
15
+ route = pn.use_route()
16
+ return pn.Text(f"Item {route.params['id']}")
17
+
18
+ @pn.component
19
+ def App():
20
+ return pn.NavigationContainer(
21
+ Stack.Navigator(
22
+ Stack.Screen("Home", HomeScreen, title="Home"),
23
+ Stack.Screen("Detail", DetailScreen, options=lambda route: {"title": f"Item {route.params['id']}"}),
24
+ )
25
+ )
26
+ ```
27
+
28
+ Public surface (all re-exported from ``pythonnative``):
29
+
30
+ - Factories: [`create_stack_navigator`][pythonnative.create_stack_navigator],
31
+ [`create_tab_navigator`][pythonnative.create_tab_navigator],
32
+ [`create_drawer_navigator`][pythonnative.create_drawer_navigator],
33
+ [`NavigationContainer`][pythonnative.NavigationContainer].
34
+ - Hooks: [`use_navigation`][pythonnative.use_navigation],
35
+ [`use_route`][pythonnative.use_route],
36
+ [`use_is_focused`][pythonnative.use_is_focused],
37
+ [`use_focus_effect`][pythonnative.use_focus_effect].
38
+ - Types: [`Navigation`][pythonnative.Navigation],
39
+ [`Route`][pythonnative.navigation.Route],
40
+ [`NavigationState`][pythonnative.navigation.NavigationState],
41
+ [`ScreenOptions`][pythonnative.ScreenOptions],
42
+ [`LinkingConfig`][pythonnative.LinkingConfig].
43
+ """
44
+
45
+ from .container import ContainerContext, NavigationContainer
46
+ from .handle import (
47
+ DrawerNavigation,
48
+ EventName,
49
+ FocusContext,
50
+ HostNavigator,
51
+ Navigation,
52
+ NavigationContext,
53
+ NavigationEvent,
54
+ NavigatorCore,
55
+ TabNavigation,
56
+ )
57
+ from .hooks import use_focus_effect, use_is_focused, use_navigation, use_route
58
+ from .host import NAV_STATE_ARG, HostContext, HostRoot
59
+ from .linking import LinkingConfig
60
+ from .navigators import (
61
+ DrawerNavigator,
62
+ StackNavigator,
63
+ TabNavigator,
64
+ create_drawer_navigator,
65
+ create_stack_navigator,
66
+ create_tab_navigator,
67
+ )
68
+ from .screen import HeaderSlot, ScreenDef, ScreenOptions
69
+ from .state import NavigationState, Route
70
+
71
+ __all__ = [
72
+ "NAV_STATE_ARG",
73
+ "ContainerContext",
74
+ "DrawerNavigation",
75
+ "DrawerNavigator",
76
+ "EventName",
77
+ "FocusContext",
78
+ "HeaderSlot",
79
+ "HostContext",
80
+ "HostNavigator",
81
+ "HostRoot",
82
+ "LinkingConfig",
83
+ "Navigation",
84
+ "NavigationContainer",
85
+ "NavigationContext",
86
+ "NavigationEvent",
87
+ "NavigationState",
88
+ "NavigatorCore",
89
+ "Route",
90
+ "ScreenDef",
91
+ "ScreenOptions",
92
+ "StackNavigator",
93
+ "TabNavigation",
94
+ "TabNavigator",
95
+ "create_drawer_navigator",
96
+ "create_stack_navigator",
97
+ "create_tab_navigator",
98
+ "use_focus_effect",
99
+ "use_is_focused",
100
+ "use_navigation",
101
+ "use_route",
102
+ ]