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
@@ -304,6 +304,93 @@ def _apply_view_border(view: Any, props: Dict[str, Any]) -> None:
304
304
  _apply_border(view.layer, props)
305
305
 
306
306
 
307
+ # Per-side border state keyed by id(view): {"widths": (l, t, r, b),
308
+ # "colors": (l, t, r, b), "layers": [CALayer or None x4]}.
309
+ _pn_side_border_map: dict = {}
310
+
311
+ _SIDE_BORDER_WIDTH_KEYS = (
312
+ "border_left_width",
313
+ "border_top_width",
314
+ "border_right_width",
315
+ "border_bottom_width",
316
+ )
317
+ _SIDE_BORDER_COLOR_KEYS = (
318
+ "border_left_color",
319
+ "border_top_color",
320
+ "border_right_color",
321
+ "border_bottom_color",
322
+ )
323
+
324
+
325
+ def _apply_side_borders(view: Any, props: Dict[str, Any]) -> None:
326
+ """Record per-side border strips (drawn as CALayer sublayers).
327
+
328
+ Activated when any ``border_<side>_width`` key is present. In that
329
+ mode the strips own all four edges: each side's width falls back
330
+ to the uniform ``border_width`` (else 0) and its color to the
331
+ uniform ``border_color`` (else black), and the layer-level uniform
332
+ border is disabled so the two mechanisms don't double-draw. The
333
+ strips are (re)positioned at frame time when bounds are known.
334
+ """
335
+ if not any(k in props and props[k] is not None for k in _SIDE_BORDER_WIDTH_KEYS):
336
+ return
337
+ base_width = float(props.get("border_width") or 0.0)
338
+ base_color = props.get("border_color") or "#000000"
339
+ widths = tuple(float(props[k]) if props.get(k) is not None else base_width for k in _SIDE_BORDER_WIDTH_KEYS)
340
+ colors = tuple(props[k] if props.get(k) is not None else base_color for k in _SIDE_BORDER_COLOR_KEYS)
341
+ entry = _pn_side_border_map.setdefault(id(view), {"layers": [None, None, None, None]})
342
+ entry["widths"] = widths
343
+ entry["colors"] = colors
344
+ try:
345
+ view.layer.setBorderWidth_(0.0)
346
+ except Exception:
347
+ pass
348
+ try:
349
+ bounds = view.bounds
350
+ w = float(bounds.size.width)
351
+ h = float(bounds.size.height)
352
+ if w > 0.0 and h > 0.0:
353
+ _update_side_border_layers(view, w, h)
354
+ except Exception:
355
+ pass
356
+
357
+
358
+ def _update_side_border_layers(view: Any, width: float, height: float) -> None:
359
+ """Size the four border-strip sublayers to the view's current bounds."""
360
+ entry = _pn_side_border_map.get(id(view))
361
+ if entry is None or "widths" not in entry:
362
+ return
363
+ left_w, top_w, right_w, bottom_w = entry["widths"]
364
+ frames = (
365
+ ((0.0, 0.0), (left_w, height)),
366
+ ((0.0, 0.0), (width, top_w)),
367
+ ((width - right_w, 0.0), (right_w, height)),
368
+ ((0.0, height - bottom_w), (width, bottom_w)),
369
+ )
370
+ CALayer = ObjCClass("CALayer")
371
+ layers = entry["layers"]
372
+ for i in range(4):
373
+ w_i = entry["widths"][i]
374
+ layer = layers[i]
375
+ if w_i <= 0.0:
376
+ if layer is not None:
377
+ try:
378
+ layer.removeFromSuperlayer()
379
+ except Exception:
380
+ pass
381
+ layers[i] = None
382
+ continue
383
+ try:
384
+ if layer is None:
385
+ layer = CALayer.layer()
386
+ view.layer.addSublayer_(layer)
387
+ layers[i] = layer
388
+ layer.setBackgroundColor_(_cgcolor(entry["colors"][i]))
389
+ layer.setFrame_(frames[i])
390
+ except Exception:
391
+ pass
392
+
393
+
307
394
  def _clamp_view_corner_radius(view: Any, width: float, height: float) -> None:
308
395
  """Clamp oversized pill radii to the view's rendered bounds."""
309
396
  requested = _pn_view_border_radius_map.get(id(view))
@@ -481,8 +568,38 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
481
568
  pass
482
569
 
483
570
 
571
+ # UIAccessibilityTraits bitmask values (UIKit constants).
572
+ _TRAIT_BUTTON = 0x1
573
+ _TRAIT_LINK = 0x2
574
+ _TRAIT_IMAGE = 0x4
575
+ _TRAIT_SELECTED = 0x8
576
+ _TRAIT_KEYBOARD_KEY = 0x20
577
+ _TRAIT_STATIC_TEXT = 0x40
578
+ _TRAIT_SUMMARY_ELEMENT = 0x80
579
+ _TRAIT_NOT_ENABLED = 0x100
580
+ _TRAIT_UPDATES_FREQUENTLY = 0x200
581
+ _TRAIT_SEARCH_FIELD = 0x400
582
+ _TRAIT_ADJUSTABLE = 0x1000
583
+ _TRAIT_HEADER = 0x10000
584
+
585
+ _TRAIT_BY_ROLE = {
586
+ "button": _TRAIT_BUTTON,
587
+ "link": _TRAIT_LINK,
588
+ "image": _TRAIT_IMAGE,
589
+ "search": _TRAIT_SEARCH_FIELD,
590
+ "keyboard_key": _TRAIT_KEYBOARD_KEY,
591
+ "static_text": _TRAIT_STATIC_TEXT,
592
+ "summary_element": _TRAIT_SUMMARY_ELEMENT,
593
+ "adjustable": _TRAIT_ADJUSTABLE,
594
+ "header": _TRAIT_HEADER,
595
+ "selected": _TRAIT_SELECTED,
596
+ "checkbox": _TRAIT_BUTTON,
597
+ "none": 0,
598
+ }
599
+
600
+
484
601
  def _apply_accessibility(view: Any, props: Dict[str, Any]) -> None:
485
- """Apply accessibility_label / hint / role / accessible to a view."""
602
+ """Apply accessibility props (label / hint / role / state / test_id)."""
486
603
  if "accessible" in props:
487
604
  try:
488
605
  view.setIsAccessibilityElement_(bool(props["accessible"]))
@@ -500,25 +617,36 @@ def _apply_accessibility(view: Any, props: Dict[str, Any]) -> None:
500
617
  view.setAccessibilityHint_(str(v) if v is not None else "")
501
618
  except Exception:
502
619
  pass
