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 @@ 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 (taps, text edits, scrolls, gestures)
11
+ this module; every interaction (taps, text edits, scrolls, gestures)
12
12
  is forwarded through
13
13
  [`dispatch_event`][pythonnative.events.dispatch_event] keyed by the
14
14
  view's reconciler-assigned tag.
@@ -171,7 +171,7 @@ def _has_event(view: Any, name: str) -> bool:
171
171
  # ======================================================================
172
172
  #
173
173
  # rubicon-objc's ``@objc_method`` FFI bridge is unreliable on iOS arm64
174
- # for some delegate callback shapes in particular when UIKit passes
174
+ # for some delegate callback shapes, in particular when UIKit passes
175
175
  # tagged pointers (e.g. NSIndexPath) or invokes selectors that return
176
176
  # objects, the FFI closure ends up in CPython's ``_ctypes.O_get`` and
177
177
  # crashes on bogus PyObject* dereferences.
@@ -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)
@@ -688,7 +817,7 @@ def _register_control_action(control: Any, events_mask: int, handler: Any) -> No
688
817
 
689
818
  The UIControl counterpart of ``_register_action``: control events
690
819
  (TouchUpInside, ValueChanged, ...) must not be delivered through
691
- rubicon's ``@objc_method`` bridge either the trampoline's ``sender``
820
+ rubicon's ``@objc_method`` bridge either; the trampoline's ``sender``
692
821
  marshaling is what crashed UISwitch toggles on iOS 18.x (the action
693
822
  fired, but touching the marshaled ``sender`` segfaulted). The raw IMP
694
823
  receives only the sender *pointer*; ``handler`` closures read any
