pythonnative 0.22.1__py3-none-any.whl → 0.24.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. pythonnative/__init__.py +43 -50
  2. pythonnative/animated.py +1 -1
  3. pythonnative/appearance.py +124 -0
  4. pythonnative/cli/pn.py +3 -3
  5. pythonnative/components.py +873 -466
  6. pythonnative/diagnostics.py +214 -0
  7. pythonnative/element.py +5 -2
  8. pythonnative/events.py +13 -8
  9. pythonnative/hooks.py +454 -49
  10. pythonnative/hot_reload.py +9 -1
  11. pythonnative/images.py +196 -0
  12. pythonnative/mutations.py +3 -12
  13. pythonnative/native_views/__init__.py +1 -7
  14. pythonnative/native_views/android.py +651 -46
  15. pythonnative/native_views/desktop.py +116 -20
  16. pythonnative/native_views/ios.py +974 -52
  17. pythonnative/navigation.py +41 -17
  18. pythonnative/preview.py +74 -7
  19. pythonnative/project/config.py +1 -9
  20. pythonnative/reconciler.py +863 -441
  21. pythonnative/screen.py +409 -27
  22. pythonnative/storage.py +3 -3
  23. pythonnative/style.py +148 -6
  24. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
  25. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
  26. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
  27. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
  28. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
  29. pythonnative/virtual_rows.py +137 -0
  30. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/METADATA +5 -4
  31. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/RECORD +35 -28
  32. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/WHEEL +1 -1
  33. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/entry_points.txt +0 -0
  34. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/licenses/LICENSE +0 -0
  35. {pythonnative-0.22.1.dist-info → pythonnative-0.24.0.dist-info}/top_level.txt +0 -0
@@ -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:
@@ -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:
@@ -826,7 +1073,7 @@ class ButtonHandler(AndroidViewHandler):
826
1073
 
827
1074
  class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
828
1075
  def onClick(self, view: Any) -> None:
829
- _fire(view, "on_click")
1076
+ _fire(view, "on_press")
830
1077
 
831
1078
  btn.setOnClickListener(ClickProxy())
832
1079
  return btn
@@ -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:
@@ -1779,6 +2094,81 @@ class ModalHandler(AndroidViewHandler):
1779
2094
  pass
1780
2095
 
1781
2096
 
2097
+ # ======================================================================
2098
+ # Portal: floats its children over the screen in a top-level overlay
2099
+ # ======================================================================
2100
+
2101
+
2102
+ class PortalHandler(AndroidViewHandler):
2103
+ """Overlay container hosting [`Portal`][pythonnative.Portal] children.
2104
+
2105
+ The portal's native view is a full-size, non-clickable
2106
+ ``FrameLayout`` added directly to the screen's fragment container
2107
+ (the same parent as the screen root, so portal coordinates equal
2108
+ viewport coordinates). It is appended last, which stacks it above
2109
+ the main content; because the overlay itself never consumes
2110
+ touches, taps on empty areas fall through to the screen below
2111
+ while the portal's own children stay interactive.
2112
+
2113
+ The overlay attaches lazily on the first child insert and re-homes
2114
+ itself if the fragment container was rebuilt (e.g. after popping
2115
+ back to a previously mounted screen).
2116
+ """
2117
+
2118
+ def _build(self, props: Dict[str, Any]) -> Any:
2119
+ overlay = jclass("android.widget.FrameLayout")(_ctx())
2120
+ overlay.setClickable(False)
2121
+ return overlay
2122
+
2123
+ def _apply(self, overlay: Any, props: Dict[str, Any], initial: bool) -> None:
2124
+ _apply_common_visual(overlay, props)
2125
+ if not initial:
2126
+ self._ensure_attached(overlay)
2127
+
2128
+ def _ensure_attached(self, overlay: Any) -> None:
2129
+ try:
2130
+ from ..utils import get_android_fragment_container
2131
+
2132
+ container = get_android_fragment_container()
2133
+ except Exception:
2134
+ return
2135
+ try:
2136
+ parent = overlay.getParent()
2137
+ if parent is not None and _java_id(parent) == _java_id(container):
2138
+ overlay.bringToFront()
2139
+ return
2140
+ if parent is not None:
2141
+ parent.removeView(overlay)
2142
+ LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
2143
+ lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
2144
+ container.addView(overlay, lp)
2145
+ except Exception:
2146
+ pass
2147
+
2148
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
2149
+ self._ensure_attached(parent)
2150
+ _insert_view(parent, child, index)
2151
+
2152
+ def remove_child(self, parent: Any, child: Any) -> None:
2153
+ try:
2154
+ parent.removeView(child)
2155
+ except Exception:
2156
+ pass
2157
+
2158
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2159
+ # The overlay fills its container via MATCH_PARENT layout params;
2160
+ # the engine frames only the portal's children.
2161
+ return
2162
+
2163
+ def _teardown(self, overlay: Any) -> None:
2164
+ try:
2165
+ parent = overlay.getParent()
2166
+ if parent is not None:
2167
+ parent.removeView(overlay)
2168
+ except Exception:
2169
+ pass
2170
+
2171
+
1782
2172
  class SliderHandler(AndroidViewHandler):