503
- if "accessibility_role" in props and props["accessibility_role"] is not None:
504
- # UIAccessibilityTraits bitmask.
505
- traits = {
506
- "button": 1 << 0,
507
- "link": 1 << 1,
508
- "image": 1 << 2,
509
- "search": 1 << 3,
510
- "header": 1 << 28,
511
- "summary_element": 1 << 6,
512
- "selected": 1 << 5,
513
- "static_text": 1 << 4,
514
- "none": 0,
515
- }
516
- trait = traits.get(str(props["accessibility_role"]).lower())
517
- if trait is not None:
620
+ if "test_id" in props:
621
+ v = props["test_id"]
622
+ try:
623
+ view.setAccessibilityIdentifier_(str(v) if v is not None else None)
624
+ except Exception:
625
+ pass
626
+
627
+ role = props.get("accessibility_role")
628
+ state = props.get("accessibility_state")
629
+ if role is None and state is None:
630
+ return
631
+ traits = 0
632
+ if role is not None:
633
+ traits |= _TRAIT_BY_ROLE.get(str(role).lower(), 0)
634
+ if isinstance(state, dict):
635
+ if state.get("selected") or state.get("checked"):
636
+ traits |= _TRAIT_SELECTED
637
+ if state.get("disabled"):
638
+ traits |= _TRAIT_NOT_ENABLED
639
+ if state.get("busy"):
640
+ traits |= _TRAIT_UPDATES_FREQUENTLY
641
+ if "expanded" in state:
518
642
  try:
519
- view.setAccessibilityTraits_(trait)
643
+ view.setAccessibilityValue_("expanded" if state["expanded"] else "collapsed")
520
644
  except Exception:
521
645
  pass
646
+ try:
647
+ view.setAccessibilityTraits_(traits)
648
+ except Exception:
649
+ pass
522
650
 
523
651
 
524
652
  def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
@@ -538,6 +666,7 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
538
666
  except Exception:
539
667
  pass
540
668
  _apply_view_border(view, props)
669
+ _apply_side_borders(view, props)
541
670
  _apply_shadow(view, props)
542
671
  _apply_transform(view, props)
543
672
  _apply_accessibility(view, props)
@@ -1037,6 +1166,8 @@ class IOSViewHandler(ViewHandler):
1037
1166
  # Controls register their own pointer as the action-handler key
1038
1167
  # (see _register_control_action); drop it with the view.
1039
1168
  _pn_action_handlers.pop(_recognizer_ptr(native_view), None)
1169
+ _pn_view_border_radius_map.pop(id(native_view), None)
1170
+ _pn_side_border_map.pop(id(native_view), None)
1040
1171
  try:
1041
1172
  native_view.removeFromSuperview()
1042
1173
  except Exception:
@@ -1086,6 +1217,7 @@ class IOSViewHandler(ViewHandler):
1086
1217
  native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
1087
1218
  native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
1088
1219
  _clamp_view_corner_radius(native_view, frame_w, frame_h)
1220
+ _update_side_border_layers(native_view, frame_w, frame_h)
1089
1221
  try:
1090
1222
  _clamp_layer_corner_radius(native_view.layer, frame_w, frame_h)
1091
1223
  except Exception:
@@ -1271,8 +1403,24 @@ class TextHandler(IOSViewHandler):
1271
1403
  pass
1272
1404
  return font
1273
1405
 
1406
+ _SPAN_REBUILD_KEYS = (
1407
+ "spans",
1408
+ "text",
1409
+ "font_size",
1410
+ "font_weight",
1411
+ "font_family",
1412
+ "italic",
1413
+ "bold",
1414
+ "color",
1415
+ "letter_spacing",
1416
+ "line_height",
1417
+ "text_decoration",
1418
+ )
1419
+
1274
1420
  def _apply(self, label: Any, props: Dict[str, Any], initial: bool) -> None:
1275
- if "text" in props:
1421
+ merged_state = _state_of(label).get("props") or props
1422
+ has_spans = bool(merged_state.get("spans"))
1423
+ if "text" in props and not has_spans:
1276
1424
  label.setText_(str(props["text"]) if props["text"] is not None else "")
1277
1425
  # Font requires combining size + weight + family + italic + bold.
1278
1426
  font_keys_present = any(k in props for k in ("font_size", "font_weight", "font_family", "italic", "bold"))
@@ -1299,9 +1447,14 @@ class TextHandler(IOSViewHandler):
1299
1447
  if "text_align" in props:
1300
1448
  mapping = {"left": 0, "center": 1, "right": 2, "natural": 4, "justify": 3}
1301
1449
  label.setTextAlignment_(mapping.get(props["text_align"], 0))
1302
- if "letter_spacing" in props or "line_height" in props or "text_decoration" in props:
1303
- merged = _state_of(label).get("props") or props
1304
- self._apply_attributed(label, merged)
1450
+ if has_spans:
1451
+ if any(k in props for k in self._SPAN_REBUILD_KEYS):
1452
+ self._apply_spans(label, merged_state)
1453
+ elif "spans" in props:
1454
+ # Rich -> plain transition: restore the plain string.
1455
+ label.setText_(str(merged_state.get("text") or ""))
1456
+ elif "letter_spacing" in props or "line_height" in props or "text_decoration" in props:
1457
+ self._apply_attributed(label, merged_state)
1305
1458
  _apply_view_border(label, props)
1306
1459
  _apply_shadow(label, props)
1307
1460
  _apply_transform(label, props)
@@ -1312,6 +1465,74 @@ class TextHandler(IOSViewHandler):
1312
1465
  except Exception:
1313
1466
  pass
1314
1467
 
1468
+ def _apply_spans(self, label: Any, merged: Dict[str, Any]) -> None:
1469
+ """Render a rich-text span list as one NSAttributedString.
1470
+
1471
+ The label's base text styling (font, color, kerning, line
1472
+ height, decoration) is applied over the full string first,
1473
+ then each span's overrides are layered onto its own range, so
1474
+ line wrapping flows across spans inside a single ``UILabel``.
1475
+ """
1476
+ try:
1477
+ spans = [s for s in (merged.get("spans") or []) if isinstance(s, dict)]
1478
+ full_text = "".join(str(s.get("text", "")) for s in spans)
1479
+ NSMutableAttributedString = ObjCClass("NSMutableAttributedString")
1480
+ NSMutableParagraphStyle = ObjCClass("NSMutableParagraphStyle")
1481
+ attr = NSMutableAttributedString.alloc().initWithString_(full_text)
1482
+ full_range = (0, len(full_text))
1483
+
1484
+ base_font = label.font
1485
+ base_size = float(base_font.pointSize) if base_font is not None else 17.0
1486
+ if base_font is not None:
1487
+ attr.addAttribute_value_range_("NSFont", base_font, full_range)
1488
+ if merged.get("letter_spacing") is not None:
1489
+ attr.addAttribute_value_range_("NSKern", float(merged["letter_spacing"]), full_range)
1490
+ if merged.get("line_height") is not None:
1491
+ p_style = NSMutableParagraphStyle.alloc().init()
1492
+ p_style.setMinimumLineHeight_(float(merged["line_height"]))
1493
+ p_style.setMaximumLineHeight_(float(merged["line_height"]))
1494
+ attr.addAttribute_value_range_("NSParagraphStyle", p_style, full_range)
1495
+ if merged.get("text_decoration") == "underline":
1496
+ attr.addAttribute_value_range_("NSUnderline", 1, full_range)
1497
+ elif merged.get("text_decoration") == "line_through":
1498
+ attr.addAttribute_value_range_("NSStrikethrough", 1, full_range)
1499
+
1500
+ pos = 0
1501
+ for span in spans:
1502
+ text = str(span.get("text", ""))
1503
+ rng = (pos, len(text))
1504
+ pos += len(text)
1505
+ if not text:
1506
+ continue
1507
+ try:
1508
+ if any(
1509
+ span.get(k) is not None for k in ("font_size", "font_weight", "font_family", "italic", "bold")
1510
+ ):
1511
+ size = float(span["font_size"]) if span.get("font_size") is not None else base_size
1512
+ weight = span.get("font_weight")
1513
+ if weight is None and span.get("bold"):
1514
+ weight = "bold"
1515
+ font = self._font_for(size, weight, span.get("font_family"), bool(span.get("italic")))
1516
+ attr.addAttribute_value_range_("NSFont", font, rng)
1517
+ if span.get("color") is not None:
1518
+ attr.addAttribute_value_range_("NSColor", _uicolor(span["color"]), rng)
1519
+ if span.get("background_color") is not None:
1520
+ attr.addAttribute_value_range_("NSBackgroundColor", _uicolor(span["background_color"]), rng)
1521
+ if span.get("letter_spacing") is not None:
1522
+ attr.addAttribute_value_range_("NSKern", float(span["letter_spacing"]), rng)
1523
+ if span.get("text_decoration") == "underline":
1524
+ attr.addAttribute_value_range_("NSUnderline", 1, rng)
1525
+ elif span.get("text_decoration") == "line_through":
1526
+ attr.addAttribute_value_range_("NSStrikethrough", 1, rng)
1527
+ except Exception:
1528
+ pass
1529
+ label.setAttributedText_(attr)
1530
+ except Exception:
1531
+ try:
1532
+ label.setText_(str(merged.get("text") or ""))
1533
+ except Exception:
1534
+ pass
1535
+
1315
1536
  def _apply_attributed(self, label: Any, props: Dict[str, Any]) -> None:
