pythonnative 0.22.0__py3-none-any.whl → 0.23.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 (53) hide show
  1. pythonnative/__init__.py +16 -2
  2. pythonnative/animated.py +6 -6
  3. pythonnative/appearance.py +124 -0
  4. pythonnative/cli/pn.py +1 -1
  5. pythonnative/components.py +718 -48
  6. pythonnative/events.py +5 -5
  7. pythonnative/gestures.py +3 -3
  8. pythonnative/hooks.py +44 -3
  9. pythonnative/hot_reload.py +4 -4
  10. pythonnative/images.py +196 -0
  11. pythonnative/layout.py +3 -3
  12. pythonnative/mutations.py +4 -13
  13. pythonnative/native_modules/camera.py +1 -1
  14. pythonnative/native_modules/haptics.py +2 -2
  15. pythonnative/native_modules/location.py +1 -1
  16. pythonnative/native_modules/permissions.py +2 -2
  17. pythonnative/native_modules/secure_store.py +1 -1
  18. pythonnative/native_views/__init__.py +1 -7
  19. pythonnative/native_views/android.py +592 -64
  20. pythonnative/native_views/base.py +3 -3
  21. pythonnative/native_views/desktop.py +85 -26
  22. pythonnative/native_views/ios.py +762 -74
  23. pythonnative/navigation.py +4 -4
  24. pythonnative/net.py +3 -3
  25. pythonnative/platform.py +1 -1
  26. pythonnative/platform_metrics.py +5 -5
  27. pythonnative/preview.py +34 -3
  28. pythonnative/project/builder.py +1 -1
  29. pythonnative/project/config.py +4 -12
  30. pythonnative/project/doctor.py +2 -2
  31. pythonnative/project/icons.py +1 -1
  32. pythonnative/project/ios.py +1 -1
  33. pythonnative/project/permissions.py +2 -2
  34. pythonnative/project/runtime_assets.py +3 -3
  35. pythonnative/reconciler.py +9 -9
  36. pythonnative/runtime.py +8 -8
  37. pythonnative/screen.py +90 -5
  38. pythonnative/sdk/_components.py +1 -1
  39. pythonnative/storage.py +3 -3
  40. pythonnative/style.py +66 -7
  41. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
  42. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
  43. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
  44. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
  45. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +2 -2
  46. pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +1 -1
  47. pythonnative/virtual_rows.py +137 -0
  48. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/METADATA +13 -13
  49. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/RECORD +53 -47
  50. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
  51. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
  52. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
  53. {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/top_level.txt +0 -0
@@ -8,7 +8,7 @@ and frame application. Handlers are registered with the
8
8
 
9
9
  **Batched protocol**: the registry applies the reconciler's mutation
10
10
  ops; handlers receive callable-free props. User callbacks never reach
11
- this module every interaction (clicks, text changes, scrolls,
11
+ this module; every interaction (clicks, text changes, scrolls,
12
12
  gestures) is forwarded through
13
13
  [`dispatch_event`][pythonnative.events.dispatch_event] keyed by the
14
14
  view's reconciler-assigned tag.
@@ -45,7 +45,26 @@ from ..gestures import make_arbiter
45
45
  from ..utils import get_android_context
46
46
  from .base import ViewHandler, _safe_max, parse_color_int
47
47
 
48
- _DRAWABLE_STYLE_KEYS = ("background_color", "border_radius", "border_width", "border_color")
48
+ _SIDE_BORDER_WIDTH_KEYS = (
49
+ "border_left_width",
50
+ "border_top_width",
51
+ "border_right_width",
52
+ "border_bottom_width",
53
+ )
54
+ _SIDE_BORDER_COLOR_KEYS = (
55
+ "border_left_color",
56
+ "border_top_color",
57
+ "border_right_color",
58
+ "border_bottom_color",
59
+ )
60
+ _DRAWABLE_STYLE_KEYS = (
61
+ "background_color",
62
+ "border_radius",
63
+ "border_width",
64
+ "border_color",
65
+ *_SIDE_BORDER_WIDTH_KEYS,
66
+ *_SIDE_BORDER_COLOR_KEYS,
67
+ )
49
68
 
50
69
 
51
70
  # ======================================================================
@@ -57,6 +76,35 @@ def _ctx() -> Any:
57
76
  return get_android_context()
58
77
 
59
78
 
79
+ def _pn_runtime_class(class_name: str) -> Any:
80
+ """Resolve a PythonNative Android helper class for the running app.
81
+
82
+ The Android template's helper classes (e.g.
83
+ ``PNAccessibilityDelegate``, ``PNBorderDrawable``) live in the
84
+ app's own package, which the ``pn`` CLI relocates to the configured
85
+ ``application_id`` at build time. Deriving the package from the
86
+ runtime ``Context`` (rather than hardcoding the template package)
87
+ keeps these lookups correct for any app id.
88
+
89
+ Args:
90
+ class_name: The class name within the app package, e.g.
91
+ ``"PNBorderDrawable"``.
92
+
93
+ Returns:
94
+ The resolved Java class.
95
+ """
96
+ package = _ctx().getPackageName()
97
+ return jclass(f"{package}.{class_name}")
98
+
99
+
100
+ def _signed_color(value: int) -> int:
101
+ """Clamp a Python color int into Java's signed 32-bit range."""
102
+ value &= 0xFFFFFFFF
103
+ if value >= 0x80000000:
104
+ value -= 0x100000000
105
+ return value
106
+
107
+
60
108
  def _density() -> float:
61
109
  return float(_ctx().getResources().getDisplayMetrics().density)
62
110
 
@@ -118,6 +166,52 @@ def _has_event(view: Any, name: str) -> bool:
118
166
  return name in event_names(merged)
119
167
 
120
168
 
169
+ def _apply_side_border(view: Any, props: Dict[str, Any]) -> bool:
170
+ """Apply per-side borders via the template's ``PNBorderDrawable``.
171
+
172
+ Activated when any ``border_<side>_width`` key is present. In that
173
+ mode the drawable owns all four edges: each side's width falls
174
+ back to the uniform ``border_width`` (else 0) and its color to
175
+ ``border_color`` (else black), and the background fill and corner
176
+ radius are baked into the same drawable.
177
+
178
+ Returns:
179
+ ``True`` if the drawable was applied; ``False`` when no
180
+ per-side key is present or the helper class is unavailable
181
+ (older generated projects), in which case the caller falls
182
+ back to the uniform ``GradientDrawable`` path.
183
+ """
184
+ if not any(props.get(k) is not None for k in _SIDE_BORDER_WIDTH_KEYS):
185
+ return False
186
+ try:
187
+ PNBorderDrawable = _pn_runtime_class("PNBorderDrawable")
188
+ except Exception:
189
+ return False
190
+ try:
191
+ base_width = float(props.get("border_width") or 0.0)
192
+ base_color = props.get("border_color") or "#000000"
193
+ widths = [
194
+ float(_dp(float(props[k]))) if props.get(k) is not None else float(_dp(base_width))
195
+ for k in _SIDE_BORDER_WIDTH_KEYS
196
+ ]
197
+ colors = [
198
+ _signed_color(parse_color_int(props[k] if props.get(k) is not None else base_color))
199
+ for k in _SIDE_BORDER_COLOR_KEYS
200
+ ]
201
+ has_bg = props.get("background_color") is not None
202
+ bg = _signed_color(parse_color_int(props["background_color"])) if has_bg else 0
203
+ radius = float(_dp(float(props.get("border_radius") or 0.0)))
204
+ drawable = PNBorderDrawable(has_bg, bg, radius, widths, colors)
205
+ view.setBackground(drawable)
206
+ try:
207
+ view.invalidate()
208
+ except Exception:
209
+ pass
210
+ return True
211
+ except Exception:
212
+ return False
213
+
214
+
121
215
  def _apply_border(view: Any, props: Dict[str, Any]) -> None:
122
216
  """Apply border_radius / border_width / border_color via a GradientDrawable.
123
217
 
@@ -126,7 +220,12 @@ def _apply_border(view: Any, props: Dict[str, Any]) -> None:
126
220
  background to a ``GradientDrawable`` (the "shape" XML primitive)
127
221
  that renders the corner radius and stroke. We preserve any
128
222
  existing ``background_color`` by re-baking it into the drawable.
223
+ Per-side borders route through
224
+ [`_apply_side_border`][pythonnative.native_views.android._apply_side_border]
225
+ instead.
129
226
  """
227
+ if _apply_side_border(view, props):
228
+ return
130
229
  has_border = any(k in props for k in ("border_radius", "border_width", "border_color"))
131
230
  has_bg = "background_color" in props and props["background_color"] is not None
132
231
  if not has_border and not has_bg:
@@ -170,16 +269,46 @@ def _apply_border(view: Any, props: Dict[str, Any]) -> None:
170
269
 
171
270
 
172
271
  def _apply_shadow(view: Any, props: Dict[str, Any]) -> None:
173
- """Apply elevation as a Material-style shadow approximation."""
272
+ """Apply shadow props via elevation plus tinted outline shadows.
273
+
274
+ Android renders view shadows from ``elevation``; iOS-style
275
+ ``shadow_radius`` maps onto it so cross-platform styles work.
276
+ On API 28+ the ambient/spot shadow colors are tinted with
277
+ ``shadow_color`` (with ``shadow_opacity`` baked into the alpha
278
+ channel), which is as close to iOS's layer shadows as the
279
+ platform allows. ``shadow_offset`` has no Android equivalent and
280
+ is ignored.
281
+ """
174
282
  elevation = props.get("elevation")
175
283
  if elevation is None and "shadow_radius" in props:
176
284
  elevation = props.get("shadow_radius")
285
+ if elevation is None and (props.get("shadow_color") is not None or props.get("shadow_opacity") is not None):
286
+ # Shadow requested without an explicit size: use a Material
287
+ # card-like default so the shadow is actually visible.
288
+ elevation = 4.0
177
289
  if elevation is None:
178
290
  return
179
291
  try:
180
292
  view.setElevation(float(_dp(float(elevation))))
181
293
  except Exception:
182
294
  pass
295
+ color = props.get("shadow_color")
296
+ if color is None:
297
+ return
298
+ try:
299
+ Build = jclass("android.os.Build")
300
+ if int(Build.VERSION.SDK_INT) < 28:
301
+ return
302
+ argb = parse_color_int(color) & 0xFFFFFFFF
303
+ opacity = props.get("shadow_opacity")
304
+ if opacity is not None:
305
+ alpha = int(max(0.0, min(1.0, float(opacity))) * 255)
306
+ argb = (argb & 0x00FFFFFF) | (alpha << 24)
307
+ signed = _signed_color(argb)
308
+ view.setOutlineAmbientShadowColor(signed)
309
+ view.setOutlineSpotShadowColor(signed)
310
+ except Exception:
311
+ pass
183
312
 
184
313
 
185
314
  def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
@@ -228,7 +357,7 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
228
357
 
229
358
 
230
359
  def _apply_accessibility(view: Any, props: Dict[str, Any]) -> None:
231
- """Apply accessibility_label / hint / accessible to a view."""
360
+ """Apply accessibility props (label / accessible / state / live region / test_id)."""
232
361
  if "accessible" in props:
233
362
  try:
234
363
  View = jclass("android.view.View")
@@ -243,10 +372,48 @@ def _apply_accessibility(view: Any, props: Dict[str, Any]) -> None:
243
372
  view.setContentDescription(str(label) if label is not None else None)
244
373
  except Exception:
245
374
  pass
246
- # Android's accessibility role / hint mostly comes through
247
- # AccessibilityNodeInfo — full plumbing is non-trivial. We keep
248
- # the API surface symmetrical with iOS but apply only the label
249
- # for now.
375
+ if "accessibility_live_region" in props:
376
+ try:
377
+ mode = {"none": 0, "polite": 1, "assertive": 2}.get(
378
+ str(props["accessibility_live_region"] or "none").lower(), 0
379
+ )
380
+ view.setAccessibilityLiveRegion(mode)
381
+ except Exception:
382
+ pass
383
+
384
+ state = props.get("accessibility_state")
385
+ if isinstance(state, dict) and "selected" in state:
386
+ try:
387
+ view.setSelected(bool(state["selected"]))
388
+ except Exception:
389
+ pass
390
+ test_id = props.get("test_id")
391
+ if test_id is None and not isinstance(state, dict):
392
+ return
393
+ # test_id (exposed as `resource-id` to UI Automator tools) and the
394
+ # remaining state flags need an AccessibilityDelegate, which Python
395
+ # can't subclass directly (Chaquopy proxies interfaces only); the
396
+ # template ships PNAccessibilityDelegate for this. Older generated
397
+ # projects without the class simply skip these props.
398
+ try:
399
+ vs = _state_of(view)
400
+ delegate = vs.get("a11y_delegate")
401
+ if delegate is None:
402
+ PNAccessibilityDelegate = _pn_runtime_class("PNAccessibilityDelegate")
403
+ delegate = PNAccessibilityDelegate()
404
+ view.setAccessibilityDelegate(delegate)
405
+ vs["a11y_delegate"] = delegate
406
+ if test_id is not None:
407
+ delegate.setTestId(str(test_id))
408
+ view.setTag(str(test_id))
409
+ if isinstance(state, dict):
410
+ delegate.setStateDisabled(state.get("disabled"))
411
+ delegate.setStateSelected(state.get("selected"))
412
+ delegate.setStateChecked(state.get("checked"))
413
+ delegate.setStateBusy(state.get("busy"))
414
+ delegate.setStateExpanded(state.get("expanded"))
415
+ except Exception:
416
+ pass
250
417
 
251
418
 
252
419
  def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
@@ -689,7 +856,7 @@ class AndroidViewHandler(ViewHandler):
689
856
 
690
857
 
691
858
  class FlexContainerHandler(AndroidViewHandler):
692
- """Container for flex layout a bare `FrameLayout`.
859
+ """Container for flex layout, a bare `FrameLayout`.
693
860
 
694
861
  All flex semantics (direction, alignment, distribution, padding)
695
862
  are computed by the layout engine and applied via
@@ -758,13 +925,93 @@ def _typeface_for(weight: Any, italic: bool) -> Any:
758
925
  return style
759
926
 
760
927
 
928
+ def _build_spannable(spans: Any) -> Any:
929
+ """Build a ``SpannableStringBuilder`` from a rich-text span list.
930
+
931
+ Each span dict carries ``text`` plus optional per-span overrides
932
+ (``color``, ``background_color``, ``font_size``, ``font_family``,
933
+ ``bold`` / ``italic`` / ``font_weight``, ``text_decoration``).
934
+ Spans without overrides inherit the TextView's base styling.
935
+ """
936
+ SpannableStringBuilder = jclass("android.text.SpannableStringBuilder")
937
+ Typeface = jclass("android.graphics.Typeface")
938
+ FLAG = 33 # Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
939
+
940
+ builder = SpannableStringBuilder()
941
+ for span in spans:
942
+ if not isinstance(span, dict):
943
+ continue
944
+ text = str(span.get("text", ""))
945
+ if not text:
946
+ continue
947
+ start = builder.length()
948
+ builder.append(text)
949
+ end = builder.length()
950
+
951
+ def _set(obj: Any) -> None:
952
+ builder.setSpan(obj, start, end, FLAG)
953
+
954
+ try:
955
+ if span.get("color") is not None:
956
+ ForegroundColorSpan = jclass("android.text.style.ForegroundColorSpan")
957
+ _set(ForegroundColorSpan(parse_color_int(span["color"])))
958
+ if span.get("background_color") is not None:
959
+ BackgroundColorSpan = jclass("android.text.style.BackgroundColorSpan")
960
+ _set(BackgroundColorSpan(parse_color_int(span["background_color"])))
961
+ if span.get("font_size") is not None:
962
+ AbsoluteSizeSpan = jclass("android.text.style.AbsoluteSizeSpan")
963
+ _set(AbsoluteSizeSpan(int(float(span["font_size"])), True)) # dip units
964
+ bold = bool(span.get("bold"))
965
+ weight = span.get("font_weight")
966
+ if not bold and weight is not None:
967
+ try:
968
+ bold = str(weight) in ("bold", "600", "700", "800", "900")
969
+ except Exception:
970
+ bold = False
971
+ italic = bool(span.get("italic"))
972
+ if bold or italic:
973
+ StyleSpan = jclass("android.text.style.StyleSpan")
974
+ if bold and italic:
975
+ _set(StyleSpan(Typeface.BOLD_ITALIC))
976
+ elif bold:
977
+ _set(StyleSpan(Typeface.BOLD))
978
+ else:
979
+ _set(StyleSpan(Typeface.ITALIC))
980
+ if span.get("font_family"):
981
+ TypefaceSpan = jclass("android.text.style.TypefaceSpan")
982
+ _set(TypefaceSpan(str(span["font_family"])))
983
+ decoration = span.get("text_decoration")
984
+ if decoration == "underline":
985
+ UnderlineSpan = jclass("android.text.style.UnderlineSpan")
986
+ _set(UnderlineSpan())
987
+ elif decoration == "line_through":
988
+ StrikethroughSpan = jclass("android.text.style.StrikethroughSpan")
989
+ _set(StrikethroughSpan())
990
+ except Exception:
991
+ pass
992
+ return builder
993
+
994
+
761
995
  class TextHandler(AndroidViewHandler):
762
996
  def _build(self, props: Dict[str, Any]) -> Any:
763
997
  return jclass("android.widget.TextView")(_ctx())
764
998
 
765
999
  def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
766
- if "text" in props:
767
- tv.setText(str(props["text"]) if props["text"] is not None else "")
1000
+ if "spans" in props or "text" in props:
1001
+ # ``update()`` merges changed props into the view state
1002
+ # before calling ``_apply``, so the merged dict always has
1003
+ # the latest text/spans pair (covering rich -> plain
1004
+ # transitions where only ``spans`` changed).
1005
+ merged = _state_of(tv).get("props") or props
1006
+ spans = merged.get("spans")
1007
+ if spans:
1008
+ try:
1009
+ tv.setText(_build_spannable(spans))
1010
+ except Exception:
1011
+ tv.setText(str(merged.get("text") or ""))
1012
+ else:
1013
+ text = merged.get("text")
1014
+ tv.setText(str(text) if text is not None else "")
768
1015
  if "font_size" in props and props["font_size"] is not None:
769
1016
  tv.setTextSize(float(props["font_size"]))
770
1017
  if "color" in props and props["color"] is not None:
@@ -844,7 +1091,7 @@ class ButtonHandler(AndroidViewHandler):
844
1091
 
845
1092
 
846
1093
  class ScrollViewHandler(AndroidViewHandler):
847
- """Scroll container wraps a single child whose height is unbounded.
1094
+ """Scroll container: wraps a single child whose height is unbounded.
848
1095
 
849
1096
  Uses ``androidx.core.widget.NestedScrollView`` (vertical) or
850
1097
  ``android.widget.HorizontalScrollView`` so nested scroll views
@@ -871,7 +1118,7 @@ class ScrollViewHandler(AndroidViewHandler):
871
1118
  sv = jclass("android.widget.ScrollView")(_ctx())
872
1119
 
873
1120
  # Vertical scroll views are *always* wrapped in a (disabled)
874
- # SwipeRefreshLayout. Wrapping later is impossible the
1121
+ # SwipeRefreshLayout. Wrapping later is impossible; the
875
1122
  # reconciler may reuse this view for a screen that adds a
876
1123
  # ``refresh_control`` prop afterwards (e.g. navigation swapping
877
1124
  # screens of the same shape), and re-parenting a mounted view
@@ -1214,7 +1461,7 @@ class TextInputHandler(AndroidViewHandler):
1214
1461
  # Map the cross-platform ``return_key_type`` to Android's
1215
1462
  # ``EditorInfo.IME_ACTION_*`` so the soft keyboard renders the
1216
1463
  # right action key. iOS has a richer set (Google / Yahoo /
1217
- # Join / Route) with no direct AOSP equivalents fall back
1464
+ # Join / Route) with no direct AOSP equivalents; fall back
1218
1465
  # to ``IME_ACTION_DONE`` for those.
1219
1466
  try:
1220
1467
  EditorInfo = jclass("android.view.inputmethod.EditorInfo")
@@ -1244,7 +1491,7 @@ class TextInputHandler(AndroidViewHandler):
1244
1491
  Single-line inputs always dismiss the keyboard on the action
1245
1492
  key (matching React Native's Android default) and fire
1246
1493
  ``on_submit`` first. Multi-line inputs only consume the action
1247
- when an ``on_submit`` handler exists otherwise Enter inserts
1494
+ when an ``on_submit`` handler exists; otherwise Enter inserts
1248
1495
  a newline.
1249
1496
  """
1250
1497
  try:
@@ -1355,8 +1602,13 @@ class ImageHandler(AndroidViewHandler):
1355
1602
  iv.setImageTintList(ColorStateList.valueOf(parse_color_int(props["tint_color"])))
1356
1603
  except Exception:
1357
1604
  pass
1605
+ if "placeholder_color" in props and props["placeholder_color"] is not None:
1606
+ try:
1607
+ iv.setBackgroundColor(parse_color_int(props["placeholder_color"]))
1608
+ except Exception:
1609
+ pass
1358
1610
  if "source" in props and props["source"]:
1359
- self._load_source(iv, props["source"])
1611
+ self._load_source(iv, str(props["source"]))
1360
1612
  if "scale_type" in props and props["scale_type"]:
1361
1613
  ScaleType = jclass("android.widget.ImageView$ScaleType")
1362
1614
  mapping = {
@@ -1372,43 +1624,39 @@ class ImageHandler(AndroidViewHandler):
1372
1624
 
1373
1625
  def _load_source(self, iv: Any, source: str) -> None:
1374
1626
  try:
1375
- if source.startswith(("http://", "https://")):
1376
- Thread = jclass("java.lang.Thread")
1377
- Runnable = jclass("java.lang.Runnable")
1378
- URL = jclass("java.net.URL")
1379
- BitmapFactory = jclass("android.graphics.BitmapFactory")
1380
- Handler = jclass("android.os.Handler")
1381
- Looper = jclass("android.os.Looper")
1382
- handler = Handler(Looper.getMainLooper())
1383
-
1384
- class LoadTask(dynamic_proxy(Runnable)):
1385
- def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None:
1386
- super().__init__()
1387
- self.image_view = image_view
1388
- self.url_str = url_str
1389
- self.main_handler = main_handler
1390
-
1391
- def run(self) -> None:
1392
- try:
1393
- url = URL(self.url_str)
1394
- stream = url.openStream()
1395
- bitmap = BitmapFactory.decodeStream(stream)
1396
- stream.close()
1397
-
1398
- class SetImage(dynamic_proxy(Runnable)):
1399
- def __init__(self, view: Any, bmp: Any) -> None:
1400
- super().__init__()
1401
- self.view = view
1402
- self.bmp = bmp
1403
-
1404
- def run(self) -> None:
1405
- self.view.setImageBitmap(self.bmp)
1627
+ if source.startswith("data:"):
1628
+ self._load_data_uri(iv, source)
1629
+ elif source.startswith(("http://", "https://")):
1630
+ from ..images import fetch
1631
+
1632
+ state = _state_of(iv)
1633
+ state["pending_uri"] = source
1634
+
1635
+ def _on_ready(path: str) -> None:
1636
+ if _state_of(iv).get("pending_uri") != source:
1637
+ return # a newer source superseded this load
1638
+ bitmap = self._decode_downsampled(iv, path)
1639
+ if bitmap is None:
1640
+ _fire(iv, "on_error", "decode failed")
1641
+ return
1642
+ try:
1643
+ iv.setImageBitmap(bitmap)
1644
+ _fire(iv, "on_load")
1645
+ except Exception:
1646
+ pass
1406
1647
 
1407
- self.main_handler.post(SetImage(self.image_view, bitmap))
1408
- except Exception:
1409
- pass
1648
+ def _on_error(message: str) -> None:
1649
+ if _state_of(iv).get("pending_uri") == source:
1650
+ _fire(iv, "on_error", message)
1410
1651
 
1411
- Thread(LoadTask(iv, source, handler)).start()
1652
+ fetch(source, _on_ready, _on_error)
1653
+ elif source.startswith("/"):
1654
+ bitmap = self._decode_downsampled(iv, source)
1655
+ if bitmap is not None:
1656
+ iv.setImageBitmap(bitmap)
1657
+ _fire(iv, "on_load")
1658
+ else:
1659
+ _fire(iv, "on_error", "decode failed")
1412
1660
  else:
1413
1661
  ctx = _ctx()
1414
1662
  res = ctx.getResources()
@@ -1417,9 +1665,76 @@ class ImageHandler(AndroidViewHandler):
1417
1665
  res_id = res.getIdentifier(res_name, "drawable", pkg)
1418
1666
  if res_id != 0:
1419
1667
  iv.setImageResource(res_id)
1668
+ _fire(iv, "on_load")
1669
+ else:
1670
+ _fire(iv, "on_error", f"drawable {res_name!r} not found")
1420
1671
  except Exception:
1421
1672
  pass
1422
1673
 
1674
+ def _load_data_uri(self, iv: Any, source: str) -> None:
1675
+ """Decode an inline ``data:`` URI (base64 payload) into the view."""
1676
+ try:
1677
+ import base64
1678
+
1679
+ payload = source.split(",", 1)[1] if "," in source else ""
1680
+ raw = base64.b64decode(payload)
1681
+ BitmapFactory = jclass("android.graphics.BitmapFactory")
1682
+ bitmap = BitmapFactory.decodeByteArray(raw, 0, len(raw))
1683
+ if bitmap is not None:
1684
+ iv.setImageBitmap(bitmap)
1685
+ _fire(iv, "on_load")
1686
+ else:
1687
+ _fire(iv, "on_error", "data URI decode failed")
1688
+ except Exception:
1689
+ _fire(iv, "on_error", "data URI decode failed")
1690
+
1691
+ def _decode_downsampled(self, iv: Any, path: str) -> Any:
1692
+ """Decode ``path`` with ``inSampleSize`` sized to the target view.
1693
+
1694
+ A 4000px photo displayed in a 200dp thumbnail should not hold a
1695
+ 48 MB bitmap; ``inSampleSize`` decodes at the nearest power-of-2
1696
+ fraction that still covers the view (falling back to the screen
1697
+ width when the view hasn't been laid out yet).
1698
+ """
1699
+ try:
1700
+ BitmapFactory = jclass("android.graphics.BitmapFactory")
1701
+ Options = jclass("android.graphics.BitmapFactory$Options")
1702
+
1703
+ bounds = Options()
1704
+ bounds.inJustDecodeBounds = True
1705
+ BitmapFactory.decodeFile(path, bounds)
1706
+ src_w = int(bounds.outWidth)
1707
+ src_h = int(bounds.outHeight)
1708
+ if src_w <= 0 or src_h <= 0:
1709
+ return None
1710
+
1711
+ target_w = 0
1712
+ target_h = 0
1713
+ try:
1714
+ target_w = int(iv.getWidth())
1715
+ target_h = int(iv.getHeight())
1716
+ except Exception:
1717
+ pass
1718
+ if target_w <= 0:
1719
+ try:
1720
+ metrics = _ctx().getResources().getDisplayMetrics()
1721
+ target_w = int(metrics.widthPixels)
1722
+ target_h = int(metrics.heightPixels)
1723
+ except Exception:
1724
+ target_w, target_h = src_w, src_h
1725
+ if target_h <= 0:
1726
+ target_h = target_w
1727
+
1728
+ sample = 1
1729
+ while (src_w // (sample * 2)) >= target_w and (src_h // (sample * 2)) >= target_h:
1730
+ sample *= 2
1731
+
1732
+ opts = Options()
1733
+ opts.inSampleSize = sample
1734
+ return BitmapFactory.decodeFile(path, opts)
1735
+ except Exception:
1736
+ return None
1737
+
1423
1738
 
1424
1739
  class SwitchHandler(AndroidViewHandler):
1425
1740
  def _build(self, props: Dict[str, Any]) -> Any:
@@ -1616,7 +1931,7 @@ class WebViewHandler(AndroidViewHandler):
1616
1931
  class SpacerHandler(AndroidViewHandler):
1617
1932
  """Empty layout placeholder used as a flexible gap.
1618
1933
 
1619
- All sizing semantics live in the layout engine ``Spacer``
1934
+ All sizing semantics live in the layout engine; ``Spacer``
1620
1935
  behaves identically to a `View` with the same style props (e.g.,
1621
1936
  ``flex: 1`` for an expanding spacer, ``size`` for a fixed gap).
1622
1937
  """
@@ -1638,7 +1953,7 @@ class SafeAreaViewHandler(FlexContainerHandler):
1638
1953
 
1639
1954
 
1640
1955
  # ======================================================================
1641
- # Modal actually presents a Dialog with the children inside
1956
+ # Modal: actually presents a Dialog with the children inside
1642
1957
  # ======================================================================
1643
1958
 
1644
1959
 
@@ -1959,7 +2274,7 @@ class TabBarHandler(AndroidViewHandler):
1959
2274
 
1960
2275
 
1961
2276
  # ======================================================================
1962
- # Pressable visual feedback + tap callbacks + gestures
2277
+ # Pressable: visual feedback + tap callbacks + gestures
1963
2278
  # ======================================================================
1964
2279
 
1965
2280
 
@@ -2073,7 +2388,7 @@ class PressableHandler(FlexContainerHandler):
2073
2388
 
2074
2389
 
2075
2390
  # ======================================================================
2076
- # StatusBar global side effect
2391
+ # StatusBar: global side effect
2077
2392
  # ======================================================================
2078
2393
 
2079
2394
 
@@ -2131,7 +2446,7 @@ def _present_alert(
2131
2446
  ) -> None:
2132
2447
  """Present an AlertDialog or BottomSheet (``style='action_sheet'``).
2133
2448
 
2134
- Safe to call from any thread the AlertDialog work is automatically
2449
+ Safe to call from any thread; the AlertDialog work is automatically
2135
2450
  marshalled to the main looper via
2136
2451
  [`pythonnative.runtime.call_on_main_thread`][pythonnative.runtime.call_on_main_thread].
2137
2452
  Returns immediately; the dialog appears on the next main-loop tick.
@@ -2227,12 +2542,12 @@ def _present_alert(
2227
2542
 
2228
2543
 
2229
2544
  # ======================================================================
2230
- # Picker native dropdown / select widget
2545
+ # Picker: native dropdown / select widget
2231
2546
  # ======================================================================
2232
2547
 
2233
2548
 
2234
2549
  class PickerHandler(AndroidViewHandler):
2235
- """``Picker`` element handler native ``Spinner`` dropdown."""
2550
+ """``Picker`` element handler, native ``Spinner`` dropdown."""
2236
2551
 
2237
2552
  def _build(self, props: Dict[str, Any]) -> Any:
2238
2553
  sp = jclass("android.widget.Spinner")(_ctx())
@@ -2300,12 +2615,12 @@ class PickerHandler(AndroidViewHandler):
2300
2615
 
2301
2616
 
2302
2617
  # ======================================================================
2303
- # Checkbox native CheckBox with an optional inline label
2618
+ # Checkbox: native CheckBox with an optional inline label
2304
2619
  # ======================================================================
2305
2620
 
2306
2621
 
2307
2622
  class CheckboxHandler(AndroidViewHandler):
2308
- """``Checkbox`` element handler native ``CheckBox`` widget.
2623
+ """``Checkbox`` element handler, native ``CheckBox`` widget.
2309
2624
 
2310
2625
  Programmatic ``value`` updates are wrapped in a per-view
2311
2626
  "suppress" guard so pushing a new state via ``setChecked`` never
@@ -2348,12 +2663,12 @@ class CheckboxHandler(AndroidViewHandler):
2348
2663
 
2349
2664
 
2350
2665
  # ======================================================================
2351
- # SegmentedControl horizontal toggle row (no UISegmentedControl on AOSP)
2666
+ # SegmentedControl: horizontal toggle row (no UISegmentedControl on AOSP)
2352
2667
  # ======================================================================
2353
2668
 
2354
2669
 
2355
2670
  class SegmentedControlHandler(AndroidViewHandler):
2356
- """``SegmentedControl`` element a horizontal row of toggle buttons.
2671
+ """``SegmentedControl`` element, a horizontal row of toggle buttons.
2357
2672
 
2358
2673
  Android has no ``UISegmentedControl`` equivalent, so the control is
2359
2674
  built from a horizontal ``LinearLayout`` holding one ``Button`` per
@@ -2492,12 +2807,12 @@ class SegmentedControlHandler(AndroidViewHandler):
2492
2807
 
2493
2808
 
2494
2809
  # ======================================================================
2495
- # DatePicker trigger button opening native date/time dialogs
2810
+ # DatePicker: trigger button opening native date/time dialogs
2496
2811
  # ======================================================================
2497
2812
 
2498
2813
 
2499
2814
  class DatePickerHandler(AndroidViewHandler):
2500
- """``DatePicker`` element a trigger ``Button`` opening native dialogs.
2815
+ """``DatePicker`` element, a trigger ``Button`` opening native dialogs.
2501
2816
 
2502
2817
  The button text reflects the current ISO ``value`` (or a
2503
2818
  placeholder). Tapping it opens a ``DatePickerDialog`` (``mode``
@@ -2662,6 +2977,217 @@ class DatePickerHandler(AndroidViewHandler):
2662
2977
  _fire(btn, "on_change", iso)
2663
2978
 
2664
2979
 
2980
+ # ======================================================================
2981
+ # VirtualList — RecyclerView-backed native virtualization
2982
+ # ======================================================================
2983
+ #
2984
+ # The heavy lifting lives in the template's ``PNVirtualListView.java``:
2985
+ # Chaquopy cannot subclass abstract Java classes (RecyclerView.Adapter,
2986
+ # RecyclerView.ViewHolder), so the Java side owns the adapter and view
2987
+ # holders and calls back into Python through the small ``Delegate``
2988
+ # interface. Each visible row hosts a nested-reconciler subtree (see
2989
+ # ``pythonnative.virtual_rows``): the delegate's ``mountRow`` renders
2990
+ # the row element and attaches its native root to the recycled cell
2991
+ # container; ``onRowRecycled`` tears the subtree down.
2992
+
2993
+ # Per-list mutable state, keyed by the RecyclerView's Java identity.
2994
+ # The delegate proxy closes over this dict so prop updates (new
2995
+ # ``render_row``, new ``count``) take effect without re-wiring Java.
2996
+ _pn_vlist_info: Dict[int, Dict[str, Any]] = {}
2997
+
2998
+
2999
+ class VirtualListHandler(AndroidViewHandler):
3000
+ """Natively virtualized list backed by ``RecyclerView``.
3001
+
3002
+ Expects props:
3003
+
3004
+ - ``count``: total number of rows.
3005
+ - ``row_height``: uniform row extent in points, or
3006
+ - ``row_heights``: per-row extents (section lists interleave
3007
+ headers and items of different heights).
3008
+ - ``render_row``: ``render_row(index) -> Element`` producing one
3009
+ row's subtree. Called lazily as rows enter the viewport.
3010
+ - ``shows_scroll_indicator``: hide the scroll bar when ``False``.
3011
+
3012
+ Events (dispatched by tag): ``on_row_press`` with the row index,
3013
+ ``on_scroll`` with ``{"x", "y", "extent", "range"}`` in points.
3014
+
3015
+ Commands: ``scroll_to_offset`` / ``scroll_to_index`` /
3016
+ ``scroll_to_end`` / ``get_scroll_offset``.
3017
+ """
3018
+
3019
+ def _build(self, props: Dict[str, Any]) -> Any:
3020
+ from ..virtual_rows import RowHostPool
3021
+
3022
+ PNVirtualListView = _pn_runtime_class("PNVirtualListView")
3023
+ info: Dict[str, Any] = {
3024
+ "count": int(props.get("count", 0) or 0),
3025
+ "row_height": float(props.get("row_height", 44.0) or 44.0),
3026
+ "row_heights": props.get("row_heights"),
3027
+ "render_row": props.get("render_row"),
3028
+ "pool": RowHostPool(),
3029
+ }
3030
+
3031
+ class _Delegate(dynamic_proxy(PNVirtualListView.Delegate)):
3032
+ def getCount(self) -> int:
3033
+ return int(info["count"])
3034
+
3035
+ def getRowHeightDp(self, position: int) -> float:
3036
+ heights = info.get("row_heights")
3037
+ if heights and 0 <= position < len(heights):
3038
+ return float(heights[position])
3039
+ return float(info["row_height"])
3040
+
3041
+ def mountRow(self, position: int, container: Any, width_dp: float, height_dp: float) -> None:
3042
+ render_row = info.get("render_row")
3043
+ if render_row is None:
3044
+ return
3045
+ try:
3046
+ pool = info["pool"]
3047
+ root = pool.bind(
3048
+ _java_id(container),
3049
+ lambda: render_row(int(position)),
3050
+ float(width_dp),
3051
+ float(height_dp),
3052
+ )
3053
+ if root is not None:
3054
+ _insert_view(container, root, 0)
3055
+ # The layout engine frames only the *descendants*
3056
+ # of a subtree root (screen hosts normally size
3057
+ # the root themselves), so the row root would
3058
+ # otherwise keep the 0x0 params ``_insert_view``
3059
+ # assigns and render as nothing. Fill the cell.
3060
+ FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
3061
+ root.setLayoutParams(FrameLP(FrameLP.MATCH_PARENT, FrameLP.MATCH_PARENT))
3062
+ except Exception:
3063
+ import traceback
3064
+
3065
+ traceback.print_exc()
3066
+
3067
+ def onRowPress(self, position: int) -> None:
3068
+ view = info.get("view")
3069
+ if view is not None:
3070
+ _fire(view, "on_row_press", int(position))
3071
+
3072
+ def onRowRecycled(self, container: Any) -> None:
3073
+ try:
3074
+ info["pool"].release(_java_id(container))
3075
+ except Exception:
3076
+ pass
3077
+
3078
+ def onScrolled(self, offset_dp: float, extent_dp: float, range_dp: float) -> None:
3079
+ view = info.get("view")
3080
+ if view is not None:
3081
+ _fire(
3082
+ view,
3083
+ "on_scroll",
3084
+ {
3085
+ "x": 0.0,
3086
+ "y": float(offset_dp),
3087
+ "extent": float(extent_dp),
3088
+ "range": float(range_dp),
3089
+ },
3090
+ )
3091
+
3092
+ delegate = _Delegate()
3093
+ view = PNVirtualListView(_ctx(), delegate)
3094
+ info["view"] = view
3095
+ # Keep the proxy alive: Java holds only a weak-ish reference
3096
+ # through the field, and Chaquopy proxies are collectible once
3097
+ # the Python side drops them.
3098
+ info["delegate"] = delegate
3099
+ _pn_vlist_info[_java_id(view)] = info
3100
+ return view
3101
+
3102
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
3103
+ _apply_common_visual(view, props)
3104
+ info = _pn_vlist_info.get(_java_id(view))
3105
+ if info is None:
3106
+ return
3107
+ data_changed = False
3108
+ if "count" in props:
3109
+ info["count"] = int(props.get("count") or 0)
3110
+ data_changed = True
3111
+ if "row_height" in props and props["row_height"] is not None:
3112
+ info["row_height"] = float(props["row_height"])
3113
+ data_changed = True
3114
+ if "row_heights" in props:
3115
+ info["row_heights"] = props.get("row_heights")
3116
+ data_changed = True
3117
+ if "render_row" in props:
3118
+ info["render_row"] = props.get("render_row")
3119
+ data_changed = True
3120
+ if "shows_scroll_indicator" in props:
3121
+ show = props["shows_scroll_indicator"] is not False
3122
+ try:
3123
+ view.setVerticalScrollBarEnabled(show)
3124
+ except Exception:
3125
+ pass
3126
+ if data_changed and not initial:
3127
+ try:
3128
+ view.notifyDataChanged()
3129
+ except Exception:
3130
+ pass
3131
+
3132
+ def _teardown(self, native_view: Any) -> None:
3133
+ info = _pn_vlist_info.pop(_java_id(native_view), None)
3134
+ if info is not None:
3135
+ info.get("pool").release_all()
3136
+ info["view"] = None
3137
+
3138
+ def measure_intrinsic(
3139
+ self,
3140
+ native_view: Any,
3141
+ max_width: float,
3142
+ max_height: float,
3143
+ ) -> Tuple[float, float]:
3144
+ # Fill the available space, like a ScrollView clamped to its
3145
+ # parent. Inside an unbounded axis (nested in another scroll
3146
+ # view) collapse to 0, matching React Native's behavior for
3147
+ # nested virtualized lists.
3148
+ w = max_width if math.isfinite(max_width) else 0.0
3149
+ h = max_height if math.isfinite(max_height) else 0.0
3150
+ return (max(0.0, w), max(0.0, h))
3151
+
3152
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
3153
+ density = _density() or 1.0
3154
+ if name == "scroll_to_offset":
3155
+ try:
3156
+ native_view.scrollToOffsetDp(
3157
+ float(args.get("y", 0.0) or 0.0),
3158
+ args.get("animated", True) is not False,
3159
+ )
3160
+ except Exception:
3161
+ pass
3162
+ return None
3163
+ if name == "scroll_to_index":
3164
+ try:
3165
+ native_view.scrollToIndex(
3166
+ int(args.get("index", 0) or 0),
3167
+ args.get("animated", True) is not False,
3168
+ )
3169
+ except Exception:
3170
+ pass
3171
+ return None
3172
+ if name == "scroll_to_end":
3173
+ info = _pn_vlist_info.get(_java_id(native_view))
3174
+ count = int(info.get("count", 0)) if info else 0
3175
+ try:
3176
+ native_view.scrollToIndex(max(0, count - 1), args.get("animated", True) is not False)
3177
+ except Exception:
3178
+ pass
3179
+ return None
3180
+ if name == "get_scroll_offset":
3181
+ try:
3182
+ return {
3183
+ "x": 0.0,
3184
+ "y": float(native_view.computeVerticalScrollOffset()) / density,
3185
+ }
3186
+ except Exception:
3187
+ return {"x": 0.0, "y": 0.0}
3188
+ return None
3189
+
3190
+
2665
3191
  # ======================================================================
2666
3192
  # Registration
2667
3193
  # ======================================================================
@@ -2694,6 +3220,7 @@ def register_handlers(registry: Any) -> None:
2694
3220
  registry.register("Checkbox", CheckboxHandler())
2695
3221
  registry.register("SegmentedControl", SegmentedControlHandler())
2696
3222
  registry.register("DatePicker", DatePickerHandler())
3223
+ registry.register("VirtualList", VirtualListHandler())
2697
3224
 
2698
3225
 
2699
3226
  __all__ = [
@@ -2720,5 +3247,6 @@ __all__ = [
2720
3247
  "CheckboxHandler",
2721
3248
  "SegmentedControlHandler",
2722
3249
  "DatePickerHandler",
3250
+ "VirtualListHandler",
2723
3251
  "register_handlers",
2724
3252
  ]