1783
2173
  def _build(self, props: Dict[str, Any]) -> Any:
1784
2174
  sb = jclass("android.widget.SeekBar")(_ctx())
@@ -2662,6 +3052,217 @@ class DatePickerHandler(AndroidViewHandler):
2662
3052
  _fire(btn, "on_change", iso)
2663
3053
 
2664
3054
 
3055
+ # ======================================================================
3056
+ # VirtualList — RecyclerView-backed native virtualization
3057
+ # ======================================================================
3058
+ #
3059
+ # The heavy lifting lives in the template's ``PNVirtualListView.java``:
3060
+ # Chaquopy cannot subclass abstract Java classes (RecyclerView.Adapter,
3061
+ # RecyclerView.ViewHolder), so the Java side owns the adapter and view
3062
+ # holders and calls back into Python through the small ``Delegate``
3063
+ # interface. Each visible row hosts a nested-reconciler subtree (see
3064
+ # ``pythonnative.virtual_rows``): the delegate's ``mountRow`` renders
3065
+ # the row element and attaches its native root to the recycled cell
3066
+ # container; ``onRowRecycled`` tears the subtree down.
3067
+
3068
+ # Per-list mutable state, keyed by the RecyclerView's Java identity.
3069
+ # The delegate proxy closes over this dict so prop updates (new
3070
+ # ``render_row``, new ``count``) take effect without re-wiring Java.
3071
+ _pn_vlist_info: Dict[int, Dict[str, Any]] = {}
3072
+
3073
+
3074
+ class VirtualListHandler(AndroidViewHandler):
3075
+ """Natively virtualized list backed by ``RecyclerView``.
3076
+
3077
+ Expects props:
3078
+
3079
+ - ``count``: total number of rows.
3080
+ - ``row_height``: uniform row extent in points, or
3081
+ - ``row_heights``: per-row extents (section lists interleave
3082
+ headers and items of different heights).
3083
+ - ``render_row``: ``render_row(index) -> Element`` producing one
3084
+ row's subtree. Called lazily as rows enter the viewport.
3085
+ - ``shows_scroll_indicator``: hide the scroll bar when ``False``.
3086
+
3087
+ Events (dispatched by tag): ``on_row_press`` with the row index,
3088
+ ``on_scroll`` with ``{"x", "y", "extent", "range"}`` in points.
3089
+
3090
+ Commands: ``scroll_to_offset`` / ``scroll_to_index`` /
3091
+ ``scroll_to_end`` / ``get_scroll_offset``.
3092
+ """
3093
+
3094
+ def _build(self, props: Dict[str, Any]) -> Any:
3095
+ from ..virtual_rows import RowHostPool
3096
+
3097
+ PNVirtualListView = _pn_runtime_class("PNVirtualListView")
3098
+ info: Dict[str, Any] = {
3099
+ "count": int(props.get("count", 0) or 0),
3100
+ "row_height": float(props.get("row_height", 44.0) or 44.0),
3101
+ "row_heights": props.get("row_heights"),
3102
+ "render_row": props.get("render_row"),
3103
+ "pool": RowHostPool(),
3104
+ }
3105
+
3106
+ class _Delegate(dynamic_proxy(PNVirtualListView.Delegate)):
3107
+ def getCount(self) -> int:
3108
+ return int(info["count"])
3109
+
3110
+ def getRowHeightDp(self, position: int) -> float:
3111
+ heights = info.get("row_heights")
3112
+ if heights and 0 <= position < len(heights):
3113
+ return float(heights[position])
3114
+ return float(info["row_height"])
3115
+
3116
+ def mountRow(self, position: int, container: Any, width_dp: float, height_dp: float) -> None:
3117
+ render_row = info.get("render_row")
3118
+ if render_row is None:
3119
+ return
3120
+ try:
3121
+ pool = info["pool"]
3122
+ root = pool.bind(
3123
+ _java_id(container),
3124
+ lambda: render_row(int(position)),
3125
+ float(width_dp),
3126
+ float(height_dp),
3127
+ )
3128
+ if root is not None:
3129
+ _insert_view(container, root, 0)
3130
+ # The layout engine frames only the *descendants*
3131
+ # of a subtree root (screen hosts normally size
3132
+ # the root themselves), so the row root would
3133
+ # otherwise keep the 0x0 params ``_insert_view``
3134
+ # assigns and render as nothing. Fill the cell.
3135
+ FrameLP = jclass("android.widget.FrameLayout$LayoutParams")
3136
+ root.setLayoutParams(FrameLP(FrameLP.MATCH_PARENT, FrameLP.MATCH_PARENT))
3137
+ except Exception:
3138
+ import traceback
3139
+
3140
+ traceback.print_exc()
3141
+
3142
+ def onRowPress(self, position: int) -> None:
3143
+ view = info.get("view")
3144
+ if view is not None:
3145
+ _fire(view, "on_row_press", int(position))
3146
+
3147
+ def onRowRecycled(self, container: Any) -> None:
3148
+ try:
3149
+ info["pool"].release(_java_id(container))
3150
+ except Exception:
3151
+ pass
3152
+
3153
+ def onScrolled(self, offset_dp: float, extent_dp: float, range_dp: float) -> None:
3154
+ view = info.get("view")
3155
+ if view is not None:
3156
+ _fire(
3157
+ view,
3158
+ "on_scroll",
3159
+ {
3160
+ "x": 0.0,
3161
+ "y": float(offset_dp),
3162
+ "extent": float(extent_dp),
3163
+ "range": float(range_dp),
3164
+ },
3165
+ )
3166
+
3167
+ delegate = _Delegate()
3168
+ view = PNVirtualListView(_ctx(), delegate)
3169
+ info["view"] = view
3170
+ # Keep the proxy alive: Java holds only a weak-ish reference
3171
+ # through the field, and Chaquopy proxies are collectible once
3172
+ # the Python side drops them.
3173
+ info["delegate"] = delegate
3174
+ _pn_vlist_info[_java_id(view)] = info
3175
+ return view
3176
+
3177
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
3178
+ _apply_common_visual(view, props)
3179
+ info = _pn_vlist_info.get(_java_id(view))
3180
+ if info is None:
3181
+ return
3182
+ data_changed = False
3183
+ if "count" in props:
3184
+ info["count"] = int(props.get("count") or 0)
3185
+ data_changed = True
3186
+ if "row_height" in props and props["row_height"] is not None:
3187
+ info["row_height"] = float(props["row_height"])
3188
+ data_changed = True
3189
+ if "row_heights" in props:
3190
+ info["row_heights"] = props.get("row_heights")
3191
+ data_changed = True
3192
+ if "render_row" in props:
3193
+ info["render_row"] = props.get("render_row")
3194
+ data_changed = True
3195
+ if "shows_scroll_indicator" in props:
3196
+ show = props["shows_scroll_indicator"] is not False
3197
+ try:
3198
+ view.setVerticalScrollBarEnabled(show)
3199
+ except Exception:
3200
+ pass
3201
+ if data_changed and not initial:
3202
+ try:
3203
+ view.notifyDataChanged()
3204
+ except Exception:
3205
+ pass
3206
+
3207
+ def _teardown(self, native_view: Any) -> None:
3208
+ info = _pn_vlist_info.pop(_java_id(native_view), None)
3209
+ if info is not None:
3210
+ info.get("pool").release_all()
3211
+ info["view"] = None
3212
+
3213
+ def measure_intrinsic(
3214
+ self,
3215
+ native_view: Any,
3216
+ max_width: float,
3217
+ max_height: float,
3218
+ ) -> Tuple[float, float]:
3219
+ # Fill the available space, like a ScrollView clamped to its
3220
+ # parent. Inside an unbounded axis (nested in another scroll
3221
+ # view) collapse to 0, matching React Native's behavior for
3222
+ # nested virtualized lists.
3223
+ w = max_width if math.isfinite(max_width) else 0.0
3224
+ h = max_height if math.isfinite(max_height) else 0.0
3225
+ return (max(0.0, w), max(0.0, h))
3226
+
3227
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
3228
+ density = _density() or 1.0
3229
+ if name == "scroll_to_offset":
3230
+ try:
3231
+ native_view.scrollToOffsetDp(
3232
+ float(args.get("y", 0.0) or 0.0),
3233
+ args.get("animated", True) is not False,
3234
+ )
3235
+ except Exception:
3236
+ pass
3237
+ return None
3238
+ if name == "scroll_to_index":
3239
+ try:
3240
+ native_view.scrollToIndex(
3241
+ int(args.get("index", 0) or 0),
3242
+ args.get("animated", True) is not False,
3243
+ )
3244
+ except Exception:
3245
+ pass
3246
+ return None
3247
+ if name == "scroll_to_end":
3248
+ info = _pn_vlist_info.get(_java_id(native_view))
3249
+ count = int(info.get("count", 0)) if info else 0
3250
+ try:
3251
+ native_view.scrollToIndex(max(0, count - 1), args.get("animated", True) is not False)
3252
+ except Exception:
3253
+ pass
3254
+ return None
3255
+ if name == "get_scroll_offset":
3256
+ try:
3257
+ return {
3258
+ "x": 0.0,
3259
+ "y": float(native_view.computeVerticalScrollOffset()) / density,
3260
+ }
3261
+ except Exception:
3262
+ return {"x": 0.0, "y": 0.0}
3263
+ return None
3264
+
3265
+
2665
3266
  # ======================================================================