1316
1537
  """Re-render the label's text as an NSAttributedString.
1317
1538
 
@@ -1368,7 +1589,7 @@ class ButtonHandler(IOSViewHandler):
1368
1589
  btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
1369
1590
  btn.retain()
1370
1591
  _pn_retained_views.append(btn)
1371
- _register_control_action(btn, 1 << 6, lambda: _fire(btn, "on_click")) # TouchUpInside
1592
+ _register_control_action(btn, 1 << 6, lambda: _fire(btn, "on_press")) # TouchUpInside
1372
1593
  return btn
1373
1594
 
1374
1595
  def measure_intrinsic(
@@ -1622,6 +1843,11 @@ class ImageHandler(IOSViewHandler):
1622
1843
  iv.setTintColor_(_uicolor(props["tint_color"]))
1623
1844
  except Exception:
1624
1845
  pass
1846
+ if "placeholder_color" in props and props["placeholder_color"] is not None:
1847
+ try:
1848
+ iv.setBackgroundColor_(_uicolor(props["placeholder_color"]))
1849
+ except Exception:
1850
+ pass
1625
1851
  if "source" in props and props["source"]:
1626
1852
  self._load_source(iv, str(props["source"]))
1627
1853
  if "scale_type" in props and props["scale_type"]:
@@ -1652,7 +1878,9 @@ class ImageHandler(IOSViewHandler):
1652
1878
 
1653
1879
  def _load_source(self, iv: Any, source: str) -> None:
1654
1880
  try:
1655
- if source.startswith(("http://", "https://")):
1881
+ if source.startswith("data:"):
1882
+ self._load_data_uri(iv, source)
1883
+ elif source.startswith(("http://", "https://")):
1656
1884
  self._load_async(iv, source)
1657
1885
  else:
1658
1886
  # Bundle resource or absolute file path.
@@ -1662,55 +1890,107 @@ class ImageHandler(IOSViewHandler):
1662
1890
  image = UIImage.imageWithContentsOfFile_(source)
1663
1891
  if image:
1664
1892
  iv.setImage_(image)
1893
+ _fire(iv, "on_load")
1894
+ else:
1895
+ _fire(iv, "on_error", f"image {source!r} not found")
1665
1896
  except Exception:
1666
1897
  pass
1667
1898
 
1668
- def _load_async(self, iv: Any, source: str) -> None:
1669
- """Asynchronously load a remote image off the main thread.
1899
+ def _load_data_uri(self, iv: Any, source: str) -> None:
1900
+ """Decode an inline ``data:`` URI (base64 payload) into the view."""
1901
+ try:
1902
+ import base64
1903
+
1904
+ payload = source.split(",", 1)[1] if "," in source else ""
1905
+ raw = base64.b64decode(payload)
1906
+ NSData = ObjCClass("NSData")
1907
+ data = NSData.dataWithBytes_length_(raw, len(raw))
1908
+ image = ObjCClass("UIImage").imageWithData_(data)
1909
+ if image is not None:
1910
+ iv.setImage_(image)
1911
+ _fire(iv, "on_load")
1912
+ else:
1913
+ _fire(iv, "on_error", "data URI decode failed")
1914
+ except Exception:
1915
+ _fire(iv, "on_error", "data URI decode failed")
1670
1916
 
1671
- Uses ``NSURLSession.sharedSession.dataTaskWithURL:completionHandler:``
1672
- so the main thread is never blocked. The completion handler
1673
- runs on a background queue; the image is set back on the main
1674
- queue so UIKit accepts it without threading warnings. The
1675
- latest requested URI wins if several loads race.
1917
+ def _load_async(self, iv: Any, source: str) -> None:
1918
+ """Load a remote image through the shared image pipeline.
1919
+
1920
+ `pythonnative.images` downloads on a background thread with
1921
+ memory + disk caching and request deduplication, then hands a
1922
+ local file path back on the main queue; the decoded image is
1923
+ downsampled to the view's bounds so oversized photos don't pin
1924
+ full-resolution bitmaps in memory. The latest requested URI
1925
+ wins if several loads race.
1676
1926
  """
1677
1927
  state = _state_of(iv)
1678
1928
  state["pending_uri"] = source
1679
1929
  try:
1680
1930
  iv.retain()
1681
1931
  _pn_retained_views.append(iv)
1682
- NSURL = ObjCClass("NSURL")
1683
- NSURLSession = ObjCClass("NSURLSession")
1684
- UIImage = ObjCClass("UIImage")
1685
- url = NSURL.URLWithString_(source)
1686
- session = NSURLSession.sharedSession
1932
+ from ..images import fetch
1687
1933
 
1688
- def completion(data: Any, response: Any, error: Any) -> None:
1689
- if error is not None or data is None:
1690
- return
1934
+ def on_ready(path: str) -> None:
1691
1935
  try:
1692
- image = UIImage.imageWithData_(data)
1936
+ if _state_of(iv).get("pending_uri") != source:
1937
+ return
1938
+ image = self._decode_downsampled(iv, path)
1693
1939
  if image is None:
1940
+ _fire(iv, "on_error", "decode failed")
1694
1941
  return
