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.
- pythonnative/__init__.py +16 -2
- pythonnative/animated.py +6 -6
- pythonnative/appearance.py +124 -0
- pythonnative/cli/pn.py +1 -1
- pythonnative/components.py +718 -48
- pythonnative/events.py +5 -5
- pythonnative/gestures.py +3 -3
- pythonnative/hooks.py +44 -3
- pythonnative/hot_reload.py +4 -4
- pythonnative/images.py +196 -0
- pythonnative/layout.py +3 -3
- pythonnative/mutations.py +4 -13
- pythonnative/native_modules/camera.py +1 -1
- pythonnative/native_modules/haptics.py +2 -2
- pythonnative/native_modules/location.py +1 -1
- pythonnative/native_modules/permissions.py +2 -2
- pythonnative/native_modules/secure_store.py +1 -1
- pythonnative/native_views/__init__.py +1 -7
- pythonnative/native_views/android.py +592 -64
- pythonnative/native_views/base.py +3 -3
- pythonnative/native_views/desktop.py +85 -26
- pythonnative/native_views/ios.py +762 -74
- pythonnative/navigation.py +4 -4
- pythonnative/net.py +3 -3
- pythonnative/platform.py +1 -1
- pythonnative/platform_metrics.py +5 -5
- pythonnative/preview.py +34 -3
- pythonnative/project/builder.py +1 -1
- pythonnative/project/config.py +4 -12
- pythonnative/project/doctor.py +2 -2
- pythonnative/project/icons.py +1 -1
- pythonnative/project/ios.py +1 -1
- pythonnative/project/permissions.py +2 -2
- pythonnative/project/runtime_assets.py +3 -3
- pythonnative/reconciler.py +9 -9
- pythonnative/runtime.py +8 -8
- pythonnative/screen.py +90 -5
- pythonnative/sdk/_components.py +1 -1
- pythonnative/storage.py +3 -3
- pythonnative/style.py +66 -7
- 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/templates/ios_template/ios_template.xcodeproj/project.pbxproj +2 -2
- pythonnative/templates/ios_template/ios_templateUITests/ios_templateUITests.swift +1 -1
- pythonnative/virtual_rows.py +137 -0
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/METADATA +13 -13
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/RECORD +53 -47
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.22.0.dist-info → pythonnative-0.23.0.dist-info}/top_level.txt +0 -0
pythonnative/native_views/ios.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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)
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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)
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
# TextInput
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
]
|