pythonnative 0.22.1__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.
- pythonnative/__init__.py +16 -2
- pythonnative/appearance.py +124 -0
- pythonnative/components.py +708 -38
- pythonnative/hooks.py +41 -0
- pythonnative/images.py +196 -0
- pythonnative/mutations.py +3 -12
- pythonnative/native_views/__init__.py +1 -7
- pythonnative/native_views/android.py +573 -45
- pythonnative/native_views/desktop.py +78 -19
- pythonnative/native_views/ios.py +739 -51
- pythonnative/preview.py +31 -0
- pythonnative/project/config.py +1 -9
- pythonnative/screen.py +85 -0
- pythonnative/style.py +65 -6
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +2 -1
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNAccessibilityDelegate.kt +47 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNBorderDrawable.kt +67 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +172 -0
- pythonnative/virtual_rows.py +137 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/METADATA +1 -1
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/RECORD +25 -19
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.22.1.dist-info → pythonnative-0.23.0.dist-info}/top_level.txt +0 -0
pythonnative/native_views/ios.py
CHANGED
|
@@ -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
|
|
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 "
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
|
|
@@ -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(
|
|
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
|
|
1669
|
-
"""
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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
|
-
|
|
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
|
|
1689
|
-
if error is not None or data is None:
|
|
1690
|
-
return
|
|
1934
|
+
def on_ready(path: str) -> None:
|
|
1691
1935
|
try:
|
|
1692
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1710
|
-
|
|
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
|
|
@@ -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
|
]
|