1695
-
1696
- def apply() -> None:
1697
- try:
1698
- if _state_of(iv).get("pending_uri") == source:
1699
- iv.setImage_(image)
1700
- except Exception:
1701
- pass
1702
-
1703
- from ..runtime import call_on_main_thread
1704
-
1705
- call_on_main_thread(apply)
1942
+ iv.setImage_(image)
1943
+ _fire(iv, "on_load")
1706
1944
  except Exception:
1707
1945
  pass
1708
1946
 
1709
- task = session.dataTaskWithURL_completionHandler_(url, completion)
1710
- task.resume()
1947
+ def on_error(message: str) -> None:
1948
+ if _state_of(iv).get("pending_uri") == source:
1949
+ _fire(iv, "on_error", message)
1950
+
1951
+ fetch(source, on_ready, on_error)
1711
1952
  except Exception:
1712
1953
  pass
1713
1954
 
1955
+ def _decode_downsampled(self, iv: Any, path: str) -> Any:
1956
+ """Decode a file, scaling down to the view's bounds if oversized.
1957
+
1958
+ Uses ``UIGraphicsImageRenderer`` to re-rasterize when the
1959
+ source is more than 2x the target's pixel size, keeping memory
1960
+ proportional to what's on screen.
1961
+ """
1962
+ try:
1963
+ UIImage = ObjCClass("UIImage")
1964
+ image = UIImage.imageWithContentsOfFile_(path)
1965
+ if image is None:
1966
+ return None
1967
+ bounds = iv.bounds
1968
+ target_w = float(bounds.size.width)
1969
+ target_h = float(bounds.size.height)
1970
+ if target_w <= 0.0 or target_h <= 0.0:
1971
+ return image
1972
+ size = image.size
1973
+ src_w, src_h = float(size.width), float(size.height)
1974
+ if src_w <= target_w * 2.0 and src_h <= target_h * 2.0:
1975
+ return image
1976
+ try:
1977
+ scale = min(target_w * 2.0 / src_w, target_h * 2.0 / src_h)
1978
+ new_w, new_h = src_w * scale, src_h * scale
1979
+ UIGraphicsImageRenderer = ObjCClass("UIGraphicsImageRenderer")
1980
+ renderer = UIGraphicsImageRenderer.alloc().initWithSize_((new_w, new_h))
1981
+
1982
+ def draw(_ctx_obj: Any) -> None:
1983
+ image.drawInRect_(((0.0, 0.0), (new_w, new_h)))
1984
+
1985
+ resized = renderer.imageWithActions_(draw)
1986
+ return resized if resized is not None else image
1987
+ except Exception:
1988
+ # Resize is an optimization; the full-size image is
1989
+ # still correct if the renderer path fails.
1990
+ return image
1991
+ except Exception:
1992
+ return None
1993
+
1714
1994
 
1715
1995
  # ----------------------------------------------------------------------
1716
1996
  # TextInput: raw libobjc target/delegate
@@ -2857,6 +3137,174 @@ class ModalHandler(IOSViewHandler):
2857
3137
  _fire(placeholder, "on_dismiss")
2858
3138
 
2859
3139
 
3140
+ # ======================================================================
3141
+ # Portal: floats its children over the screen in a top-level overlay
3142
+ # ======================================================================
3143
+ #
3144
+ # The overlay is an instance of ``_PNPortalViewCTypes``, a raw libobjc
3145
+ # subclass of ``UIView`` overriding ``pointInside:withEvent:`` so the
3146
+ # overlay only claims touches that land on one of its subviews. UIKit's
3147
+ # default hit-testing skips a view (and its whole subtree) when
3148
+ # ``pointInside:`` returns NO, so taps on empty portal area fall
3149
+ # through to the screen content below while the portal's own children
3150
+ # stay fully interactive. Registered raw (not via rubicon's
3151
+ # ``@objc_method``) for the same arm64 FFI reliability reasons as the
3152
+ # other delegate classes in this module.
3153
+
3154
+
3155
+ class _PNCGPoint(_ct.Structure):
3156
+ _fields_ = [("x", _ct.c_double), ("y", _ct.c_double)]
3157
+
3158
+
3159
+ _PORTAL_POINT_INSIDE_TYPE = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _PNCGPoint, _ct.c_void_p)
3160
+
3161
+
3162
+ def _portal_point_inside_imp(self_ptr: int, _cmd_ptr: int, point: Any, _event_ptr: int) -> bool:
3163
+ """Return whether ``point`` lands on any visible, interactive subview."""
3164
+ try:
3165
+ view = ObjCInstance(_ct.c_void_p(self_ptr))
3166
+ px = float(point.x)
3167
+ py = float(point.y)
3168
+ # On instances of this raw-registered class rubicon resolves
3169
+ # ``subviews`` as a bound method rather than a property, so it
3170
+ # must be called to get the NSArray.
3171
+ subs = view.subviews
3172
+ if callable(subs):
3173
+ subs = subs()
3174
+ for sub in list(subs or []):
3175
+ try:
3176
+ if bool(sub.isHidden()) or float(sub.alpha) <= 0.01:
3177
+ continue
3178
+ if not bool(sub.isUserInteractionEnabled()):
3179
+ continue
3180
+ frame = sub.frame
3181
+ fx = float(frame.origin.x)
3182
+ fy = float(frame.origin.y)
3183
+ fw = float(frame.size.width)
3184
+ fh = float(frame.size.height)
3185
+ if fx <= px <= fx + fw and fy <= py <= fy + fh:
3186
+ return True
3187
+ except Exception:
3188
+ continue
3189
+ return False
3190
+ except Exception:
3191
+ return False
3192
+
3193
+
3194
+ _portal_point_inside_imp_ref = _PORTAL_POINT_INSIDE_TYPE(_portal_point_inside_imp)
3195
+
3196
+ _UIVIEW_CLS = _get_cls(b"UIView")
3197
+ _PN_PORTAL_VIEW_CLS = _alloc_cls(_UIVIEW_CLS, b"_PNPortalViewCTypes", 0) if _UIVIEW_CLS else None
3198
+ if _PN_PORTAL_VIEW_CLS:
3199
+ _add_method(
3200
+ _PN_PORTAL_VIEW_CLS,
3201
+ _sel_reg(b"pointInside:withEvent:"),
3202
+ _ct.cast(_portal_point_inside_imp_ref, _ct.c_void_p),
3203
+ b"c@:{CGPoint=dd}@",
3204
+ )
3205
+ _reg_cls(_PN_PORTAL_VIEW_CLS)
3206
+
3207
+
3208
+ class PortalHandler(IOSViewHandler):
3209
+ """Overlay container hosting [`Portal`][pythonnative.Portal] children.
3210
+
3211
+ The portal's native view is a transparent, full-size overlay added
3212
+ to the topmost view controller's view (the same view the screen
3213
+ root is attached to). Its frame mirrors the screen host's root
3214
+ frame (below the top safe-area inset), so portal coordinates equal
3215
+ viewport coordinates. Attachment happens lazily on the first child
3216
+ insert and re-homes itself if the host view changed (e.g. after a
3217
+ native modal closed).
3218
+
3219
+ When the ``_PNPortalViewCTypes`` registration is unavailable the
3220
+ handler degrades to a plain ``UIView`` overlay: portal children
3221
+ still render and receive touches, but empty portal area blocks the
3222
+ content below instead of passing touches through.
3223
+ """
3224
+
3225
+ def _build(self, props: Dict[str, Any]) -> Any:
3226
+ overlay = None
3227
+ if _PN_PORTAL_VIEW_CLS:
3228
+ try:
3229
+ overlay = ObjCClass("_PNPortalViewCTypes").alloc().init()
3230
+ except Exception:
3231
+ overlay = None
3232
+ if overlay is None:
3233
+ overlay = ObjCClass("UIView").alloc().init()
3234
+ overlay.setTranslatesAutoresizingMaskIntoConstraints_(True)
3235
+ overlay.setBackgroundColor_(_uicolor("#00000000"))
3236
+ overlay.retain()
3237
+ _pn_retained_views.append(overlay)
3238
+ return overlay
3239
+
3240
+ def _apply(self, overlay: Any, props: Dict[str, Any], initial: bool) -> None:
3241
+ _apply_common_visual(overlay, props)
3242
+ if not initial:
3243
+ self._ensure_attached(overlay)
3244
+
3245
+ def _ensure_attached(self, overlay: Any) -> None:
3246
+ try:
3247
+ UIApplication = ObjCClass("UIApplication")
3248
+ top = _top_view_controller_for_alert(UIApplication.sharedApplication)
3249
+ host = top.view if top is not None else None
3250
+ if host is None:
3251
+ return
3252
+ current = overlay.superview
3253
+ if current is not None and _objc_ptr(current) == _objc_ptr(host):
3254
+ host.bringSubviewToFront_(overlay)
3255
+ self._sync_overlay_frame(overlay, host)
3256
+ return
3257
+ if current is not None:
3258
+ overlay.removeFromSuperview()
3259
+ host.addSubview_(overlay)
3260
+ self._sync_overlay_frame(overlay, host)
3261
+ except Exception:
3262
+ pass
3263
+
3264
+ @staticmethod
3265
+ def _sync_overlay_frame(overlay: Any, host: Any) -> None:
3266
+ """Mirror the screen host's root frame (see ``_sync_root_frame``)."""
3267
+ try:
3268
+ bounds = host.bounds
3269
+ insets = host.safeAreaInsets
3270
+ top = float(insets.top)
3271
+ left = float(insets.left)
3272
+ right = float(insets.right)
3273
+ w = max(0.0, float(bounds.size.width) - left - right)
3274
+ h = max(0.0, float(bounds.size.height) - top)
3275
+ overlay.setFrame_(((left, top), (w, h)))
3276
+ overlay.setAutoresizingMask_(2 | 16) # FlexibleWidth | FlexibleHeight
3277
+ except Exception:
3278
+ pass
3279
+
3280
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
3281
+ self._ensure_attached(parent)
3282
+ super().insert_child(parent, child, index)
3283
+
3284
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
3285
+ # The overlay frames itself against the host view; the engine
3286
+ # frames only the portal's children.
3287
+ return
3288
+
3289
+ def measure_intrinsic(
3290
+ self,
3291
+ native_view: Any,
3292
+ max_width: float,
3293
+ max_height: float,
3294
+ ) -> Tuple[float, float]:
3295
+ return (0.0, 0.0)
3296
+
3297
+ def _teardown(self, overlay: Any) -> None:
3298
+ try:
3299
+ _pn_retained_views.remove(overlay)
3300
+ except ValueError:
3301
+ pass
3302
+ try:
3303
+ overlay.release()
3304
+ except Exception:
3305
+ pass
3306
+
3307
+
2860
3308
  # ======================================================================