@@ -763,7 +892,7 @@ def _make_gesture_handler(
763
892
  pass
764
893
  elif kind == "swipe":
765
894
  # Discrete: UIKit only calls us on recognition, and only the
766
- # recognizer whose direction matched fires so the bound
895
+ # recognizer whose direction matched fires, so the bound
767
896
  # per-recognizer direction is the actual swipe direction.
768
897
  payload["state"] = "ended"
769
898
  payload["direction"] = direction
@@ -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:
@@ -1154,7 +1286,7 @@ class IOSViewHandler(ViewHandler):
1154
1286
 
1155
1287
  ``decay`` (and any unknown kind) returns ``False`` so the Python
1156
1288
  ticker integrates the exact physics. Off-main-thread starts also
1157
- fall back UIKit animation APIs are main-thread-only.
1289
+ fall back; UIKit animation APIs are main-thread-only.
1158
1290
  """
1159
1291
  if native_view is None or not isinstance(spec, dict):
1160
1292
  return False
@@ -1190,7 +1322,7 @@ class IOSViewHandler(ViewHandler):
1190
1322
 
1191
1323
 
1192
1324
  class FlexContainerHandler(IOSViewHandler):
1193
- """Container for flex layout a bare `UIView`.
1325
+ """Container for flex layout, a bare `UIView`.
1194
1326
 
1195
1327
  All flex semantics (direction, alignment, distribution, padding)
1196
1328
  are computed by the layout engine and applied via
@@ -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
 
@@ -1416,7 +1637,7 @@ class ButtonHandler(IOSViewHandler):
1416
1637
 
1417
1638
  # ``scrollViewDidScroll:`` hands the delegate a ``UIScrollView*``. rubicon's
1418
1639
  # ``@objc_method`` FFI bridge is unreliable for delegate callbacks that take
1419
- # ObjC object arguments on arm64 (see the module header note) on the arm64
1640
+ # ObjC object arguments on arm64 (see the module header note); on the arm64
1420
1641
  # simulator the callback simply never reaches Python, so ``on_scroll`` would
1421
1642
  # silently never fire. Exactly like the UITabBar delegate, we therefore build
1422
1643
  # the delegate class with raw libobjc and dispatch through a CFUNCTYPE IMP.
@@ -1462,7 +1683,7 @@ if _PN_SCROLL_DELEGATE_CLS:
1462
1683
 
1463
1684
 
1464
1685
  class ScrollViewHandler(IOSViewHandler):
1465
- """Scroll container wraps a single child whose height is unbounded.
1686
+ """Scroll container: wraps a single child whose height is unbounded.
1466
1687
 
1467
1688
  The child is positioned by the layout engine using its natural
1468
1689
  content height. The shared frame applier expands the parent
@@ -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,58 +1890,110 @@ 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
- # TextInput raw libobjc target/delegate
1996
+ # TextInput: raw libobjc target/delegate
1717
1997
  # ----------------------------------------------------------------------
1718
1998
  #
1719
1999
  # UITextField control events and UITextField/UITextView delegate
@@ -1786,7 +2066,7 @@ def _tf_on_submit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
1786
2066
 
1787
2067
 
1788
2068
  def _tf_should_return_imp(self_ptr: int, _cmd: int, tf_ptr: int) -> bool:
1789
- """``textFieldShouldReturn:`` — dismiss the keyboard on Return.
2069
+ """Dismiss the keyboard on Return (``textFieldShouldReturn:``).
1790
2070
 
1791
2071
  iOS doesn't dismiss the keyboard on Return by default; the standard
1792
2072
  pattern is for the delegate to resign first responder and return
@@ -2444,7 +2724,7 @@ class ProgressBarHandler(IOSViewHandler):
2444
2724
 
2445
2725
 
2446
2726
  # ======================================================================
2447
- # WebView WKWebView with navigation + script-message delegates
2727
+ # WebView: WKWebView with navigation + script-message delegates
2448
2728
  # ======================================================================
2449
2729
 
2450
2730
  # WKWebView.scrollView isn't auto-detected as a property by rubicon, so it
@@ -2466,7 +2746,7 @@ def _webview_url(webview: Any) -> str:
2466
2746
  # WKNavigationDelegate + WKScriptMessageHandler bridge. WebKit passes
2467
2747
  # object arguments (``WKNavigation*`` / ``WKScriptMessage*``) to these
2468
2748
  # delegate callbacks, which rubicon's ``@objc_method`` FFI bridge
2469
- # mismarshals on iOS 18.x the app dies with EXC_BAD_ACCESS inside
2749
+ # mismarshals on iOS 18.x; the app dies with EXC_BAD_ACCESS inside
2470
2750
  # ``objc_msgSend`` (see the module header note). Like the scroll and
2471
2751
  # tab-bar delegates we therefore build the class with raw libobjc and
2472
2752
  # CFUNCTYPE IMPs, keep per-delegate state keyed by the delegate
@@ -2702,7 +2982,7 @@ class SafeAreaViewHandler(IOSViewHandler):
2702
2982
 
2703
2983
 
2704
2984
  # ======================================================================
2705
- # Modal actually presents a UIViewController
2985
+ # Modal: actually presents a UIViewController
2706
2986
  # ======================================================================
2707
2987
 
2708
2988
 
@@ -2767,7 +3047,7 @@ class ModalHandler(IOSViewHandler):
2767
3047
  buf.remove(child)
2768
3048
 
2769
3049
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2770
- # Modal is a virtual placeholder not rendered inline.
3050
+ # Modal is a virtual placeholder, not rendered inline.
2771
3051
  return
2772
3052
 
2773
3053
  def measure_intrinsic(
@@ -2858,7 +3138,7 @@ class ModalHandler(IOSViewHandler):
2858
3138
 
2859
3139
 
2860
3140
  # ======================================================================
2861
- # StatusBar global side effect, no view in the tree
3141
+ # StatusBar: global side effect, no view in the tree
2862
3142
  # ======================================================================
2863
3143
 
2864
3144
 
@@ -2903,7 +3183,7 @@ class StatusBarHandler(IOSViewHandler):
2903
3183
 
2904
3184
 
2905
3185
  # ======================================================================
2906
- # KeyboardAvoidingView publishes the keyboard height to Python
3186
+ # KeyboardAvoidingView: publishes the keyboard height to Python
2907
3187
  # ======================================================================
2908
3188
 
2909
3189
 
@@ -2976,7 +3256,7 @@ class KeyboardAvoidingViewHandler(IOSViewHandler):
2976
3256
 
2977
3257
 
2978
3258
  # ======================================================================
2979
- # TabBar UITabBar with a raw ctypes delegate
3259
+ # TabBar: UITabBar with a raw ctypes delegate
2980
3260
  # ======================================================================
2981
3261
  #
2982
3262
  # ``tabBar:didSelectItem:`` passes the UITabBarItem as an ObjC object;
@@ -3193,7 +3473,7 @@ def _present_alert(
3193
3473
  ) -> None:
3194
3474
  """Present a UIAlertController of the given style.
3195
3475
 
3196
- Safe to call from any thread the UIKit work is automatically
3476
+ Safe to call from any thread; the UIKit work is automatically
3197
3477
  marshalled to the main thread via
3198
3478
  [`pythonnative.runtime.call_on_main_thread`][pythonnative.runtime.call_on_main_thread].
3199
3479
  Returns immediately; the alert appears on the next main-loop tick.
@@ -3261,7 +3541,7 @@ def _present_alert(
3261
3541
 
3262
3542
 
3263
3543
  # ======================================================================
3264
- # Picker action-sheet dropdown
3544
+ # Picker: action-sheet dropdown
3265
3545
  # ======================================================================
3266
3546
  #
3267
3547
  # The PythonNative `Picker` renders as a `UIButton` whose tap presents
@@ -3304,7 +3584,7 @@ def _present_picker_sheet(btn: Any) -> None:
3304
3584
 
3305
3585
 
3306
3586
  class PickerHandler(IOSViewHandler):
3307
- """``Picker`` element handler native action-sheet dropdown."""
3587
+ """``Picker`` element handler, native action-sheet dropdown."""
3308
3588
 
3309
3589
  def _build(self, props: Dict[str, Any]) -> Any:
3310
3590
  btn = ObjCClass("UIButton").buttonWithType_(1) # UIButtonTypeSystem
@@ -3333,7 +3613,7 @@ class PickerHandler(IOSViewHandler):
3333
3613
 
3334
3614
 
3335
3615
  # ======================================================================
3336
- # Checkbox SF Symbol UIButton toggling checked / unchecked
3616
+ # Checkbox: SF Symbol UIButton toggling checked / unchecked
3337
3617
  # ======================================================================
3338
3618
 
3339
3619
 
@@ -3438,7 +3718,7 @@ class CheckboxHandler(IOSViewHandler):
3438
3718
 
3439
3719
 
3440
3720
  # ======================================================================
3441
- # SegmentedControl native UISegmentedControl
3721
+ # SegmentedControl: native UISegmentedControl
3442
3722
  # ======================================================================
3443
3723
 
3444
3724
 
@@ -3526,7 +3806,7 @@ class SegmentedControlHandler(IOSViewHandler):
3526
3806
 
3527
3807
 
3528
3808
  # ======================================================================
3529
- # DatePicker native UIDatePicker (compact style on iOS 13.4+)
3809
+ # DatePicker: native UIDatePicker (compact style on iOS 13.4+)
3530
3810
  # ======================================================================
3531
3811
 
3532
3812
 
@@ -3641,6 +3921,412 @@ class DatePickerHandler(IOSViewHandler):
3641
3921
  return (0.0, 0.0)
3642
3922
 
3643
3923
 
3924
+ # ======================================================================
3925
+ # VirtualList — UITableView-backed native virtualization
3926
+ # ======================================================================
3927
+ #
3928
+ # We register a raw libobjc class ``_PNTableSourceCTypes`` rather than
3929
+ # using rubicon-objc's ``@objc_method`` because UIKit invokes
3930
+ # ``tableView:cellForRowAtIndexPath:`` with a tagged-pointer
3931
+ # NSIndexPath that crashes inside CPython's ``_ctypes.O_get`` when
3932
+ # rubicon-objc's FFI closure tries to wrap it as a PyObject*.
3933
+ #
3934
+ # Each UITableView gets its own dataSource instance; per-instance state
3935
+ # lives in ``_pn_table_state`` keyed by the dataSource's raw pointer
3936
+ # (the integer passed as ``self`` to every IMP). Rows host nested
3937
+ # reconciler subtrees pooled per cell ``contentView`` (see
3938
+ # ``pythonnative.virtual_rows``); recycled cells rebind their existing
3939
+ # subtree instead of remounting, so steady scrolling reuses both the
3940
+ # native cell and the Python-side row tree.
3941
+
3942
+ _pn_table_state: Dict[int, dict] = {}
3943
+
3944
+ _PN_CELL_REUSE_ID = "PNCell"
3945
+
3946
+ _SEL_ROW = _sel_reg(b"row")
3947
+ _SEL_DESELECT_ROW = _sel_reg(b"deselectRowAtIndexPath:animated:")
3948
+ _SEL_SET_DATA_SOURCE = _sel_reg(b"setDataSource:")
3949
+
3950
+ _TABLE_NUM_SECTIONS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
3951
+ _TABLE_NUM_ROWS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_long)
3952
+ _TABLE_HEIGHT_TYPE = _ct.CFUNCTYPE(_ct.c_double, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
3953
+ _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)
3954
+ _TABLE_DID_SELECT_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
3955
+ _TABLE_SCROLL_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
3956
+
3957
+
3958
+ def _table_row_of(ip_ptr: int) -> int:
3959
+ """Read ``[indexPath row]`` via raw msgSend (tagged-pointer safe)."""
3960
+ try:
3961
+ _objc_msgSend.restype = _ct.c_long
3962
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
3963
+ return int(_objc_msgSend(_ct.c_void_p(ip_ptr), _SEL_ROW))
3964
+ except Exception:
3965
+ return 0
3966
+
3967
+
3968
+ def _table_row_height(info: dict, row: int) -> float:
3969
+ heights = info.get("row_heights")
3970
+ if heights and 0 <= row < len(heights):
3971
+ return float(heights[row])
3972
+ return float(info.get("row_height", 44.0))
3973
+
3974
+
3975
+ def _table_num_sections_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int) -> int:
3976
+ return 1
3977
+
3978
+
3979
+ def _table_num_rows_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, section: int) -> int:
3980
+ info = _pn_table_state.get(int(self_ptr))
3981
+ return int(info.get("count", 0)) if info else 0
3982
+
3983
+
3984
+ def _table_height_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> float:
3985
+ info = _pn_table_state.get(int(self_ptr))
3986
+ if info is None:
3987
+ return 44.0
3988
+ return _table_row_height(info, _table_row_of(ip_ptr))
3989
+
3990
+
3991
+ def _table_cell_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> int:
3992
+ """Build (or rebind) a cell for ``tableView:cellForRowAtIndexPath:``.
3993
+
3994
+ ``ip_ptr`` is read raw via ``[indexPath row]`` to avoid the
3995
+ rubicon-objc tagged-pointer crash. The table view itself is a real
3996
+ heap object so wrapping it as an ObjCInstance is safe. Freshly
3997
+ allocated cells are retained explicitly so they survive the Python
3998
+ wrapper's release.
3999
+ """
4000
+ import traceback as _tb
4001
+
4002
+ row = _table_row_of(ip_ptr)
4003
+ try:
4004
+ UITableViewCell = ObjCClass("UITableViewCell")
4005
+ tv = ObjCInstance(_ct.c_void_p(tv_ptr))
4006
+ info = _pn_table_state.get(int(self_ptr))
4007
+ row_h = _table_row_height(info, row) if info else 44.0
4008
+
4009
+ try:
4010
+ cell_w = float(tv.bounds.size.width)
4011
+ except Exception:
4012
+ cell_w = 0.0
4013
+ if cell_w <= 0:
4014
+ try:
4015
+ cell_w = float(ObjCClass("UIScreen").mainScreen().bounds.size.width)
4016
+ except Exception:
4017
+ cell_w = 320.0
4018
+
4019
+ cell = tv.dequeueReusableCellWithIdentifier_(_PN_CELL_REUSE_ID)
4020
+ if cell is None or (hasattr(cell, "ptr") and cell.ptr.value == 0):
4021
+ cell = UITableViewCell.alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
4022
+ transparent = _uicolor("#00000000")
4023
+ cell.setBackgroundColor_(transparent)
4024
+ cell.contentView.setBackgroundColor_(transparent)
4025
+ cell.setSelectionStyle_(0)
4026
+ cell.retain() # offset the Python wrapper's release on __del__
4027
+
4028
+ try:
4029
+ cell.setFrame_(((0, 0), (cell_w, row_h)))
4030
+ cell.contentView.setFrame_(((0, 0), (cell_w, row_h)))
4031
+ except Exception:
4032
+ pass
4033
+
4034
+ content = cell.contentView
4035
+ try:
4036
+ existing_subs = content.subviews
4037
+ if callable(existing_subs):
4038
+ existing_subs = existing_subs()
4039
+ for sub in list(existing_subs or []):
4040
+ sub.removeFromSuperview()
4041
+ except Exception:
4042
+ pass
4043
+
4044
+ if info is not None:
4045
+ render_row = info.get("render_row")
4046
+ pool = info.get("pool")
4047
+ if render_row is not None and pool is not None:
4048
+ try:
4049
+ content_key = int(content.ptr.value)
4050
+ root = pool.bind(content_key, lambda: render_row(row), cell_w, row_h)
4051
+ if root is not None:
4052
+ content.addSubview_(root)
4053
+ # The layout engine frames only the *descendants*
4054
+ # of a subtree root (screen hosts normally size
4055
+ # the root themselves), so the row root would
4056
+ # otherwise keep its zero frame and render as
4057
+ # nothing. Fill the cell.
4058
+ try:
4059
+ root.setFrame_(((0, 0), (cell_w, row_h)))
4060
+ except Exception:
4061
+ pass
4062
+ except Exception:
4063
+ print(f"[VirtualList][iOS] mount for row={row} raised:")
4064
+ _tb.print_exc()
4065
+
4066
+ cell_ptr = cell.ptr.value
4067
+ return int(cell_ptr) if cell_ptr is not None else 0
4068
+ except Exception:
4069
+ print(f"[VirtualList][iOS] _table_cell_imp raised for row={row}:")
4070
+ _tb.print_exc()
4071
+ try:
4072
+ cell = ObjCClass("UITableViewCell").alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
4073
+ cell.retain()
4074
+ return int(cell.ptr.value)
4075
+ except Exception:
4076
+ return 0
4077
+
4078
+
4079
+ def _table_did_select_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> None:
4080
+ row = _table_row_of(ip_ptr)
4081
+ try:
4082
+ _objc_msgSend.restype = None
4083
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_bool]
4084
+ _objc_msgSend(_ct.c_void_p(tv_ptr), _SEL_DESELECT_ROW, _ct.c_void_p(ip_ptr), True)
4085
+ except Exception:
4086
+ pass
4087
+ _fire_ptr(int(tv_ptr or 0), "on_row_press", row)
4088
+
4089
+
4090
+ def _table_scroll_imp(self_ptr: int, cmd_ptr: int, sv_ptr: int) -> None:
4091
+ """Raw C callback for ``scrollViewDidScroll:`` on the table source."""
4092
+ info = _pn_table_state.get(int(self_ptr))
4093
+ if not info:
4094
+ return
4095
+ tv = info.get("tv")
4096
+ if tv is None:
4097
+ return
4098
+ try:
4099
+ offset = float(tv.contentOffset.y)
4100
+ extent = float(tv.bounds.size.height)
4101
+ content = float(tv.contentSize.height)
4102
+ except Exception:
4103
+ return
4104
+ _fire(tv, "on_scroll", {"x": 0.0, "y": offset, "extent": extent, "range": content})
4105
+
4106
+
4107
+ _table_num_sections_imp_ref = _TABLE_NUM_SECTIONS_TYPE(_table_num_sections_imp)
4108
+ _table_num_rows_imp_ref = _TABLE_NUM_ROWS_TYPE(_table_num_rows_imp)
4109
+ _table_height_imp_ref = _TABLE_HEIGHT_TYPE(_table_height_imp)
4110
+ _table_cell_imp_ref = _TABLE_CELL_TYPE(_table_cell_imp)
4111
+ _table_did_select_imp_ref = _TABLE_DID_SELECT_TYPE(_table_did_select_imp)
4112
+ _table_scroll_imp_ref = _TABLE_SCROLL_TYPE(_table_scroll_imp)
4113
+
4114
+ _PN_TABLE_SOURCE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTableSourceCTypes", 0)
4115
+ if _PN_TABLE_SOURCE_CLS:
4116
+ _add_method(
4117
+ _PN_TABLE_SOURCE_CLS,
4118
+ _sel_reg(b"numberOfSectionsInTableView:"),
4119
+ _ct.cast(_table_num_sections_imp_ref, _ct.c_void_p),
4120
+ b"q@:@",
4121
+ )
4122
+ _add_method(
4123
+ _PN_TABLE_SOURCE_CLS,
4124
+ _sel_reg(b"tableView:numberOfRowsInSection:"),
4125
+ _ct.cast(_table_num_rows_imp_ref, _ct.c_void_p),
4126
+ b"q@:@q",
4127
+ )
4128
+ _add_method(
4129
+ _PN_TABLE_SOURCE_CLS,
4130
+ _sel_reg(b"tableView:heightForRowAtIndexPath:"),
4131
+ _ct.cast(_table_height_imp_ref, _ct.c_void_p),
4132
+ b"d@:@@",
4133
+ )
4134
+ _add_method(
4135
+ _PN_TABLE_SOURCE_CLS,
4136
+ _sel_reg(b"tableView:cellForRowAtIndexPath:"),
4137
+ _ct.cast(_table_cell_imp_ref, _ct.c_void_p),
4138
+ b"@@:@@",
4139
+ )
4140
+ _add_method(
4141
+ _PN_TABLE_SOURCE_CLS,
4142
+ _sel_reg(b"tableView:didSelectRowAtIndexPath:"),
4143
+ _ct.cast(_table_did_select_imp_ref, _ct.c_void_p),
4144
+ b"v@:@@",
4145
+ )
4146
+ _add_method(
4147
+ _PN_TABLE_SOURCE_CLS,
4148
+ _sel_reg(b"scrollViewDidScroll:"),
4149
+ _ct.cast(_table_scroll_imp_ref, _ct.c_void_p),
4150
+ b"v@:@",
4151
+ )
4152
+ _reg_cls(_PN_TABLE_SOURCE_CLS)
4153
+
4154
+
4155
+ def _alloc_table_source_instance() -> int:
4156
+ """Allocate and retain a fresh ``_PNTableSourceCTypes`` instance.
4157
+
4158
+ Returns the raw pointer for the new dataSource. Callers must keep
4159
+ the pointer alive themselves; UITableView's dataSource and
4160
+ delegate relationships are non-retaining.
4161
+ """
4162
+ if not _PN_TABLE_SOURCE_CLS:
4163
+ raise RuntimeError("_PNTableSourceCTypes class registration failed")
4164
+ _objc_msgSend.restype = _ct.c_void_p
4165
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
4166
+ raw = _objc_msgSend(_PN_TABLE_SOURCE_CLS, _SEL_ALLOC)
4167
+ raw = _objc_msgSend(raw, _SEL_INIT)
4168
+ raw = _objc_msgSend(raw, _SEL_RETAIN)
4169
+ return int(raw) if raw is not None else 0
4170
+
4171
+
4172
+ class VirtualListHandler(IOSViewHandler):
4173
+ """Natively virtualized list backed by ``UITableView``.
4174
+
4175
+ Expects props:
4176
+
4177
+ - ``count``: total number of rows.
4178
+ - ``row_height``: uniform row extent in points, or
4179
+ - ``row_heights``: per-row extents.
4180
+ - ``render_row``: ``render_row(index) -> Element`` producing one
4181
+ row's subtree. Called lazily as rows enter the viewport.
4182
+ - ``shows_scroll_indicator``: hide the scroll bar when ``False``.
4183
+
4184
+ Events (dispatched by tag): ``on_row_press`` with the row index,
4185
+ ``on_scroll`` with ``{"x", "y", "extent", "range"}`` in points.
4186
+
4187
+ Commands: ``scroll_to_offset`` / ``scroll_to_index`` /
4188
+ ``scroll_to_end`` / ``get_scroll_offset``.
4189
+ """
4190
+
4191
+ def _build(self, props: Dict[str, Any]) -> Any:
4192
+ from ..virtual_rows import RowHostPool
4193
+
4194
+ tv = ObjCClass("UITableView").alloc().initWithFrame_style_(((0, 0), (0, 0)), 0)
4195
+ tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
4196
+ tv.setSeparatorStyle_(0) # none by default; rows draw their own
4197
+ try:
4198
+ tv.setEstimatedRowHeight_(0.0) # force exact heightForRow
4199
+ except Exception:
4200
+ pass
4201
+ tv.retain()
4202
+ _pn_retained_views.append(tv)
4203
+
4204
+ source_ptr = _alloc_table_source_instance()
4205
+ if source_ptr == 0:
4206
+ raise RuntimeError("[VirtualList][iOS] dataSource alloc returned NULL")
4207
+
4208
+ _pn_table_state[source_ptr] = {
4209
+ "count": int(props.get("count", 0) or 0),
4210
+ "row_height": float(props.get("row_height", 44.0) or 44.0),
4211
+ "row_heights": props.get("row_heights"),
4212
+ "render_row": props.get("render_row"),
4213
+ "pool": RowHostPool(),
4214
+ "tv": tv,
4215
+ }
4216
+
4217
+ _objc_msgSend.restype = None
4218
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
4219
+ tv_ptr = tv.ptr if hasattr(tv, "ptr") else tv
4220
+ _objc_msgSend(tv_ptr, _SEL_SET_DATA_SOURCE, _ct.c_void_p(source_ptr))
4221
+ _objc_msgSend(tv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(source_ptr))
4222
+
4223
+ state = _pn_table_state[source_ptr]
4224
+ state["source_ptr"] = source_ptr
4225
+ return tv
4226
+
4227
+ def _source_state(self, tv: Any) -> Optional[dict]:
4228
+ tv_ptr = _objc_ptr(tv)
4229
+ for info in _pn_table_state.values():
4230
+ candidate = info.get("tv")
4231
+ if candidate is not None and _objc_ptr(candidate) == tv_ptr:
4232
+ return info
4233
+ return None
4234
+
4235
+ def _apply(self, tv: Any, props: Dict[str, Any], initial: bool) -> None:
4236
+ _apply_common_visual(tv, props)
4237
+ info = self._source_state(tv)
4238
+ if info is None:
4239
+ return
4240
+ data_changed = False
4241
+ if "count" in props:
4242
+ info["count"] = int(props.get("count") or 0)
4243
+ data_changed = True
4244
+ if "row_height" in props and props["row_height"] is not None:
4245
+ info["row_height"] = float(props["row_height"])
4246
+ data_changed = True
4247
+ if "row_heights" in props:
4248
+ info["row_heights"] = props.get("row_heights")
4249
+ data_changed = True
4250
+ if "render_row" in props:
4251
+ info["render_row"] = props.get("render_row")
4252
+ data_changed = True
4253
+ if "shows_scroll_indicator" in props:
4254
+ visible = props["shows_scroll_indicator"] is not False
4255
+ try:
4256
+ tv.setShowsVerticalScrollIndicator_(visible)
4257
+ except Exception:
4258
+ pass
4259
+ if data_changed and not initial:
4260
+ try:
4261
+ tv.reloadData()
4262
+ except Exception:
4263
+ pass
4264
+
4265
+ def _teardown(self, tv: Any) -> None:
4266
+ info = self._source_state(tv)
4267
+ if info is None:
4268
+ return
4269
+ try:
4270
+ info["pool"].release_all()
4271
+ except Exception:
4272
+ pass
4273
+ info["tv"] = None
4274
+ _pn_table_state.pop(info.get("source_ptr"), None)
4275
+
4276
+ def measure_intrinsic(
4277
+ self,
4278
+ native_view: Any,
4279
+ max_width: float,
4280
+ max_height: float,
4281
+ ) -> Tuple[float, float]:
4282
+ # Fill the available space, like a ScrollView clamped to its
4283
+ # parent; collapse to 0 on unbounded axes (nested lists don't
4284
+ # scroll, matching React Native).
4285
+ w = max_width if math.isfinite(max_width) else 0.0
4286
+ h = max_height if math.isfinite(max_height) else 0.0
4287
+ return (max(0.0, w), max(0.0, h))
4288
+
4289
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
4290
+ if name == "scroll_to_offset":
4291
+ y = float(args.get("y", 0.0) or 0.0)
4292
+ animated = args.get("animated", True) is not False
4293
+ try:
4294
+ native_view.setContentOffset_animated_((0.0, y), animated)
4295
+ except Exception:
4296
+ pass
4297
+ return None
4298
+ if name == "scroll_to_index":
4299
+ index = int(args.get("index", 0) or 0)
4300
+ animated = args.get("animated", True) is not False
4301
+ info = self._source_state(native_view)
4302
+ count = int(info.get("count", 0)) if info else 0
4303
+ index = max(0, min(index, max(0, count - 1)))
4304
+ try:
4305
+ path = ObjCClass("NSIndexPath").indexPathForRow_inSection_(index, 0)
4306
+ # 1 = UITableViewScrollPositionTop
4307
+ native_view.scrollToRowAtIndexPath_atScrollPosition_animated_(path, 1, animated)
4308
+ except Exception:
4309
+ pass
4310
+ return None
4311
+ if name == "scroll_to_end":
4312
+ animated = args.get("animated", True) is not False
4313
+ try:
4314
+ content_h = float(native_view.contentSize.height)
4315
+ bounds_h = float(native_view.bounds.size.height)
4316
+ target = max(0.0, content_h - bounds_h)
4317
+ native_view.setContentOffset_animated_((0.0, target), animated)
4318
+ except Exception:
4319
+ pass
4320
+ return None
4321
+ if name == "get_scroll_offset":
4322
+ try:
4323
+ offset = native_view.contentOffset
4324
+ return {"x": float(offset.x), "y": float(offset.y)}
4325
+ except Exception:
4326
+ return {"x": 0.0, "y": 0.0}
4327
+ return None
4328
+
4329
+
3644
4330
  # ======================================================================
3645
4331
  # Registration
3646
4332
  # ======================================================================
@@ -3673,6 +4359,7 @@ def register_handlers(registry: Any) -> None:
3673
4359
  registry.register("Checkbox", CheckboxHandler())
3674
4360
  registry.register("SegmentedControl", SegmentedControlHandler())
3675
4361
  registry.register("DatePicker", DatePickerHandler())
4362
+ registry.register("VirtualList", VirtualListHandler())
3676
4363
 
3677
4364
 
3678
4365
  __all__ = [
@@ -3699,5 +4386,6 @@ __all__ = [
3699
4386
  "CheckboxHandler",
3700
4387
  "SegmentedControlHandler",
3701
4388
  "DatePickerHandler",
4389
+ "VirtualListHandler",
3702
4390
  "register_handlers",
3703
4391
  ]