2666
3267
  # Registration
2667
3268
  # ======================================================================
@@ -2685,6 +3286,7 @@ def register_handlers(registry: Any) -> None:
2685
3286
  registry.register("Spacer", SpacerHandler())
2686
3287
  registry.register("SafeAreaView", SafeAreaViewHandler())
2687
3288
  registry.register("Modal", ModalHandler())
3289
+ registry.register("Portal", PortalHandler())
2688
3290
  registry.register("Slider", SliderHandler())
2689
3291
  registry.register("TabBar", TabBarHandler())
2690
3292
  registry.register("Pressable", PressableHandler())
@@ -2694,6 +3296,7 @@ def register_handlers(registry: Any) -> None:
2694
3296
  registry.register("Checkbox", CheckboxHandler())
2695
3297
  registry.register("SegmentedControl", SegmentedControlHandler())
2696
3298
  registry.register("DatePicker", DatePickerHandler())
3299
+ registry.register("VirtualList", VirtualListHandler())
2697
3300
 
2698
3301
 
2699
3302
  __all__ = [
@@ -2711,6 +3314,7 @@ __all__ = [
2711
3314
  "SpacerHandler",
2712
3315
  "SafeAreaViewHandler",
2713
3316
  "ModalHandler",
3317
+ "PortalHandler",
2714
3318
  "SliderHandler",
2715
3319
  "TabBarHandler",
2716
3320
  "PressableHandler",
@@ -2720,5 +3324,6 @@ __all__ = [
2720
3324
  "CheckboxHandler",
2721
3325
  "SegmentedControlHandler",
2722
3326
  "DatePickerHandler",
3327
+ "VirtualListHandler",
2723
3328
  "register_handlers",
2724
3329
  ]