2861
3309
  # StatusBar: global side effect, no view in the tree
2862
3310
  # ======================================================================
@@ -3641,6 +4089,476 @@ class DatePickerHandler(IOSViewHandler):
3641
4089
  return (0.0, 0.0)
3642
4090
 
3643
4091
 
4092
+ # ======================================================================
4093
+ # VirtualList — UITableView-backed native virtualization
4094
+ # ======================================================================
4095
+ #
4096
+ # We register a raw libobjc class ``_PNTableSourceCTypes`` rather than
4097
+ # using rubicon-objc's ``@objc_method`` because UIKit invokes
4098
+ # ``tableView:cellForRowAtIndexPath:`` with a tagged-pointer
4099
+ # NSIndexPath that crashes inside CPython's ``_ctypes.O_get`` when
4100
+ # rubicon-objc's FFI closure tries to wrap it as a PyObject*.
4101
+ #
4102
+ # Each UITableView gets its own dataSource instance; per-instance state
4103
+ # lives in ``_pn_table_state`` keyed by the dataSource's raw pointer
4104
+ # (the integer passed as ``self`` to every IMP). Rows host nested
4105
+ # reconciler subtrees pooled per cell ``contentView`` (see
4106
+ # ``pythonnative.virtual_rows``); recycled cells rebind their existing
4107
+ # subtree instead of remounting, so steady scrolling reuses both the
4108
+ # native cell and the Python-side row tree.
4109
+
4110
+ _pn_table_state: Dict[int, dict] = {}
4111
+
4112
+ _PN_CELL_REUSE_ID = "PNCell"
4113
+
4114
+ _SEL_ROW = _sel_reg(b"row")
4115
+ _SEL_DESELECT_ROW = _sel_reg(b"deselectRowAtIndexPath:animated:")
4116
+ _SEL_SET_DATA_SOURCE = _sel_reg(b"setDataSource:")
4117
+
4118
+ _TABLE_NUM_SECTIONS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
4119
+ _TABLE_NUM_ROWS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_long)
4120
+ _TABLE_HEIGHT_TYPE = _ct.CFUNCTYPE(_ct.c_double, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
4121
+ _TABLE_CELL_TYPE = _ct.CFUNCTYPE(_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
4122
+ _TABLE_DID_SELECT_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
4123
+ _TABLE_SCROLL_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
4124
+
4125
+
4126
+ def _table_row_of(ip_ptr: int) -> int:
4127
+ """Read ``[indexPath row]`` via raw msgSend (tagged-pointer safe)."""
4128
+ try:
4129
+ _objc_msgSend.restype = _ct.c_long
4130
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
4131
+ return int(_objc_msgSend(_ct.c_void_p(ip_ptr), _SEL_ROW))
4132
+ except Exception:
4133
+ return 0
4134
+
4135
+
4136
+ def _table_row_height(info: dict, row: int) -> float:
4137
+ heights = info.get("row_heights")
4138
+ if heights and 0 <= row < len(heights):
4139
+ return float(heights[row])
4140
+ return float(info.get("row_height", 44.0))
4141
+
4142
+
4143
+ def _table_row_start(info: dict, row: int) -> float:
4144
+ """Content-coordinate y where ``row`` begins."""
4145
+ starts = info.get("row_starts")
4146
+ if starts is not None and 0 <= row < len(starts):
4147
+ return float(starts[row])
4148
+ return float(info.get("row_height", 44.0)) * row
4149
+
4150
+
4151
+ def _table_set_heights(info: dict, heights: Optional[List[float]]) -> None:
4152
+ """Store per-row heights plus their prefix sums (for row starts)."""
4153
+ info["row_heights"] = heights
4154
+ if not heights:
4155
+ info["row_starts"] = None
4156
+ return
4157
+ starts = [0.0] * len(heights)
4158
+ acc = 0.0
4159
+ for i, h in enumerate(heights):
4160
+ starts[i] = acc
4161
+ acc += float(h)
4162
+ info["row_starts"] = starts
4163
+
4164
+
4165
+ def _table_num_sections_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int) -> int:
4166
+ return 1
4167
+
4168
+
4169
+ def _table_num_rows_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, section: int) -> int:
4170
+ info = _pn_table_state.get(int(self_ptr))
4171
+ return int(info.get("count", 0)) if info else 0
4172
+
4173
+
4174
+ def _table_height_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> float:
4175
+ info = _pn_table_state.get(int(self_ptr))
4176
+ if info is None:
4177
+ return 44.0
4178
+ return _table_row_height(info, _table_row_of(ip_ptr))
4179
+
4180
+
4181
+ def _table_cell_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> int:
4182
+ """Build (or rebind) a cell for ``tableView:cellForRowAtIndexPath:``.
4183
+
4184
+ ``ip_ptr`` is read raw via ``[indexPath row]`` to avoid the
4185
+ rubicon-objc tagged-pointer crash. The table view itself is a real
4186
+ heap object so wrapping it as an ObjCInstance is safe. Freshly
4187
+ allocated cells are retained explicitly so they survive the Python
4188
+ wrapper's release.
4189
+ """
4190
+ import traceback as _tb
4191
+
4192
+ row = _table_row_of(ip_ptr)
4193
+ try:
4194
+ UITableViewCell = ObjCClass("UITableViewCell")
4195
+ tv = ObjCInstance(_ct.c_void_p(tv_ptr))
4196
+ info = _pn_table_state.get(int(self_ptr))
4197
+ row_h = _table_row_height(info, row) if info else 44.0
4198
+
4199
+ try:
4200
+ cell_w = float(tv.bounds.size.width)
4201
+ except Exception:
4202
+ cell_w = 0.0
4203
+ if cell_w <= 0:
4204
+ try:
4205
+ cell_w = float(ObjCClass("UIScreen").mainScreen().bounds.size.width)
4206
+ except Exception:
4207
+ cell_w = 320.0
4208
+
4209
+ cell = tv.dequeueReusableCellWithIdentifier_(_PN_CELL_REUSE_ID)
4210
+ if cell is None or (hasattr(cell, "ptr") and cell.ptr.value == 0):
4211
+ cell = UITableViewCell.alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
4212
+ transparent = _uicolor("#00000000")
4213
+ cell.setBackgroundColor_(transparent)
4214
+ cell.contentView.setBackgroundColor_(transparent)
4215
+ cell.setSelectionStyle_(0)
4216
+ cell.retain() # offset the Python wrapper's release on __del__
4217
+
4218
+ try:
4219
+ cell.setFrame_(((0, 0), (cell_w, row_h)))
4220
+ cell.contentView.setFrame_(((0, 0), (cell_w, row_h)))
4221
+ except Exception:
4222
+ pass
4223
+
4224
+ content = cell.contentView
4225
+ try:
4226
+ existing_subs = content.subviews
4227
+ if callable(existing_subs):
4228
+ existing_subs = existing_subs()
4229
+ for sub in list(existing_subs or []):
4230
+ sub.removeFromSuperview()
4231
+ except Exception:
4232
+ pass
4233
+
4234
+ # Only rows in (or near) the visible content window get a
4235
+ # mounted subtree. UITableView's accessibility container calls
4236
+ # ``cellForRowAtIndexPath:`` for *every* row whenever an
4237
+ # accessibility client (VoiceOver, XCUITest / UI-test drivers)
4238
+ # snapshots the hierarchy, no matter the estimated heights.
4239
+ # Mounting those would defeat virtualization -- a 10k-row list
4240
+ # would mount 10k Python subtrees on the first snapshot -- and
4241
+ # the never-displayed cells would sit stacked at the table
4242
+ # origin where tests read them as visible. Off-window requests
4243
+ # get an empty accessibility-hidden placeholder; when the row
4244
+ # really scrolls on screen UIKit asks again and the content
4245
+ # window has moved, so the real subtree mounts.
4246
+ try:
4247
+ offset_y = float(tv.contentOffset.y)
4248
+ except Exception:
4249
+ offset_y = 0.0
4250
+ try:
4251
+ viewport_h = float(tv.bounds.size.height)
4252
+ except Exception:
4253
+ viewport_h = 0.0
4254
+ row_start = _table_row_start(info, row) if info else 0.0
4255
+ margin = viewport_h if viewport_h > 0 else 800.0
4256
+ in_window = (row_start + row_h >= offset_y - margin) and (
4257
+ row_start <= offset_y + max(viewport_h, row_h) + margin
4258
+ )
4259
+ try:
4260
+ cell.setAccessibilityElementsHidden_(not in_window)
4261
+ except Exception:
4262
+ pass
4263
+
4264
+ if in_window and info is not None:
4265
+ render_row = info.get("render_row")
4266
+ pool = info.get("pool")
4267
+ if render_row is not None and pool is not None:
4268
+ try:
4269
+ content_key = int(content.ptr.value)
4270
+ root = pool.bind(content_key, lambda: render_row(row), cell_w, row_h)
4271
+ if root is not None:
4272
+ content.addSubview_(root)
4273
+ # The layout engine frames only the *descendants*
4274
+ # of a subtree root (screen hosts normally size
4275
+ # the root themselves), so the row root would
4276
+ # otherwise keep its zero frame and render as
4277
+ # nothing. Fill the cell.
4278
+ try:
4279
+ root.setFrame_(((0, 0), (cell_w, row_h)))
4280
+ except Exception:
4281
+ pass
4282
+ except Exception:
4283
+ print(f"[VirtualList][iOS] mount for row={row} raised:")
4284
+ _tb.print_exc()
4285
+
4286
+ cell_ptr = cell.ptr.value
4287
+ return int(cell_ptr) if cell_ptr is not None else 0
4288
+ except Exception:
4289
+ print(f"[VirtualList][iOS] _table_cell_imp raised for row={row}:")
4290
+ _tb.print_exc()
4291
+ try:
4292
+ cell = ObjCClass("UITableViewCell").alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
4293
+ cell.retain()
4294
+ return int(cell.ptr.value)
4295
+ except Exception:
4296
+ return 0
4297
+
4298
+
4299
+ def _table_did_select_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> None:
4300
+ row = _table_row_of(ip_ptr)
4301
+ try:
4302
+ _objc_msgSend.restype = None
4303
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_bool]
4304
+ _objc_msgSend(_ct.c_void_p(tv_ptr), _SEL_DESELECT_ROW, _ct.c_void_p(ip_ptr), True)
4305
+ except Exception:
4306
+ pass
4307
+ _fire_ptr(int(tv_ptr or 0), "on_row_press", row)
4308
+
4309
+
4310
+ def _table_scroll_imp(self_ptr: int, cmd_ptr: int, sv_ptr: int) -> None:
4311
+ """Raw C callback for ``scrollViewDidScroll:`` on the table source."""
4312
+ info = _pn_table_state.get(int(self_ptr))
4313
+ if not info:
4314
+ return
4315
+ tv = info.get("tv")
4316
+ if tv is None:
4317
+ return
4318
+ try:
4319
+ offset = float(tv.contentOffset.y)
4320
+ extent = float(tv.bounds.size.height)
4321
+ content = float(tv.contentSize.height)
4322
+ except Exception:
4323
+ return
4324
+ _fire(tv, "on_scroll", {"x": 0.0, "y": offset, "extent": extent, "range": content})
4325
+
4326
+
4327
+ _table_num_sections_imp_ref = _TABLE_NUM_SECTIONS_TYPE(_table_num_sections_imp)
4328
+ _table_num_rows_imp_ref = _TABLE_NUM_ROWS_TYPE(_table_num_rows_imp)
4329
+ _table_height_imp_ref = _TABLE_HEIGHT_TYPE(_table_height_imp)
4330
+ _table_cell_imp_ref = _TABLE_CELL_TYPE(_table_cell_imp)
4331
+ _table_did_select_imp_ref = _TABLE_DID_SELECT_TYPE(_table_did_select_imp)
4332
+ _table_scroll_imp_ref = _TABLE_SCROLL_TYPE(_table_scroll_imp)
4333
+
4334
+ _PN_TABLE_SOURCE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTableSourceCTypes", 0)
4335
+ if _PN_TABLE_SOURCE_CLS:
4336
+ _add_method(
4337
+ _PN_TABLE_SOURCE_CLS,
4338
+ _sel_reg(b"numberOfSectionsInTableView:"),
4339
+ _ct.cast(_table_num_sections_imp_ref, _ct.c_void_p),
4340
+ b"q@:@",
4341
+ )
4342
+ _add_method(
4343
+ _PN_TABLE_SOURCE_CLS,
4344
+ _sel_reg(b"tableView:numberOfRowsInSection:"),
4345
+ _ct.cast(_table_num_rows_imp_ref, _ct.c_void_p),
4346
+ b"q@:@q",
4347
+ )
4348
+ _add_method(
4349
+ _PN_TABLE_SOURCE_CLS,
4350
+ _sel_reg(b"tableView:heightForRowAtIndexPath:"),
4351
+ _ct.cast(_table_height_imp_ref, _ct.c_void_p),
4352
+ b"d@:@@",
4353
+ )
4354
+ _add_method(
4355
+ _PN_TABLE_SOURCE_CLS,
4356
+ _sel_reg(b"tableView:cellForRowAtIndexPath:"),
4357
+ _ct.cast(_table_cell_imp_ref, _ct.c_void_p),
4358
+ b"@@:@@",
4359
+ )
4360
+ _add_method(
4361
+ _PN_TABLE_SOURCE_CLS,
4362
+ _sel_reg(b"tableView:didSelectRowAtIndexPath:"),
4363
+ _ct.cast(_table_did_select_imp_ref, _ct.c_void_p),
4364
+ b"v@:@@",
4365
+ )
4366
+ _add_method(
4367
+ _PN_TABLE_SOURCE_CLS,
4368
+ _sel_reg(b"scrollViewDidScroll:"),
4369
+ _ct.cast(_table_scroll_imp_ref, _ct.c_void_p),
4370
+ b"v@:@",
4371
+ )
4372
+ _reg_cls(_PN_TABLE_SOURCE_CLS)
4373
+
4374
+
4375
+ def _alloc_table_source_instance() -> int:
4376
+ """Allocate and retain a fresh ``_PNTableSourceCTypes`` instance.
4377
+
4378
+ Returns the raw pointer for the new dataSource. Callers must keep
4379
+ the pointer alive themselves; UITableView's dataSource and
4380
+ delegate relationships are non-retaining.
4381
+ """
4382
+ if not _PN_TABLE_SOURCE_CLS:
4383
+ raise RuntimeError("_PNTableSourceCTypes class registration failed")
4384
+ _objc_msgSend.restype = _ct.c_void_p
4385
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
4386
+ raw = _objc_msgSend(_PN_TABLE_SOURCE_CLS, _SEL_ALLOC)
4387
+ raw = _objc_msgSend(raw, _SEL_INIT)
4388
+ raw = _objc_msgSend(raw, _SEL_RETAIN)
4389
+ return int(raw) if raw is not None else 0
4390
+
4391
+
4392
+ class VirtualListHandler(IOSViewHandler):
4393
+ """Natively virtualized list backed by ``UITableView``.
4394
+
4395
+ Expects props:
4396
+
4397
+ - ``count``: total number of rows.
4398
+ - ``row_height``: uniform row extent in points, or
4399
+ - ``row_heights``: per-row extents.
4400
+ - ``render_row``: ``render_row(index) -> Element`` producing one
4401
+ row's subtree. Called lazily as rows enter the viewport.
4402
+ - ``shows_scroll_indicator``: hide the scroll bar when ``False``.
4403
+
4404
+ Events (dispatched by tag): ``on_row_press`` with the row index,
4405
+ ``on_scroll`` with ``{"x", "y", "extent", "range"}`` in points.
4406
+
4407
+ Commands: ``scroll_to_offset`` / ``scroll_to_index`` /
4408
+ ``scroll_to_end`` / ``get_scroll_offset``.
4409
+ """
4410
+
4411
+ def _build(self, props: Dict[str, Any]) -> Any:
4412
+ from ..virtual_rows import RowHostPool
4413
+
4414
+ tv = ObjCClass("UITableView").alloc().initWithFrame_style_(((0, 0), (0, 0)), 0)
4415
+ tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
4416
+ tv.setSeparatorStyle_(0) # none by default; rows draw their own
4417
+ try:
4418
+ # A *nonzero* estimate keeps UITableView lazy: with
4419
+ # ``estimatedRowHeight = 0`` modern UIKit builds cells for
4420
+ # every row up front to size its content, which mounts a
4421
+ # Python subtree per row (defeating virtualization) and
4422
+ # leaves the never-displayed cells visible to the
4423
+ # accessibility tree. ``heightForRowAtIndexPath:`` still
4424
+ # returns exact heights for the rows that do come on screen.
4425
+ heights = props.get("row_heights")
4426
+ if heights:
4427
+ estimate = sum(float(h) for h in heights) / max(1, len(heights))
4428
+ else:
4429
+ estimate = float(props.get("row_height", 44.0) or 44.0)
4430
+ tv.setEstimatedRowHeight_(max(1.0, estimate))
4431
+ except Exception:
4432
+ pass
4433
+ tv.retain()
4434
+ _pn_retained_views.append(tv)
4435
+
4436
+ source_ptr = _alloc_table_source_instance()
4437
+ if source_ptr == 0:
4438
+ raise RuntimeError("[VirtualList][iOS] dataSource alloc returned NULL")
4439
+
4440
+ state = {
4441
+ "count": int(props.get("count", 0) or 0),
4442
+ "row_height": float(props.get("row_height", 44.0) or 44.0),
4443
+ "render_row": props.get("render_row"),
4444
+ "pool": RowHostPool(),
4445
+ "tv": tv,
4446
+ }
4447
+ _table_set_heights(state, props.get("row_heights"))
4448
+ _pn_table_state[source_ptr] = state
4449
+
4450
+ _objc_msgSend.restype = None
4451
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
4452
+ tv_ptr = tv.ptr if hasattr(tv, "ptr") else tv
4453
+ _objc_msgSend(tv_ptr, _SEL_SET_DATA_SOURCE, _ct.c_void_p(source_ptr))
4454
+ _objc_msgSend(tv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(source_ptr))
4455
+
4456
+ state["source_ptr"] = source_ptr
4457
+ return tv
4458
+
4459
+ def _source_state(self, tv: Any) -> Optional[dict]:
4460
+ tv_ptr = _objc_ptr(tv)
4461
+ for info in _pn_table_state.values():
4462
+ candidate = info.get("tv")
4463
+ if candidate is not None and _objc_ptr(candidate) == tv_ptr:
4464
+ return info
4465
+ return None
4466
+
4467
+ def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
4468
+ _apply_common_visual(tv, props)
4469
+ info = self._source_state(tv)
4470
+ if info is None:
4471
+ return
4472
+ data_changed = False
4473
+ if "count" in props:
4474
+ info["count"] = int(props.get("count") or 0)
4475
+ data_changed = True
4476
+ if "row_height" in props and props["row_height"] is not None:
4477
+ info["row_height"] = float(props["row_height"])
4478
+ data_changed = True
4479
+ if "row_heights" in props:
4480
+ _table_set_heights(info, props.get("row_heights"))
4481
+ data_changed = True
4482
+ if "render_row" in props:
4483
+ info["render_row"] = props.get("render_row")
4484
+ data_changed = True
4485
+ if "shows_scroll_indicator" in props:
4486
+ visible = props["shows_scroll_indicator"] is not False
4487
+ try:
4488
+ tv.setShowsVerticalScrollIndicator_(visible)
4489
+ except Exception:
4490
+ pass
4491
+ if data_changed and not initial:
4492
+ try:
4493
+ tv.reloadData()
4494
+ except Exception:
4495
+ pass
4496
+
4497
+ def _teardown(self, tv: Any) -> None:
4498
+ info = self._source_state(tv)
4499
+ if info is None:
4500
+ return
4501
+ try:
4502
+ info["pool"].release_all()
4503
+ except Exception:
4504
+ pass
4505
+ info["tv"] = None
4506
+ _pn_table_state.pop(info.get("source_ptr"), None)
4507
+
4508
+ def measure_intrinsic(
4509
+ self,
4510
+ native_view: Any,
4511
+ max_width: float,
4512
+ max_height: float,
4513
+ ) -> Tuple[float, float]:
4514
+ # Fill the available space, like a ScrollView clamped to its
4515
+ # parent; collapse to 0 on unbounded axes (nested lists don't
4516
+ # scroll, matching React Native).
4517
+ w = max_width if math.isfinite(max_width) else 0.0
4518
+ h = max_height if math.isfinite(max_height) else 0.0
4519
+ return (max(0.0, w), max(0.0, h))
4520
+
4521
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
4522
+ if name == "scroll_to_offset":
4523
+ y = float(args.get("y", 0.0) or 0.0)
4524
+ animated = args.get("animated", True) is not False
4525
+ try:
4526
+ native_view.setContentOffset_animated_((0.0, y), animated)
4527
+ except Exception:
4528
+ pass
4529
+ return None
4530
+ if name == "scroll_to_index":
4531
+ index = int(args.get("index", 0) or 0)
4532
+ animated = args.get("animated", True) is not False
4533
+ info = self._source_state(native_view)
4534
+ count = int(info.get("count", 0)) if info else 0
4535
+ index = max(0, min(index, max(0, count - 1)))
4536
+ try:
4537
+ path = ObjCClass("NSIndexPath").indexPathForRow_inSection_(index, 0)
4538
+ # 1 = UITableViewScrollPositionTop
4539
+ native_view.scrollToRowAtIndexPath_atScrollPosition_animated_(path, 1, animated)
4540
+ except Exception:
4541
+ pass
4542
+ return None
4543
+ if name == "scroll_to_end":
4544
+ animated = args.get("animated", True) is not False
4545
+ try:
4546
+ content_h = float(native_view.contentSize.height)
4547
+ bounds_h = float(native_view.bounds.size.height)
4548
+ target = max(0.0, content_h - bounds_h)
4549
+ native_view.setContentOffset_animated_((0.0, target), animated)
4550
+ except Exception:
4551
+ pass
4552
+ return None
4553
+ if name == "get_scroll_offset":
4554
+ try:
4555
+ offset = native_view.contentOffset
4556
+ return {"x": float(offset.x), "y": float(offset.y)}
4557
+ except Exception:
4558
+ return {"x": 0.0, "y": 0.0}
4559
+ return None
4560
+
4561
+
3644
4562
  # ======================================================================
3645
4563
  # Registration
3646
4564
  # ======================================================================
@@ -3664,6 +4582,7 @@ def register_handlers(registry: Any) -> None:
3664
4582
  registry.register("ScrollView", ScrollViewHandler())
3665
4583
  registry.register("SafeAreaView", SafeAreaViewHandler())
3666
4584
  registry.register("Modal", ModalHandler())
4585
+ registry.register("Portal", PortalHandler())
3667
4586
  registry.register("Slider", SliderHandler())
3668
4587
  registry.register("TabBar", TabBarHandler())
3669
4588
  registry.register("Pressable", PressableHandler())
@@ -3673,6 +4592,7 @@ def register_handlers(registry: Any) -> None:
3673
4592
  registry.register("Checkbox", CheckboxHandler())
3674
4593
  registry.register("SegmentedControl", SegmentedControlHandler())
3675
4594
  registry.register("DatePicker", DatePickerHandler())
4595
+ registry.register("VirtualList", VirtualListHandler())
3676
4596
 
3677
4597
 
3678
4598
  __all__ = [
@@ -3690,6 +4610,7 @@ __all__ = [
3690
4610
  "SpacerHandler",
3691
4611
  "SafeAreaViewHandler",
3692
4612
  "ModalHandler",
4613
+ "PortalHandler",
3693
4614
  "SliderHandler",
3694
4615
  "TabBarHandler",
3695
4616
  "PressableHandler",
@@ -3699,5 +4620,6 @@ __all__ = [
3699
4620
  "CheckboxHandler",
3700
4621
  "SegmentedControlHandler",
3701
4622
  "DatePickerHandler",
4623
+ "VirtualListHandler",
3702
4624
  "register_handlers",
3703
4625
  ]