pythonnative 0.20.0__py3-none-any.whl → 0.22.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. pythonnative/__init__.py +14 -3
  2. pythonnative/animated.py +420 -135
  3. pythonnative/cli/pn.py +450 -956
  4. pythonnative/components.py +519 -235
  5. pythonnative/events.py +210 -0
  6. pythonnative/gestures.py +875 -0
  7. pythonnative/layout.py +463 -149
  8. pythonnative/mutations.py +130 -0
  9. pythonnative/native_views/__init__.py +161 -97
  10. pythonnative/native_views/android.py +1050 -1124
  11. pythonnative/native_views/base.py +108 -18
  12. pythonnative/native_views/desktop.py +460 -417
  13. pythonnative/native_views/ios.py +1918 -1916
  14. pythonnative/project/__init__.py +68 -0
  15. pythonnative/project/android.py +504 -0
  16. pythonnative/project/builder.py +555 -0
  17. pythonnative/project/config.py +642 -0
  18. pythonnative/project/doctor.py +233 -0
  19. pythonnative/project/icons.py +247 -0
  20. pythonnative/project/ios.py +344 -0
  21. pythonnative/project/permissions.py +343 -0
  22. pythonnative/project/runtime_assets.py +272 -0
  23. pythonnative/reconciler.py +540 -470
  24. pythonnative/screen.py +5 -2
  25. pythonnative/sdk/_components.py +2 -2
  26. pythonnative/templates/android_template/app/build.gradle +2 -0
  27. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
  28. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
  29. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
  30. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
  31. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
  32. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
@@ -6,14 +6,28 @@ frame application. Handlers are registered with the
6
6
  [`NativeViewRegistry`][pythonnative.native_views.NativeViewRegistry] by
7
7
  [`register_handlers`][pythonnative.native_views.ios.register_handlers].
8
8
 
9
- Layout is owned by the pure-Python flex engine in
10
- [`pythonnative.layout`][pythonnative.layout]: container handlers create
11
- plain `UIView`s, the engine computes per-child frames in points, and
9
+ **Batched protocol**: the registry applies the reconciler's mutation
10
+ ops; handlers receive callable-free props. User callbacks never reach
11
+ this module every interaction (taps, text edits, scrolls, gestures)
12
+ is forwarded through
13
+ [`dispatch_event`][pythonnative.events.dispatch_event] keyed by the
14
+ view's reconciler-assigned tag.
15
+
16
+ **Layout** is owned by the pure-Python flex engine in
17
+ `pythonnative.layout`: container handlers create plain `UIView`s, the
18
+ engine computes per-child frames in points, and
12
19
  [`set_frame`][pythonnative.native_views.ios.IOSViewHandler.set_frame]
13
20
  applies those frames via UIKit's classic ``frame`` property (with Auto
14
- Layout disabled). Handlers therefore only deal with *visual* props and
15
- ignore everything in
16
- [`pythonnative.layout.LAYOUT_STYLE_KEYS`][pythonnative.layout.LAYOUT_STYLE_KEYS].
21
+ Layout disabled).
22
+
23
+ **Gestures** attach real ``UIGestureRecognizer`` instances (pan, pinch,
24
+ rotation, tap, long-press, swipe) configured from the serialized
25
+ gesture specs, so recognition runs fully natively.
26
+
27
+ **Animations**: ``timing`` and ``spring`` specs are driven by UIKit
28
+ block animations with completion callbacks reported back through
29
+ ``pythonnative.animated.native_animation_completed``; ``decay`` falls
30
+ back to the Python ticker.
17
31
 
18
32
  This module is only imported on iOS at runtime. Desktop tests inject a
19
33
  mock registry via
@@ -26,8 +40,9 @@ import math
26
40
  import threading
27
41
  from typing import Any, Callable, Dict, List, Optional, Tuple
28
42
 
29
- from rubicon.objc import SEL, ObjCClass, objc_method
43
+ from rubicon.objc import SEL, ObjCClass, ObjCInstance, objc_method
30
44
 
45
+ from ..events import dispatch_event, event_names
31
46
  from . import _tripwire_log
32
47
  from .base import ViewHandler, _safe_max, parse_color_int
33
48
 
@@ -91,6 +106,66 @@ def _objc_ptr(obj: Any) -> Optional[int]:
91
106
  return None
92
107
 
93
108
 
109
+ # ======================================================================
110
+ # Tag table (ObjC pointer -> reconciler tag -> per-view state)
111
+ # ======================================================================
112
+
113
+ _view_tags: Dict[int, int] = {}
114
+ _view_state: Dict[int, Dict[str, Any]] = {}
115
+
116
+
117
+ def _remember(view: Any, tag: int) -> None:
118
+ ptr = _objc_ptr(view)
119
+ if ptr is not None:
120
+ _view_tags[ptr] = tag
121
+ _view_state[tag] = {"props": {}}
122
+
123
+
124
+ def _tag_of(view: Any) -> Optional[int]:
125
+ ptr = _objc_ptr(view)
126
+ if ptr is None:
127
+ return None
128
+ return _view_tags.get(ptr)
129
+
130
+
131
+ def _state_of(view: Any) -> Dict[str, Any]:
132
+ tag = _tag_of(view)
133
+ if tag is None:
134
+ return {}
135
+ return _view_state.setdefault(tag, {"props": {}})
136
+
137
+
138
+ def _forget(view: Any) -> None:
139
+ ptr = _objc_ptr(view)
140
+ if ptr is None:
141
+ return
142
+ tag = _view_tags.pop(ptr, None)
143
+ if tag is not None:
144
+ _view_state.pop(tag, None)
145
+
146
+
147
+ def _fire(view: Any, name: str, *args: Any) -> bool:
148
+ """Dispatch event ``name`` for ``view`` through the tag registry."""
149
+ tag = _tag_of(view)
150
+ if tag is None:
151
+ return False
152
+ return dispatch_event(tag, name, *args)
153
+
154
+
155
+ def _fire_ptr(view_ptr: int, name: str, *args: Any) -> bool:
156
+ """Dispatch event ``name`` for the raw ObjC pointer ``view_ptr``."""
157
+ tag = _view_tags.get(int(view_ptr or 0))
158
+ if tag is None:
159
+ return False
160
+ return dispatch_event(tag, name, *args)
161
+
162
+
163
+ def _has_event(view: Any, name: str) -> bool:
164
+ """Whether the element wired a callback named ``name`` this render."""
165
+ merged = _state_of(view).get("props") or {}
166
+ return name in event_names(merged)
167
+
168
+
94
169
  # ======================================================================
95
170
  # Raw libobjc helpers
96
171
  # ======================================================================
@@ -105,8 +180,8 @@ def _objc_ptr(obj: Any) -> Optional[int]:
105
180
  # new ObjC class via ``objc_allocateClassPair``, attach plain
106
181
  # CFUNCTYPE-wrapped Python functions as ``IMP``s, and dispatch via
107
182
  # ``objc_msgSend``. Every delegate that takes ObjC object arguments
108
- # beyond ``UITableView*`` / plain integers should use this pattern
109
- # (UITabBar's selection delegate and UITableView's data source both do).
183
+ # beyond plain integers should use this pattern (UITabBar's selection
184
+ # delegate and UIScrollView's scroll delegate both do).
110
185
 
111
186
  _libobjc = _ct.cdll.LoadLibrary("libobjc.A.dylib")
112
187
 
@@ -135,10 +210,7 @@ _SEL_ALLOC = _sel_reg(b"alloc")
135
210
  _SEL_INIT = _sel_reg(b"init")
136
211
  _SEL_RETAIN = _sel_reg(b"retain")
137
212
  _SEL_SET_DELEGATE = _sel_reg(b"setDelegate:")
138
- _SEL_SET_DATA_SOURCE = _sel_reg(b"setDataSource:")
139
213
  _SEL_TAG = _sel_reg(b"tag")
140
- _SEL_ROW = _sel_reg(b"row")
141
- _SEL_DESELECT_ROW = _sel_reg(b"deselectRowAtIndexPath:animated:")
142
214
  _SEL_TEXT = _sel_reg(b"text")
143
215
  _SEL_UTF8STRING = _sel_reg(b"UTF8String")
144
216
  _SEL_ADD_TARGET_ACTION_EVENTS = _sel_reg(b"addTarget:action:forControlEvents:")
@@ -150,6 +222,7 @@ _SEL_TEXT_FIELD_DID_BEGIN = _sel_reg(b"textFieldDidBeginEditing:")
150
222
  _SEL_TEXT_FIELD_DID_END = _sel_reg(b"textFieldDidEndEditing:")
151
223
  _SEL_TEXT_VIEW_DID_BEGIN = _sel_reg(b"textViewDidBeginEditing:")
152
224
  _SEL_TEXT_VIEW_DID_END = _sel_reg(b"textViewDidEndEditing:")
225
+ _SEL_TEXT_VIEW_DID_CHANGE = _sel_reg(b"textViewDidChange:")
153
226
 
154
227
  _NS_OBJECT_CLS = _get_cls(b"NSObject")
155
228
 
@@ -309,14 +382,7 @@ def _make_transform(spec: Any) -> Any:
309
382
  Each dict has exactly one of ``rotate`` (degrees), ``scale`` (uniform),
310
383
  ``scale_x``, ``scale_y``, ``translate_x``, ``translate_y``.
311
384
  """
312
- try:
313
- from rubicon.objc.api import objc_const # noqa: F401
314
- except Exception:
315
- pass
316
- # rubicon-objc doesn't expose CGAffineTransformIdentity directly;
317
- # we reconstruct it via the C struct.
318
385
  ct = _ct
319
- libc = ct.cdll.LoadLibrary("/usr/lib/libobjc.A.dylib") # noqa: F841
320
386
 
321
387
  class CGAffineTransform(ct.Structure):
322
388
  _fields_ = [
@@ -331,14 +397,10 @@ def _make_transform(spec: Any) -> Any:
331
397
  coregraphics = ct.cdll.LoadLibrary(
332
398
  "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
333
399
  )
334
- coregraphics.CGAffineTransformMakeIdentity = getattr(coregraphics, "CGAffineTransformMakeIdentity", None)
335
- coregraphics.CGAffineTransformRotate = coregraphics.CGAffineTransformRotate
336
400
  coregraphics.CGAffineTransformRotate.restype = CGAffineTransform
337
401
  coregraphics.CGAffineTransformRotate.argtypes = [CGAffineTransform, ct.c_double]
338
- coregraphics.CGAffineTransformScale = coregraphics.CGAffineTransformScale
339
402
  coregraphics.CGAffineTransformScale.restype = CGAffineTransform
340
403
  coregraphics.CGAffineTransformScale.argtypes = [CGAffineTransform, ct.c_double, ct.c_double]
341
- coregraphics.CGAffineTransformTranslate = coregraphics.CGAffineTransformTranslate
342
404
  coregraphics.CGAffineTransformTranslate.restype = CGAffineTransform
343
405
  coregraphics.CGAffineTransformTranslate.argtypes = [CGAffineTransform, ct.c_double, ct.c_double]
344
406
 
@@ -409,7 +471,7 @@ def _apply_transform(view: Any, props: Dict[str, Any]) -> None:
409
471
  # running.
410
472
  _tripwire_log(
411
473
  "set_transform:nan",
412
- f"[set_transform:nan] spec={spec!r} -> " f"(a={a!r}, b={b!r}, c={c!r}, d={d!r}, tx={tx!r}, ty={ty!r})",
474
+ f"[set_transform:nan] spec={spec!r} -> (a={a!r}, b={b!r}, c={c!r}, d={d!r}, tx={tx!r}, ty={ty!r})",
413
475
  )
414
476
  view.setTransform_((1.0, 0.0, 0.0, 1.0, 0.0, 0.0))
415
477
  return
@@ -481,149 +543,341 @@ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
481
543
  _apply_accessibility(view, props)
482
544
 
483
545
 
484
- # Properties that handlers can animate via
485
- # [`set_animated_property`][pythonnative.native_views.ios.IOSViewHandler.set_animated_property].
486
- _ANIMATABLE_PROPS = {
487
- "opacity",
488
- "translate_x",
489
- "translate_y",
490
- "scale",
491
- "scale_x",
492
- "scale_y",
493
- "rotate", # degrees
494
- "background_color",
495
- }
546
+ # ======================================================================
547
+ # Retention + shared targets
548
+ # ======================================================================
549
+
550
+ _pn_retained_views: list = []
496
551
 
497
552
 
498
553
  # ======================================================================
499
- # Base class with shared frame/measure implementations
554
+ # Simultaneous-recognition gesture delegate (raw libobjc)
500
555
  # ======================================================================
556
+ #
557
+ # All PythonNative recognizers on a view should recognize together
558
+ # (press feedback + pan + pinch...), matching the GestureArbiter's
559
+ # semantics on Android/desktop. UIKit defaults to exclusivity, so every
560
+ # recognizer we create gets this delegate, which answers YES to
561
+ # ``gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:``.
501
562
 
563
+ _GESTURE_SIMUL_TYPE = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
502
564
 
503
- class IOSViewHandler(ViewHandler):
504
- """Base class providing the shared `set_frame` / measure contract.
505
565
 
506
- All iOS handlers go through `set_frame` to apply the layout
507
- engine's computed frames via classic ``CGRect`` positioning (Auto
508
- Layout off). Child management defaults to UIKit's
509
- `addSubview_:` / `removeFromSuperview` API.
566
+ def _gesture_simul_imp(self_ptr: int, cmd_ptr: int, g1_ptr: int, g2_ptr: int) -> bool:
567
+ return True
568
+
569
+
570
+ _gesture_simul_imp_ref = _GESTURE_SIMUL_TYPE(_gesture_simul_imp)
571
+
572
+ _PN_GESTURE_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNGestureDelegateCTypes", 0)
573
+ if _PN_GESTURE_DELEGATE_CLS:
574
+ _add_method(
575
+ _PN_GESTURE_DELEGATE_CLS,
576
+ _sel_reg(b"gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer:"),
577
+ _ct.cast(_gesture_simul_imp_ref, _ct.c_void_p),
578
+ b"c@:@@",
579
+ )
580
+ _reg_cls(_PN_GESTURE_DELEGATE_CLS)
581
+
582
+ _pn_gesture_delegate_ptr: Any = None
583
+
584
+
585
+ def _shared_gesture_delegate_ptr() -> Any:
586
+ global _pn_gesture_delegate_ptr
587
+ if _pn_gesture_delegate_ptr is None and _PN_GESTURE_DELEGATE_CLS:
588
+ _objc_msgSend.restype = _ct.c_void_p
589
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
590
+ raw = _objc_msgSend(_PN_GESTURE_DELEGATE_CLS, _SEL_ALLOC)
591
+ raw = _objc_msgSend(raw, _SEL_INIT)
592
+ raw = _objc_msgSend(raw, _SEL_RETAIN)
593
+ _pn_gesture_delegate_ptr = raw
594
+ return _pn_gesture_delegate_ptr
595
+
596
+
597
+ def _set_recognizer_delegate(recognizer: Any) -> None:
598
+ delegate = _shared_gesture_delegate_ptr()
599
+ if delegate is None:
600
+ return
601
+ try:
602
+ _objc_msgSend.restype = None
603
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
604
+ rec_ptr = recognizer.ptr if hasattr(recognizer, "ptr") else recognizer
605
+ _objc_msgSend(rec_ptr, _SEL_SET_DELEGATE, delegate)
606
+ except Exception:
607
+ pass
608
+
609
+
610
+ # ======================================================================
611
+ # Native gesture wiring (UIGestureRecognizer -> dispatch_event)
612
+ # ======================================================================
613
+ #
614
+ # Recognizer *actions* must not go through rubicon's ``@objc_method``
615
+ # bridge: on iOS 18.x the action invocation dies inside UIKit/rubicon
616
+ # marshaling (``NSMapGet: map table argument is NULL``) and never
617
+ # reaches Python. Exactly like the scroll/tab-bar delegates, we route
618
+ # every action through one raw libobjc target class whose CFUNCTYPE IMP
619
+ # receives the recognizer *pointer* and looks up a Python closure keyed
620
+ # by that pointer. The closure then reads state/location off the
621
+ # retained rubicon recognizer object (outbound rubicon calls are fine).
622
+
623
+ # Maps recognizer ptr -> zero-arg Python handler closure.
624
+ _pn_action_handlers: Dict[int, Any] = {}
625
+
626
+ _ACTION_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
627
+
628
+
629
+ def _action_imp(_self_ptr: int, _cmd_ptr: int, sender_ptr: int) -> None:
630
+ """Raw C callback for every PythonNative recognizer action."""
631
+ handler = _pn_action_handlers.get(int(sender_ptr or 0))
632
+ if handler is None:
633
+ return
634
+ try:
635
+ handler()
636
+ except Exception:
637
+ pass
638
+
639
+
640
+ _action_imp_ref = _ACTION_IMP_TYPE(_action_imp)
641
+
642
+ _SEL_ON_ACTION = _sel_reg(b"onPNAction:")
643
+
644
+ _PN_ACTION_TARGET_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNActionTargetCTypes", 0)
645
+ if _PN_ACTION_TARGET_CLS:
646
+ _add_method(
647
+ _PN_ACTION_TARGET_CLS,
648
+ _SEL_ON_ACTION,
649
+ _ct.cast(_action_imp_ref, _ct.c_void_p),
650
+ b"v@:@",
651
+ )
652
+ _reg_cls(_PN_ACTION_TARGET_CLS)
653
+
654
+ _pn_action_target_ptr: Any = None
655
+
656
+
657
+ def _shared_action_target_ptr() -> Any:
658
+ global _pn_action_target_ptr
659
+ if _pn_action_target_ptr is None and _PN_ACTION_TARGET_CLS:
660
+ _objc_msgSend.restype = _ct.c_void_p
661
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
662
+ raw = _objc_msgSend(_PN_ACTION_TARGET_CLS, _SEL_ALLOC)
663
+ raw = _objc_msgSend(raw, _SEL_INIT)
664
+ raw = _objc_msgSend(raw, _SEL_RETAIN)
665
+ _pn_action_target_ptr = raw
666
+ return _pn_action_target_ptr
667
+
668
+
669
+ def _recognizer_ptr(rec: Any) -> int:
670
+ ptr = rec.ptr if hasattr(rec, "ptr") else rec
671
+ return int(getattr(ptr, "value", ptr) or 0)
672
+
673
+
674
+ def _register_action(rec: Any, handler: Any) -> None:
675
+ """Bind ``handler`` to ``rec`` via the shared raw action target."""
676
+ target_ptr = _shared_action_target_ptr()
677
+ if target_ptr is None:
678
+ return
679
+ _objc_msgSend.restype = None
680
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
681
+ rec_ptr = rec.ptr if hasattr(rec, "ptr") else rec
682
+ _objc_msgSend(rec_ptr, _sel_reg(b"addTarget:action:"), target_ptr, _SEL_ON_ACTION)
683
+ _pn_action_handlers[_recognizer_ptr(rec)] = handler
684
+
685
+
686
+ def _register_control_action(control: Any, events_mask: int, handler: Any) -> None:
687
+ """Bind ``handler`` to a ``UIControl`` event via the shared raw target.
688
+
689
+ The UIControl counterpart of ``_register_action``: control events
690
+ (TouchUpInside, ValueChanged, ...) must not be delivered through
691
+ rubicon's ``@objc_method`` bridge either — the trampoline's ``sender``
692
+ marshaling is what crashed UISwitch toggles on iOS 18.x (the action
693
+ fired, but touching the marshaled ``sender`` segfaulted). The raw IMP
694
+ receives only the sender *pointer*; ``handler`` closures read any
695
+ control state they need from the retained rubicon wrapper they
696
+ captured at wiring time (outbound rubicon calls are fine).
510
697
  """
698
+ target_ptr = _shared_action_target_ptr()
699
+ if target_ptr is None:
700
+ return
701
+ _objc_msgSend.restype = None
702
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_ulong]
703
+ ctl_ptr = control.ptr if hasattr(control, "ptr") else control
704
+ _objc_msgSend(ctl_ptr, _SEL_ADD_TARGET_ACTION_EVENTS, target_ptr, _SEL_ON_ACTION, events_mask)
705
+ _pn_action_handlers[_recognizer_ptr(control)] = handler
511
706
 
512
- def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
513
- if native_view is None:
707
+
708
+ # UIGestureRecognizerState -> GestureEvent.state
709
+ _GSTATE = {1: "began", 2: "changed", 3: "ended", 4: "cancelled", 5: "cancelled"}
710
+
711
+ _SWIPE_DIRECTIONS = {"right": 1, "left": 2, "up": 4, "down": 8}
712
+
713
+
714
+ def _make_gesture_handler(
715
+ rec: Any,
716
+ view: Any,
717
+ kind: str,
718
+ index: int,
719
+ direction: Optional[str] = None,
720
+ ) -> Any:
721
+ """Build the action closure emitting one ``gesture:<i>`` payload."""
722
+
723
+ def handler() -> None:
724
+ try:
725
+ raw_state = int(rec.state)
726
+ except Exception:
727
+ raw_state = 3
728
+ state = _GSTATE.get(raw_state)
729
+ if state is None:
514
730
  return
731
+
732
+ payload: Dict[str, Any] = {"kind": kind, "state": state}
515
733
  try:
516
- frame_x = _safe_finite(x, 0.0)
517
- frame_y = _safe_finite(y, 0.0)
518
- frame_w = max(0.0, _safe_finite(width, 0.0))
519
- frame_h = max(0.0, _safe_finite(height, 0.0))
520
- native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
521
- native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
522
- _clamp_view_corner_radius(native_view, frame_w, frame_h)
734
+ location = rec.locationInView_(view)
735
+ payload["x"] = float(location.x)
736
+ payload["y"] = float(location.y)
737
+ except Exception:
738
+ pass
739
+ try:
740
+ payload["pointer_count"] = int(rec.numberOfTouches)
741
+ except Exception:
742
+ pass
743
+
744
+ if kind == "pan":
523
745
  try:
524
- _clamp_layer_corner_radius(native_view.layer, frame_w, frame_h)
746
+ translation = rec.translationInView_(view)
747
+ payload["translation_x"] = float(translation.x)
748
+ payload["translation_y"] = float(translation.y)
749
+ velocity = rec.velocityInView_(view)
750
+ payload["velocity_x"] = float(velocity.x)
751
+ payload["velocity_y"] = float(velocity.y)
525
752
  except Exception:
526
753
  pass
754
+ elif kind == "pinch":
527
755
  try:
528
- parent = native_view.superview
529
- parent_cls = ""
530
- try:
531
- parent_cls = str(parent.objc_class.name) if parent is not None else ""
532
- except Exception:
533
- parent_cls = ""
534
- # Expand the parent UIScrollView's contentSize whenever a
535
- # child's frame extends past the visible bounds, so the
536
- # scroll view can actually scroll to reveal it.
537
- if "UIScrollView" in parent_cls:
538
- bounds = parent.bounds
539
- content_w = max(float(bounds.size.width), frame_x + frame_w)
540
- content_h = max(float(bounds.size.height), frame_y + frame_h)
541
- parent.setContentSize_((content_w, content_h))
756
+ payload["scale"] = float(rec.scale)
757
+ except Exception:
758
+ pass
759
+ elif kind == "rotation":
760
+ try:
761
+ payload["rotation"] = float(rec.rotation)
542
762
  except Exception:
543
763
  pass
764
+ elif kind == "swipe":
765
+ # Discrete: UIKit only calls us on recognition, and only the
766
+ # recognizer whose direction matched fires — so the bound
767
+ # per-recognizer direction is the actual swipe direction.
768
+ payload["state"] = "ended"
769
+ payload["direction"] = direction
770
+ elif kind == "tap":
771
+ payload["state"] = "ended"
772
+
773
+ _fire(view, f"gesture:{index}", payload)
774
+
775
+ return handler
776
+
777
+
778
+ def _make_recognizer(kind: str, spec: Dict[str, Any]) -> List[Tuple[Any, Optional[str]]]:
779
+ """Build the UIGestureRecognizer(s) for one serialized gesture spec.
780
+
781
+ Swipe with ``direction="any"`` needs one recognizer per direction
782
+ (UIKit constraint), so this returns ``(recognizer, direction)``
783
+ pairs; ``direction`` is ``None`` for non-swipe kinds.
784
+ """
785
+ out: List[Tuple[Any, Optional[str]]] = []
786
+ if kind == "tap":
787
+ rec = ObjCClass("UITapGestureRecognizer").alloc().init()
788
+ try:
789
+ rec.setNumberOfTapsRequired_(max(1, int(spec.get("n_taps", 1))))
544
790
  except Exception:
545
791
  pass
546
-
547
- def measure_intrinsic(
548
- self,
549
- native_view: Any,
550
- max_width: float,
551
- max_height: float,
552
- ) -> Tuple[float, float]:
792
+ out.append((rec, None))
793
+ elif kind == "long_press":
794
+ rec = ObjCClass("UILongPressGestureRecognizer").alloc().init()
553
795
  try:
554
- mw = _safe_max(max_width, fallback=10000.0)
555
- mh = _safe_max(max_height, fallback=10000.0)
556
- size = native_view.sizeThatFits_((mw, mh))
557
- w = float(size.width)
558
- h = float(size.height)
559
- if math.isfinite(max_width):
560
- w = min(w, max_width)
561
- return (w, h)
796
+ rec.setMinimumPressDuration_(float(spec.get("min_duration_ms", 500.0)) / 1000.0)
797
+ rec.setAllowableMovement_(float(spec.get("max_distance", 12.0)))
562
798
  except Exception:
563
- return (0.0, 0.0)
564
-
565
- def set_animated_property(
566
- self,
567
- native_view: Any,
568
- prop_name: str,
569
- value: Any,
570
- duration_ms: float = 0.0,
571
- easing: str = "linear",
572
- ) -> None:
573
- """Apply ``prop_name`` to ``native_view`` immediately or animated.
574
-
575
- Used by ``Animated.View`` to bypass the reconciler when the
576
- bound `Animated.Value` ticks. When
577
- ``duration_ms > 0``, the change is wrapped in
578
- ``UIView.animate(withDuration:)`` so UIKit interpolates between
579
- the current and target value at 60 FPS without further Python
580
- involvement.
581
-
582
- Args:
583
- native_view: The target ``UIView``-derived instance.
584
- prop_name: One of ``opacity``, ``translate_x``,
585
- ``translate_y``, ``scale``, ``scale_x``, ``scale_y``,
586
- ``rotate`` (degrees), ``background_color``.
587
- value: The new property value.
588
- duration_ms: Optional UIKit animation duration in ms; ``0``
589
- applies the change immediately.
590
- easing: Easing curve name (``linear``, ``ease_in``,
591
- ``ease_out``, ``ease_in_out``).
592
- """
593
- if native_view is None:
594
- return
799
+ pass
800
+ out.append((rec, None))
801
+ elif kind == "pan":
802
+ rec = ObjCClass("UIPanGestureRecognizer").alloc().init()
595
803
  try:
596
- applier = _animated_applier_for(prop_name, value)
804
+ rec.setMinimumNumberOfTouches_(max(1, int(spec.get("min_pointers", 1))))
597
805
  except Exception:
598
- return
599
- if applier is None:
600
- return
601
- if duration_ms <= 0:
806
+ pass
807
+ out.append((rec, None))
808
+ elif kind == "swipe":
809
+ direction = str(spec.get("direction", "any"))
810
+ directions = [direction] if direction in _SWIPE_DIRECTIONS else list(_SWIPE_DIRECTIONS)
811
+ for d in directions:
812
+ rec = ObjCClass("UISwipeGestureRecognizer").alloc().init()
602
813
  try:
603
- applier(native_view)
814
+ rec.setDirection_(_SWIPE_DIRECTIONS[d])
604
815
  except Exception:
605
816
  pass
606
- return
817
+ out.append((rec, d))
818
+ elif kind == "pinch":
819
+ out.append((ObjCClass("UIPinchGestureRecognizer").alloc().init(), None))
820
+ elif kind == "rotation":
821
+ out.append((ObjCClass("UIRotationGestureRecognizer").alloc().init(), None))
822
+ return out
823
+
824
+
825
+ def _wire_gestures(view: Any, specs: Any) -> None:
826
+ """Attach native recognizers for the serialized gesture specs.
827
+
828
+ Re-wiring on update removes previously attached PythonNative
829
+ recognizers first (configuration changes are rare; correctness
830
+ beats incremental patching here).
831
+ """
832
+ state = _state_of(view)
833
+ for rec in state.get("gesture_recognizers") or []:
607
834
  try:
608
- UIView = ObjCClass("UIView")
609
- options = {
610
- "linear": 1 << 16,
611
- "ease_in": 1 << 17,
612
- "ease_out": 1 << 18,
613
- "ease_in_out": 0,
614
- }.get(easing, 0)
615
- UIView.animateWithDuration_delay_options_animations_completion_(
616
- duration_ms / 1000.0,
617
- 0.0,
618
- options,
619
- lambda: applier(native_view),
620
- None,
621
- )
835
+ view.removeGestureRecognizer_(rec)
622
836
  except Exception:
837
+ pass
838
+ _pn_action_handlers.pop(_recognizer_ptr(rec), None)
839
+ state["gesture_recognizers"] = []
840
+ if not isinstance(specs, (list, tuple)) or not specs:
841
+ return
842
+
843
+ try:
844
+ view.setUserInteractionEnabled_(True)
845
+ except Exception:
846
+ pass
847
+
848
+ recognizers: List[Any] = []
849
+ for i, spec in enumerate(specs):
850
+ if not isinstance(spec, dict):
851
+ continue
852
+ kind = str(spec.get("kind", ""))
853
+ if not kind:
854
+ continue
855
+ for rec, direction in _make_recognizer(kind, spec):
623
856
  try:
624
- applier(native_view)
857
+ rec.setCancelsTouchesInView_(False)
625
858
  except Exception:
626
859
  pass
860
+ _set_recognizer_delegate(rec)
861
+ try:
862
+ view.addGestureRecognizer_(rec)
863
+ rec.retain()
864
+ recognizers.append(rec)
865
+ except Exception:
866
+ continue
867
+ _register_action(rec, _make_gesture_handler(rec, view, kind, i, direction))
868
+
869
+ state["gesture_recognizers"] = recognizers
870
+
871
+
872
+ # ======================================================================
873
+ # Native-driven animations (UIView block animations)
874
+ # ======================================================================
875
+
876
+ _native_anims: Dict[int, Dict[str, Any]] = {}
877
+
878
+
879
+ def _is_main_thread() -> bool:
880
+ return threading.current_thread() is threading.main_thread()
627
881
 
628
882
 
629
883
  def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], None]]:
@@ -641,7 +895,7 @@ def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], Non
641
895
 
642
896
  return _apply
643
897
  if prop in ("translate_x", "translate_y", "scale", "scale_x", "scale_y", "rotate"):
644
- spec = {prop: value} if prop != "rotate" else {"rotate": value}
898
+ spec = {prop: value}
645
899
 
646
900
  def _apply(view: Any) -> None:
647
901
  _apply_transform(view, {"transform": [spec]})
@@ -650,355 +904,284 @@ def _animated_applier_for(prop: str, value: Any) -> Optional[Callable[[Any], Non
650
904
  return None
651
905
 
652
906
 
653
- # ======================================================================
654
- # ObjC callback targets (retained at module level)
655
- # ======================================================================
907
+ def _spring_parameters(spec: Dict[str, Any]) -> Tuple[float, float, float]:
908
+ """Translate physics params into UIKit's (duration, damping_ratio, velocity).
656
909
 
657
- _pn_btn_handler_map: dict = {}
658
- _pn_btn_callback_map: dict = {}
659
- _pn_retained_views: list = []
910
+ UIKit's spring API takes a damping *ratio* (0..1) and a velocity
911
+ normalized to the total travel distance. We derive both from the
912
+ stiffness/damping/mass spec and approximate the settle duration
913
+ from the envelope decay.
914
+ """
915
+ stiffness = max(1e-3, float(spec.get("stiffness", 100.0)))
916
+ damping = max(1e-3, float(spec.get("damping", 10.0)))
917
+ mass = max(1e-3, float(spec.get("mass", 1.0)))
918
+ omega0 = math.sqrt(stiffness / mass)
919
+ zeta = damping / (2.0 * math.sqrt(stiffness * mass))
920
+ damping_ratio = max(0.05, min(1.0, zeta))
921
+ if zeta < 1.0:
922
+ duration = min(10.0, max(0.15, 4.0 / max(0.05, zeta * omega0)))
923
+ else:
924
+ duration = min(10.0, max(0.15, 4.0 / omega0))
925
+ distance = abs(float(spec.get("to", 0.0)) - float(spec.get("from", 0.0)))
926
+ v0 = float(spec.get("initial_velocity", 0.0))
927
+ norm_velocity = (v0 / distance) if distance > 1e-9 else 0.0
928
+ return duration, damping_ratio, norm_velocity
929
+
930
+
931
+ _EASING_OPTIONS = {
932
+ "linear": 1 << 16, # UIViewAnimationOptionCurveLinear
933
+ "ease_in": 1 << 17,
934
+ "ease_out": 1 << 18,
935
+ "ease_in_out": 0,
936
+ "ease": 0,
937
+ }
660
938
 
661
939
 
662
- class _PNButtonTarget(NSObject): # type: ignore[valid-type]
663
- @objc_method
664
- def onTap_(self, sender: object) -> None:
665
- # Do not introspect ``sender`` here. On rubicon-objc 0.5.x the
666
- # selector trampoline can hand this callback a raw ObjC pointer;
667
- # calling ``getattr(sender, "ptr", ...)`` has been observed to
668
- # segfault before the user's callback runs.
669
- cb = _pn_btn_callback_map.get(id(self))
670
- if cb is not None:
671
- cb()
672
-
673
-
674
- _pn_tf_change_callback_map: dict = {}
675
- _pn_tf_submit_callback_map: dict = {}
676
- _pn_tf_focus_callback_map: dict = {}
677
- _pn_tf_blur_callback_map: dict = {}
678
- _pn_tf_raw_target_map: dict = {}
679
- _pn_tv_raw_target_map: dict = {}
680
- _PN_TEXTFIELD_TARGET_CLS: Optional[int] = None
681
- _textfield_edit_imp_ref: Any = None
682
- _textfield_submit_imp_ref: Any = None
683
- _textfield_should_return_imp_ref: Any = None
684
- _textfield_did_begin_imp_ref: Any = None
685
- _textfield_did_end_imp_ref: Any = None
940
+ def _start_native_animation(view: Any, anim_id: int, prop_name: str, spec: Dict[str, Any]) -> bool:
941
+ """Run a ``timing`` / ``spring`` spec as a UIView block animation."""
942
+ applier_from = _animated_applier_for(prop_name, spec.get("from"))
943
+ applier_to = _animated_applier_for(prop_name, spec.get("to"))
944
+ if applier_to is None:
945
+ return False
946
+ UIView = ObjCClass("UIView")
947
+ kind = str(spec.get("kind", "timing"))
686
948
 
949
+ def _completion(finished: bool) -> None:
950
+ _native_anims.pop(anim_id, None)
951
+ try:
952
+ from ..animated import native_animation_completed
687
953
 
688
- def _textfield_text(sender_ptr: int) -> str:
689
- if not sender_ptr:
690
- return ""
691
- try:
692
- _objc_msgSend.restype = _ct.c_void_p
693
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
694
- nsstring_ptr = _objc_msgSend(_ct.c_void_p(sender_ptr), _SEL_TEXT)
695
- if not nsstring_ptr:
696
- return ""
697
- _objc_msgSend.restype = _ct.c_char_p
698
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
699
- raw = _objc_msgSend(_ct.c_void_p(nsstring_ptr), _SEL_UTF8STRING)
700
- if not raw:
701
- return ""
702
- return raw.decode("utf-8", errors="replace")
703
- except Exception:
704
- return ""
954
+ native_animation_completed(anim_id, bool(finished))
955
+ except Exception:
956
+ pass
705
957
 
958
+ def _animations() -> None:
959
+ try:
960
+ applier_to(view)
961
+ except Exception:
962
+ pass
706
963
 
707
- def _textfield_on_edit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
708
- cb = _pn_tf_change_callback_map.get(int(self_ptr))
709
- if cb is None:
710
- return
711
- text = _textfield_text(int(sender_ptr or 0))
712
964
  try:
713
- cb(text)
965
+ # Snap to the starting value so the animation covers the full
966
+ # declared range even if the view was somewhere else.
967
+ if applier_from is not None:
968
+ applier_from(view)
969
+ _native_anims[anim_id] = {"view": view, "prop": prop_name}
970
+ if kind == "spring":
971
+ duration, damping_ratio, velocity = _spring_parameters(spec)
972
+ UIView.animateWithDuration_delay_usingSpringWithDamping_initialSpringVelocity_options_animations_completion_(
973
+ duration,
974
+ 0.0,
975
+ damping_ratio,
976
+ velocity,
977
+ 1 << 1, # AllowUserInteraction
978
+ _animations,
979
+ _completion,
980
+ )
981
+ else:
982
+ duration = max(0.0, float(spec.get("duration_ms", 300.0))) / 1000.0
983
+ options = _EASING_OPTIONS.get(str(spec.get("easing", "ease_in_out")), 0) | (1 << 1)
984
+ UIView.animateWithDuration_delay_options_animations_completion_(
985
+ duration,
986
+ float(spec.get("delay_ms", 0.0) or 0.0) / 1000.0,
987
+ options,
988
+ _animations,
989
+ _completion,
990
+ )
991
+ return True
714
992
  except Exception:
715
- pass
716
-
993
+ _native_anims.pop(anim_id, None)
994
+ return False
717
995
 
718
- def _textfield_on_submit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
719
- cb = _pn_tf_submit_callback_map.get(int(self_ptr))
720
- if cb is None:
721
- return
722
- text = _textfield_text(int(sender_ptr or 0))
723
- try:
724
- cb(text)
725
- except Exception:
726
- pass
727
996
 
997
+ # ======================================================================
998
+ # Base class with shared frame/measure/animation implementations
999
+ # ======================================================================
728
1000
 
729
- def _textfield_should_return_imp(self_ptr: int, _cmd: int, tf_ptr: int) -> bool:
730
- """``UITextFieldDelegate.textFieldShouldReturn:`` — dismiss the keyboard.
731
1001
 
732
- iOS doesn't dismiss the keyboard on Return by default; the standard
733
- pattern is for the delegate to call ``resignFirstResponder`` and
734
- return ``YES``. Matching that here brings PythonNative's
735
- ``TextInput`` in line with React Native's default behavior and with
736
- what users expect from a ``return_key_type="done"`` style.
1002
+ class IOSViewHandler(ViewHandler):
1003
+ """Base class providing the shared protocol implementation.
1004
+
1005
+ Subclasses implement
1006
+ [`_build`][pythonnative.native_views.ios.IOSViewHandler._build]
1007
+ (construct the UIKit view) and
1008
+ [`_apply`][pythonnative.native_views.ios.IOSViewHandler._apply]
1009
+ (apply visual props); the base class owns tag registration,
1010
+ gesture wiring, frame application via classic ``CGRect``
1011
+ positioning (Auto Layout off), intrinsic measurement via
1012
+ ``sizeThatFits:``, and the animation hooks.
737
1013
  """
738
- try:
739
- _objc_msgSend.restype = None
740
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
741
- _objc_msgSend(_ct.c_void_p(int(tf_ptr or 0)), _SEL_RESIGN_FIRST_RESPONDER)
742
- except Exception:
743
- pass
744
- return True
745
1014
 
1015
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
1016
+ view = self._build(props)
1017
+ _remember(view, tag)
1018
+ _state_of(view)["props"] = dict(props)
1019
+ self._apply(view, props, initial=True)
1020
+ if props.get("gestures"):
1021
+ _wire_gestures(view, props.get("gestures"))
1022
+ return view
1023
+
1024
+ def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
1025
+ state = _state_of(native_view)
1026
+ merged = state.setdefault("props", {})
1027
+ merged.update(changed_props)
1028
+ self._apply(native_view, changed_props, initial=False)
1029
+ if "gestures" in changed_props:
1030
+ _wire_gestures(native_view, changed_props.get("gestures"))
1031
+
1032
+ def destroy(self, native_view: Any) -> None:
1033
+ self._teardown(native_view)
1034
+ state = _state_of(native_view)
1035
+ for rec in state.get("gesture_recognizers") or []:
1036
+ _pn_action_handlers.pop(_recognizer_ptr(rec), None)
1037
+ # Controls register their own pointer as the action-handler key
1038
+ # (see _register_control_action); drop it with the view.
1039
+ _pn_action_handlers.pop(_recognizer_ptr(native_view), None)
1040
+ try:
1041
+ native_view.removeFromSuperview()
1042
+ except Exception:
1043
+ pass
1044
+ _forget(native_view)
746
1045
 
747
- def _textfield_did_begin_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
748
- """``textFieldDidBeginEditing:`` / ``textViewDidBeginEditing:`` -> ``on_focus``."""
749
- cb = _pn_tf_focus_callback_map.get(int(self_ptr))
750
- if cb is None:
751
- return
752
- try:
753
- cb()
754
- except Exception:
755
- pass
756
-
757
-
758
- def _textfield_did_end_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
759
- """``textFieldDidEndEditing:`` / ``textViewDidEndEditing:`` -> ``on_blur``."""
760
- cb = _pn_tf_blur_callback_map.get(int(self_ptr))
761
- if cb is None:
762
- return
763
- try:
764
- cb()
765
- except Exception:
766
- pass
767
-
768
-
769
- def _ensure_textfield_target_class() -> Optional[int]:
770
- global _PN_TEXTFIELD_TARGET_CLS
771
- global _textfield_edit_imp_ref, _textfield_submit_imp_ref, _textfield_should_return_imp_ref
772
- global _textfield_did_begin_imp_ref, _textfield_did_end_imp_ref
773
- if _PN_TEXTFIELD_TARGET_CLS is not None:
774
- return _PN_TEXTFIELD_TARGET_CLS
775
- existing = _get_cls(b"PNTextFieldActionTarget")
776
- if existing:
777
- _PN_TEXTFIELD_TARGET_CLS = int(existing)
778
- return _PN_TEXTFIELD_TARGET_CLS
779
- cls = _alloc_cls(_NS_OBJECT_CLS, b"PNTextFieldActionTarget", 0)
780
- if not cls:
781
- return None
782
- action_type = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
783
- bool_type = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
784
- _textfield_edit_imp_ref = action_type(_textfield_on_edit_imp)
785
- _textfield_submit_imp_ref = action_type(_textfield_on_submit_imp)
786
- _textfield_should_return_imp_ref = bool_type(_textfield_should_return_imp)
787
- _textfield_did_begin_imp_ref = action_type(_textfield_did_begin_imp)
788
- _textfield_did_end_imp_ref = action_type(_textfield_did_end_imp)
789
- _add_method(cls, _SEL_ON_EDIT, _ct.cast(_textfield_edit_imp_ref, _ct.c_void_p), b"v@:@")
790
- _add_method(cls, _SEL_ON_SUBMIT, _ct.cast(_textfield_submit_imp_ref, _ct.c_void_p), b"v@:@")
791
- _add_method(
792
- cls,
793
- _SEL_TEXT_FIELD_SHOULD_RETURN,
794
- _ct.cast(_textfield_should_return_imp_ref, _ct.c_void_p),
795
- b"c@:@",
796
- )
797
- # ``textFieldDidBeginEditing:`` / ``textViewDidBeginEditing:`` both
798
- # share the focus IMP; the end-editing pair shares the blur IMP. The
799
- # same target object is wired as both UITextFieldDelegate and
800
- # UITextViewDelegate so on_focus / on_blur work for single- and
801
- # multi-line inputs alike.
802
- _add_method(cls, _SEL_TEXT_FIELD_DID_BEGIN, _ct.cast(_textfield_did_begin_imp_ref, _ct.c_void_p), b"v@:@")
803
- _add_method(cls, _SEL_TEXT_FIELD_DID_END, _ct.cast(_textfield_did_end_imp_ref, _ct.c_void_p), b"v@:@")
804
- _add_method(cls, _SEL_TEXT_VIEW_DID_BEGIN, _ct.cast(_textfield_did_begin_imp_ref, _ct.c_void_p), b"v@:@")
805
- _add_method(cls, _SEL_TEXT_VIEW_DID_END, _ct.cast(_textfield_did_end_imp_ref, _ct.c_void_p), b"v@:@")
806
- _reg_cls(cls)
807
- _PN_TEXTFIELD_TARGET_CLS = int(cls)
808
- return _PN_TEXTFIELD_TARGET_CLS
809
-
810
-
811
- def _new_textfield_target() -> Optional[int]:
812
- cls = _ensure_textfield_target_class()
813
- if not cls:
814
- return None
815
- _objc_msgSend.restype = _ct.c_void_p
816
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
817
- raw = _objc_msgSend(_ct.c_void_p(cls), _SEL_ALLOC)
818
- raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_INIT)
819
- raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_RETAIN)
820
- return int(raw) if raw else None
821
-
822
-
823
- def _attach_textfield_raw_target(tf: Any, props: Dict[str, Any]) -> None:
824
- tf_ptr = _objc_ptr(tf)
825
- if not tf_ptr:
826
- return
827
- target_ptr = _pn_tf_raw_target_map.get(id(tf))
828
- if target_ptr is None:
829
- target_ptr = _new_textfield_target()
830
- if not target_ptr:
831
- return
832
- _pn_tf_raw_target_map[id(tf)] = target_ptr
833
- _pn_retained_views.append(target_ptr)
834
- _objc_msgSend.restype = None
835
- _objc_msgSend.argtypes = [
836
- _ct.c_void_p,
837
- _ct.c_void_p,
838
- _ct.c_void_p,
839
- _ct.c_void_p,
840
- _ct.c_ulong,
841
- ]
842
- _objc_msgSend(
843
- _ct.c_void_p(tf_ptr),
844
- _SEL_ADD_TARGET_ACTION_EVENTS,
845
- _ct.c_void_p(target_ptr),
846
- _SEL_ON_EDIT,
847
- 1 << 17,
848
- )
849
- _objc_msgSend(
850
- _ct.c_void_p(tf_ptr),
851
- _SEL_ADD_TARGET_ACTION_EVENTS,
852
- _ct.c_void_p(target_ptr),
853
- _SEL_ON_SUBMIT,
854
- 1 << 6,
855
- )
856
- # Wire the same object as the UITextFieldDelegate so its
857
- # ``textFieldShouldReturn:`` runs and resigns first responder
858
- # — without this iOS keeps the keyboard up after Return.
859
- _objc_msgSend.restype = None
860
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
861
- _objc_msgSend(
862
- _ct.c_void_p(tf_ptr),
863
- _SEL_SET_DELEGATE,
864
- _ct.c_void_p(target_ptr),
865
- )
866
- if "on_change" in props:
867
- _pn_tf_change_callback_map[int(target_ptr)] = props["on_change"]
868
- if "on_submit" in props:
869
- _pn_tf_submit_callback_map[int(target_ptr)] = props["on_submit"]
870
- if "on_focus" in props:
871
- _pn_tf_focus_callback_map[int(target_ptr)] = props["on_focus"]
872
- if "on_blur" in props:
873
- _pn_tf_blur_callback_map[int(target_ptr)] = props["on_blur"]
874
-
875
-
876
- def _attach_textview_raw_target(tv: Any, props: Dict[str, Any]) -> None:
877
- """Wire ``tv`` as a UITextViewDelegate for ``on_focus`` / ``on_blur``.
878
-
879
- Unlike ``UITextField`` (a ``UIControl`` driven by target-action), a
880
- ``UITextView`` only exposes focus/blur through its delegate's
881
- ``textViewDidBeginEditing:`` / ``textViewDidEndEditing:``. We reuse
882
- the same raw ``PNTextFieldActionTarget`` class — it implements those
883
- selectors too — and set it as the text view's delegate.
884
- """
885
- tv_ptr = _objc_ptr(tv)
886
- if not tv_ptr:
887
- return
888
- target_ptr = _pn_tv_raw_target_map.get(id(tv))
889
- if target_ptr is None:
890
- target_ptr = _new_textfield_target()
891
- if not target_ptr:
892
- return
893
- _pn_tv_raw_target_map[id(tv)] = target_ptr
894
- _pn_retained_views.append(target_ptr)
895
- _objc_msgSend.restype = None
896
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
897
- _objc_msgSend(_ct.c_void_p(tv_ptr), _SEL_SET_DELEGATE, _ct.c_void_p(target_ptr))
898
- if "on_focus" in props:
899
- _pn_tf_focus_callback_map[int(target_ptr)] = props["on_focus"]
900
- if "on_blur" in props:
901
- _pn_tf_blur_callback_map[int(target_ptr)] = props["on_blur"]
902
-
903
-
904
- _pn_switch_handler_map: dict = {}
905
-
906
-
907
- class _PNSwitchTarget(NSObject): # type: ignore[valid-type]
908
- _callback: Optional[Callable[[bool], None]] = None
909
-
910
- @objc_method
911
- def onToggle_(self, sender: object) -> None:
912
- if self._callback is not None:
913
- try:
914
- self._callback(bool(sender.isOn()))
915
- except Exception:
916
- pass
917
-
1046
+ def _build(self, props: Dict[str, Any]) -> Any:
1047
+ raise NotImplementedError
918
1048
 
919
- _pn_slider_handler_map: dict = {}
1049
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1050
+ _apply_common_visual(view, props)
920
1051
 
1052
+ def _teardown(self, native_view: Any) -> None:
1053
+ """Subclass hook for extra cleanup before the view is released."""
921
1054
 
922
- class _PNSliderTarget(NSObject): # type: ignore[valid-type]
923
- _callback: Optional[Callable[[float], None]] = None
924
-
925
- @objc_method
926
- def onSlide_(self, sender: object) -> None:
927
- if self._callback is not None:
1055
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
1056
+ try:
1057
+ child.setTranslatesAutoresizingMaskIntoConstraints_(True)
1058
+ except Exception:
1059
+ pass
1060
+ try:
1061
+ count = len(list(parent.subviews or []))
1062
+ except Exception:
1063
+ count = index
1064
+ try:
1065
+ parent.insertSubview_atIndex_(child, max(0, min(index, count)))
1066
+ except Exception:
928
1067
  try:
929
- self._callback(float(sender.value))
1068
+ parent.addSubview_(child)
930
1069
  except Exception:
931
1070
  pass
932
1071
 
1072
+ def remove_child(self, parent: Any, child: Any) -> None:
1073
+ try:
1074
+ child.removeFromSuperview()
1075
+ except Exception:
1076
+ pass
933
1077
 
934
- _pn_pressable_state: dict = {}
935
-
936
-
937
- class _PNPressableTarget(NSObject): # type: ignore[valid-type]
938
- @objc_method
939
- def onTouchDown_(self, sender: object) -> None:
940
- info = _pn_pressable_state.get(id(self))
941
- if not info:
942
- return
943
- view = info.get("view")
944
- opacity = info.get("pressed_opacity", 0.6)
945
- if view is not None:
946
- try:
947
- UIView = ObjCClass("UIView")
948
- UIView.animateWithDuration_animations_(0.05, lambda: view.setAlpha_(float(opacity)))
949
- except Exception:
950
- pass
951
-
952
- @objc_method
953
- def onTouchUp_(self, sender: object) -> None:
954
- info = _pn_pressable_state.get(id(self))
955
- if not info:
1078
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1079
+ if native_view is None:
956
1080
  return
957
- view = info.get("view")
958
- cb = info.get("on_press")
959
- if view is not None:
1081
+ try:
1082
+ frame_x = _safe_finite(x, 0.0)
1083
+ frame_y = _safe_finite(y, 0.0)
1084
+ frame_w = max(0.0, _safe_finite(width, 0.0))
1085
+ frame_h = max(0.0, _safe_finite(height, 0.0))
1086
+ native_view.setTranslatesAutoresizingMaskIntoConstraints_(True)
1087
+ native_view.setFrame_(((frame_x, frame_y), (frame_w, frame_h)))
1088
+ _clamp_view_corner_radius(native_view, frame_w, frame_h)
960
1089
  try:
961
- UIView = ObjCClass("UIView")
962
- UIView.animateWithDuration_animations_(0.1, lambda: view.setAlpha_(1.0))
1090
+ _clamp_layer_corner_radius(native_view.layer, frame_w, frame_h)
963
1091
  except Exception:
964
1092
  pass
965
- if cb is not None:
966
1093
  try:
967
- cb()
1094
+ parent = native_view.superview
1095
+ parent_cls = ""
1096
+ try:
1097
+ parent_cls = str(parent.objc_class.name) if parent is not None else ""
1098
+ except Exception:
1099
+ parent_cls = ""
1100
+ # Expand the parent UIScrollView's contentSize whenever a
1101
+ # child's frame extends past the visible bounds, so the
1102
+ # scroll view can actually scroll to reveal it.
1103
+ if "UIScrollView" in parent_cls:
1104
+ bounds = parent.bounds
1105
+ content_w = max(float(bounds.size.width), frame_x + frame_w)
1106
+ content_h = max(float(bounds.size.height), frame_y + frame_h)
1107
+ parent.setContentSize_((content_w, content_h))
968
1108
  except Exception:
969
1109
  pass
1110
+ except Exception:
1111
+ pass
970
1112
 
971
- @objc_method
972
- def onTouchCancel_(self, sender: object) -> None:
973
- info = _pn_pressable_state.get(id(self))
974
- if not info:
975
- return
976
- view = info.get("view")
977
- if view is not None:
978
- try:
979
- UIView = ObjCClass("UIView")
980
- UIView.animateWithDuration_animations_(0.1, lambda: view.setAlpha_(1.0))
981
- except Exception:
982
- pass
1113
+ def measure_intrinsic(
1114
+ self,
1115
+ native_view: Any,
1116
+ max_width: float,
1117
+ max_height: float,
1118
+ ) -> Tuple[float, float]:
1119
+ try:
1120
+ mw = _safe_max(max_width, fallback=10000.0)
1121
+ mh = _safe_max(max_height, fallback=10000.0)
1122
+ size = native_view.sizeThatFits_((mw, mh))
1123
+ w = float(size.width)
1124
+ h = float(size.height)
1125
+ if math.isfinite(max_width):
1126
+ w = min(w, max_width)
1127
+ return (w, h)
1128
+ except Exception:
1129
+ return (0.0, 0.0)
983
1130
 
984
- @objc_method
985
- def onLongPress_(self, sender: object) -> None:
986
- info = _pn_pressable_state.get(id(self))
987
- if not info:
1131
+ def set_animated_property(self, native_view: Any, prop_name: str, value: Any) -> None:
1132
+ """Apply one Python-driven animation frame immediately."""
1133
+ if native_view is None:
988
1134
  return
989
- # UILongPressGestureRecognizer fires on state Began (state==1).
990
1135
  try:
991
- state = int(sender.state)
1136
+ applier = _animated_applier_for(prop_name, value)
992
1137
  except Exception:
993
- state = 1
994
- if state != 1:
995
1138
  return
996
- cb = info.get("on_long_press")
997
- if cb is not None:
998
- try:
999
- cb()
1000
- except Exception:
1001
- pass
1139
+ if applier is None:
1140
+ return
1141
+ try:
1142
+ applier(native_view)
1143
+ except Exception:
1144
+ pass
1145
+
1146
+ def start_animation(
1147
+ self,
1148
+ native_view: Any,
1149
+ anim_id: int,
1150
+ prop_name: str,
1151
+ spec: Dict[str, Any],
1152
+ ) -> bool:
1153
+ """Drive ``timing`` / ``spring`` specs with UIKit block animations.
1154
+
1155
+ ``decay`` (and any unknown kind) returns ``False`` so the Python
1156
+ ticker integrates the exact physics. Off-main-thread starts also
1157
+ fall back — UIKit animation APIs are main-thread-only.
1158
+ """
1159
+ if native_view is None or not isinstance(spec, dict):
1160
+ return False
1161
+ if str(spec.get("kind", "")) not in ("timing", "spring"):
1162
+ return False
1163
+ if not _is_main_thread():
1164
+ return False
1165
+ return _start_native_animation(native_view, anim_id, prop_name, spec)
1166
+
1167
+ def cancel_animation(self, native_view: Any, anim_id: int) -> Any:
1168
+ entry = _native_anims.pop(anim_id, None)
1169
+ if entry is None:
1170
+ return None
1171
+ view = entry.get("view")
1172
+ prop = str(entry.get("prop", ""))
1173
+ value: Any = None
1174
+ try:
1175
+ presentation = view.layer.presentationLayer()
1176
+ if presentation is not None and prop == "opacity":
1177
+ value = float(presentation.opacity)
1178
+ except Exception:
1179
+ value = None
1180
+ try:
1181
+ view.layer.removeAllAnimations()
1182
+ except Exception:
1183
+ pass
1184
+ return value
1002
1185
 
1003
1186
 
1004
1187
  # ======================================================================
@@ -1014,32 +1197,11 @@ class FlexContainerHandler(IOSViewHandler):
1014
1197
  [`set_frame`][pythonnative.native_views.ios.IOSViewHandler.set_frame].
1015
1198
  """
1016
1199
 
1017
- def create(self, props: Dict[str, Any]) -> Any:
1200
+ def _build(self, props: Dict[str, Any]) -> Any:
1018
1201
  v = ObjCClass("UIView").alloc().init()
1019
1202
  v.setTranslatesAutoresizingMaskIntoConstraints_(True)
1020
- _apply_common_visual(v, props)
1021
1203
  return v
1022
1204
 
1023
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1024
- _apply_common_visual(native_view, changed)
1025
-
1026
- def add_child(self, parent: Any, child: Any) -> None:
1027
- try:
1028
- child.setTranslatesAutoresizingMaskIntoConstraints_(True)
1029
- except Exception:
1030
- pass
1031
- parent.addSubview_(child)
1032
-
1033
- def remove_child(self, parent: Any, child: Any) -> None:
1034
- child.removeFromSuperview()
1035
-
1036
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
1037
- try:
1038
- child.setTranslatesAutoresizingMaskIntoConstraints_(True)
1039
- except Exception:
1040
- pass
1041
- parent.insertSubview_atIndex_(child, index)
1042
-
1043
1205
 
1044
1206
  # ======================================================================
1045
1207
  # Leaf handlers
@@ -1047,16 +1209,12 @@ class FlexContainerHandler(IOSViewHandler):
1047
1209
 
1048
1210
 
1049
1211
  class TextHandler(IOSViewHandler):
1050
- def create(self, props: Dict[str, Any]) -> Any:
1212
+ def _build(self, props: Dict[str, Any]) -> Any:
1051
1213
  label = ObjCClass("UILabel").alloc().init()
1052
1214
  label.setNumberOfLines_(0)
1053
1215
  label.setTranslatesAutoresizingMaskIntoConstraints_(True)
1054
- self._apply(label, props)
1055
1216
  return label
1056
1217
 
1057
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1058
- self._apply(native_view, changed)
1059
-
1060
1218
  def _font_for(self, size: float, weight: Any, family: Optional[str], italic: bool) -> Any:
1061
1219
  """Resolve a UIFont from family/weight/italic/size keys."""
1062
1220
  size = float(size)
@@ -1113,23 +1271,24 @@ class TextHandler(IOSViewHandler):
1113
1271
  pass
1114
1272
  return font
1115
1273
 
1116
- def _apply(self, label: Any, props: Dict[str, Any]) -> None:
1274
+ def _apply(self, label: Any, props: Dict[str, Any], initial: bool) -> None:
1117
1275
  if "text" in props:
1118
1276
  label.setText_(str(props["text"]) if props["text"] is not None else "")
1119
1277
  # Font requires combining size + weight + family + italic + bold.
1120
1278
  font_keys_present = any(k in props for k in ("font_size", "font_weight", "font_family", "italic", "bold"))
1121
1279
  if font_keys_present:
1280
+ merged = _state_of(label).get("props") or props
1122
1281
  current = label.font
1123
1282
  try:
1124
1283
  current_size = float(current.pointSize) if current is not None else 17.0
1125
1284
  except Exception:
1126
1285
  current_size = 17.0
1127
- size = float(props.get("font_size", current_size)) if props.get("font_size") is not None else current_size
1128
- weight = props.get("font_weight")
1129
- if weight is None and props.get("bold"):
1286
+ size = float(merged.get("font_size", current_size)) if merged.get("font_size") is not None else current_size
1287
+ weight = merged.get("font_weight")
1288
+ if weight is None and merged.get("bold"):
1130
1289
  weight = "bold"
1131
- family = props.get("font_family")
1132
- italic = bool(props.get("italic"))
1290
+ family = merged.get("font_family")
1291
+ italic = bool(merged.get("italic"))
1133
1292
  label.setFont_(self._font_for(size, weight, family, italic))
1134
1293
  if "color" in props and props["color"] is not None:
1135
1294
  label.setTextColor_(_uicolor(props["color"]))
@@ -1141,7 +1300,8 @@ class TextHandler(IOSViewHandler):
1141
1300
  mapping = {"left": 0, "center": 1, "right": 2, "natural": 4, "justify": 3}
1142
1301
  label.setTextAlignment_(mapping.get(props["text_align"], 0))
1143
1302
  if "letter_spacing" in props or "line_height" in props or "text_decoration" in props:
1144
- self._apply_attributed(label, props)
1303
+ merged = _state_of(label).get("props") or props
1304
+ self._apply_attributed(label, merged)
1145
1305
  _apply_view_border(label, props)
1146
1306
  _apply_shadow(label, props)
1147
1307
  _apply_transform(label, props)
@@ -1199,7 +1359,7 @@ class TextHandler(IOSViewHandler):
1199
1359
 
1200
1360
 
1201
1361
  class ButtonHandler(IOSViewHandler):
1202
- def create(self, props: Dict[str, Any]) -> Any:
1362
+ def _build(self, props: Dict[str, Any]) -> Any:
1203
1363
  # ``UIButtonTypeSystem`` (1) gives us a properly-sized button
1204
1364
  # with intrinsicContentSize derived from the title; the default
1205
1365
  # ``UIButtonTypeCustom`` returns CGSizeZero from sizeThatFits_,
@@ -1208,12 +1368,9 @@ class ButtonHandler(IOSViewHandler):
1208
1368
  btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
1209
1369
  btn.retain()
1210
1370
  _pn_retained_views.append(btn)
1211
- self._apply(btn, props)
1371
+ _register_control_action(btn, 1 << 6, lambda: _fire(btn, "on_click")) # TouchUpInside
1212
1372
  return btn
1213
1373
 
1214
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1215
- self._apply(native_view, changed)
1216
-
1217
1374
  def measure_intrinsic(
1218
1375
  self,
1219
1376
  native_view: Any,
@@ -1232,7 +1389,7 @@ class ButtonHandler(IOSViewHandler):
1232
1389
  except Exception:
1233
1390
  return (44.0, 32.0)
1234
1391
 
1235
- def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
1392
+ def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
1236
1393
  if "title" in props:
1237
1394
  btn.setTitle_forState_(str(props["title"]) if props["title"] is not None else "", 0)
1238
1395
  if "font_size" in props and props["font_size"] is not None:
@@ -1255,15 +1412,6 @@ class ButtonHandler(IOSViewHandler):
1255
1412
  btn.setAlpha_(float(props["opacity"]))
1256
1413
  except Exception:
1257
1414
  pass
1258
- if "on_click" in props:
1259
- existing = _pn_btn_handler_map.get(id(btn))
1260
- if existing is not None:
1261
- _pn_btn_callback_map[id(existing)] = props["on_click"]
1262
- else:
1263
- handler = _PNButtonTarget.new()
1264
- _pn_btn_handler_map[id(btn)] = handler
1265
- _pn_btn_callback_map[id(handler)] = props["on_click"]
1266
- btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6)
1267
1415
 
1268
1416
 
1269
1417
  # ``scrollViewDidScroll:`` hands the delegate a ``UIScrollView*``. rubicon's
@@ -1288,9 +1436,8 @@ def _scroll_did_scroll_imp(self_ptr: int, _cmd_ptr: int, _scroll_view_ptr: int)
1288
1436
  info = _pn_scroll_imp_map.get(self_ptr)
1289
1437
  if not info:
1290
1438
  return
1291
- cb = info.get("on_scroll")
1292
1439
  sv = info.get("sv")
1293
- if cb is None or sv is None:
1440
+ if sv is None:
1294
1441
  return
1295
1442
  try:
1296
1443
  offset = sv.contentOffset
@@ -1298,10 +1445,7 @@ def _scroll_did_scroll_imp(self_ptr: int, _cmd_ptr: int, _scroll_view_ptr: int)
1298
1445
  y = float(offset.y)
1299
1446
  except Exception:
1300
1447
  return
1301
- try:
1302
- cb(x, y)
1303
- except Exception:
1304
- pass
1448
+ _fire(sv, "on_scroll", {"x": x, "y": y})
1305
1449
 
1306
1450
 
1307
1451
  _scroll_imp_ref = _SCROLL_IMP_TYPE(_scroll_did_scroll_imp)
@@ -1325,36 +1469,25 @@ class ScrollViewHandler(IOSViewHandler):
1325
1469
  `UIScrollView.contentSize` whenever a child frame extends beyond
1326
1470
  the visible bounds.
1327
1471
 
1328
- When ``refresh_control`` is provided in props (a dict with
1329
- ``refreshing`` + ``on_refresh``), a ``UIRefreshControl`` is
1330
- attached to the scroll view.
1472
+ Scroll offsets are reported as ``on_scroll`` events with a
1473
+ ``{"x": pts, "y": pts}`` payload. Imperative commands:
1474
+ ``scroll_to_offset`` / ``scroll_to_end`` / ``get_scroll_offset``.
1475
+
1476
+ When ``refresh_control`` is provided in props, a
1477
+ ``UIRefreshControl`` is attached and pull-to-refresh fires the
1478
+ ``on_refresh`` event.
1331
1479
  """
1332
1480
 
1333
- def create(self, props: Dict[str, Any]) -> Any:
1481
+ def _build(self, props: Dict[str, Any]) -> Any:
1334
1482
  sv = ObjCClass("UIScrollView").alloc().init()
1335
1483
  sv.setTranslatesAutoresizingMaskIntoConstraints_(True)
1336
- _apply_common_visual(sv, props)
1337
- self._apply_refresh(sv, props)
1338
- self._apply_scroll_props(sv, props)
1484
+ self._wire_scroll(sv)
1339
1485
  return sv
1340
1486
 
1341
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1342
- _apply_common_visual(native_view, changed)
1343
- if "refresh_control" in changed:
1344
- self._apply_refresh(native_view, changed)
1345
- self._apply_scroll_props(native_view, changed)
1346
-
1347
- def add_child(self, parent: Any, child: Any) -> None:
1348
- try:
1349
- child.setTranslatesAutoresizingMaskIntoConstraints_(True)
1350
- except Exception:
1351
- pass
1352
- parent.addSubview_(child)
1353
-
1354
- def remove_child(self, parent: Any, child: Any) -> None:
1355
- child.removeFromSuperview()
1356
-
1357
- def _apply_scroll_props(self, sv: Any, props: Dict[str, Any]) -> None:
1487
+ def _apply(self, sv: Any, props: Dict[str, Any], initial: bool) -> None:
1488
+ _apply_common_visual(sv, props)
1489
+ if "refresh_control" in props:
1490
+ self._apply_refresh(sv, props)
1358
1491
  # ``shows_scroll_indicator`` is present only when False; a removed
1359
1492
  # prop (None) restores both indicators.
1360
1493
  if "shows_scroll_indicator" in props:
@@ -1382,34 +1515,70 @@ class ScrollViewHandler(IOSViewHandler):
1382
1515
  sv.setKeyboardDismissMode_(mapping.get(props["keyboard_dismiss_mode"], 0))
1383
1516
  except Exception:
1384
1517
  pass
1385
- if "on_scroll" in props:
1386
- self._wire_scroll(sv, props["on_scroll"])
1387
1518
 
1388
- def _wire_scroll(self, sv: Any, on_scroll: Any) -> None:
1389
- delegate_ptr = getattr(sv, "_pn_scroll_delegate_ptr", None)
1390
- if delegate_ptr is None:
1391
- if not _PN_SCROLL_DELEGATE_CLS:
1392
- return
1393
- _objc_msgSend.restype = _ct.c_void_p
1394
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
1395
- d = _objc_msgSend(_PN_SCROLL_DELEGATE_CLS, _SEL_ALLOC)
1396
- d = _objc_msgSend(d, _SEL_INIT)
1397
- d = _objc_msgSend(d, _SEL_RETAIN)
1398
- delegate_ptr = int(d)
1399
- sv._pn_scroll_delegate_ptr = delegate_ptr
1400
- _pn_scroll_imp_map[delegate_ptr] = {"on_scroll": on_scroll, "sv": sv}
1401
- _objc_msgSend.restype = None
1402
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
1403
- sv_ptr = sv.ptr if hasattr(sv, "ptr") else sv
1404
- _objc_msgSend(sv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(delegate_ptr))
1405
- else:
1406
- info = _pn_scroll_imp_map.setdefault(delegate_ptr, {})
1407
- info["on_scroll"] = on_scroll
1408
- info["sv"] = sv
1519
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
1520
+ if name == "scroll_to_offset":
1521
+ x = float(args.get("x", 0.0) or 0.0)
1522
+ y = float(args.get("y", 0.0) or 0.0)
1523
+ animated = args.get("animated", True) is not False
1524
+ try:
1525
+ native_view.setContentOffset_animated_((x, y), animated)
1526
+ except Exception:
1527
+ pass
1528
+ return None
1529
+ if name == "scroll_to_end":
1530
+ animated = args.get("animated", True) is not False
1531
+ try:
1532
+ content = native_view.contentSize
1533
+ bounds = native_view.bounds
1534
+ target_y = max(0.0, float(content.height) - float(bounds.size.height))
1535
+ target_x = max(0.0, float(content.width) - float(bounds.size.width))
1536
+ horizontal = float(content.width) > float(bounds.size.width) and float(content.height) <= float(
1537
+ bounds.size.height
1538
+ )
1539
+ offset = (target_x, 0.0) if horizontal else (0.0, target_y)
1540
+ native_view.setContentOffset_animated_(offset, animated)
1541
+ except Exception:
1542
+ pass
1543
+ return None
1544
+ if name == "get_scroll_offset":
1545
+ try:
1546
+ offset = native_view.contentOffset
1547
+ return {"x": float(offset.x), "y": float(offset.y)}
1548
+ except Exception:
1549
+ return {"x": 0.0, "y": 0.0}
1550
+ return None
1551
+
1552
+ def _wire_scroll(self, sv: Any) -> None:
1553
+ if not _PN_SCROLL_DELEGATE_CLS:
1554
+ return
1555
+ _objc_msgSend.restype = _ct.c_void_p
1556
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
1557
+ d = _objc_msgSend(_PN_SCROLL_DELEGATE_CLS, _SEL_ALLOC)
1558
+ d = _objc_msgSend(d, _SEL_INIT)
1559
+ d = _objc_msgSend(d, _SEL_RETAIN)
1560
+ delegate_ptr = int(d)
1561
+ _pn_scroll_imp_map[delegate_ptr] = {"sv": sv}
1562
+ _objc_msgSend.restype = None
1563
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
1564
+ sv_ptr = sv.ptr if hasattr(sv, "ptr") else sv
1565
+ _objc_msgSend(sv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(delegate_ptr))
1409
1566
 
1410
1567
  def _apply_refresh(self, sv: Any, props: Dict[str, Any]) -> None:
1411
1568
  spec = props.get("refresh_control")
1412
1569
  if not spec:
1570
+ # Prop removed (screen reuse can recycle this scroll view
1571
+ # for a refresh-less screen): detach so no phantom pull
1572
+ # gesture survives.
1573
+ try:
1574
+ existing = sv.refreshControl
1575
+ if existing is not None:
1576
+ existing.endRefreshing()
1577
+ _pn_action_handlers.pop(_recognizer_ptr(existing), None)
1578
+ sv.setRefreshControl_(None)
1579
+ sv.setAlwaysBounceVertical_(False)
1580
+ except Exception:
1581
+ pass
1413
1582
  return
1414
1583
  try:
1415
1584
  existing = sv.refreshControl
@@ -1418,16 +1587,16 @@ class ScrollViewHandler(IOSViewHandler):
1418
1587
  rc.retain()
1419
1588
  _pn_retained_views.append(rc)
1420
1589
  sv.setRefreshControl_(rc)
1421
- target = _PNButtonTarget.new()
1422
- target.retain()
1423
- _pn_retained_views.append(target)
1424
- _pn_btn_handler_map[id(rc)] = target
1425
- rc.addTarget_action_forControlEvents_(target, SEL("onTap:"), 1 << 12) # ValueChanged
1590
+ _register_control_action(rc, 1 << 12, lambda: _fire(sv, "on_refresh")) # ValueChanged
1591
+ # Without this, a scroll view whose content fits its
1592
+ # bounds never engages the pan gesture, making the
1593
+ # refresh control unreachable by a pull (RN's ScrollView
1594
+ # bounces vertically by default for the same reason).
1595
+ try:
1596
+ sv.setAlwaysBounceVertical_(True)
1597
+ except Exception:
1598
+ pass
1426
1599
  existing = rc
1427
- cb = spec.get("on_refresh") if isinstance(spec, dict) else None
1428
- target = _pn_btn_handler_map.get(id(existing))
1429
- if target is not None and cb is not None:
1430
- _pn_btn_callback_map[id(target)] = cb
1431
1600
  refreshing = bool(spec.get("refreshing")) if isinstance(spec, dict) else False
1432
1601
  if refreshing:
1433
1602
  existing.beginRefreshing()
@@ -1437,101 +1606,377 @@ class ScrollViewHandler(IOSViewHandler):
1437
1606
  pass
1438
1607
 
1439
1608
 
1440
- # Public ``text_content_type`` names -> the documented ``UITextContentType*``
1441
- # symbol names. The constants' raw NSString values are *not* derivable from
1442
- # the symbol (e.g. ``UITextContentTypeOneTimeCode`` == ``"one-time-code"``),
1443
- # so we read the real constants out of UIKit via ``objc_const`` instead of
1444
- # hardcoding their string values.
1445
- _TEXT_CONTENT_TYPE_SYMBOLS = {
1446
- "username": "UITextContentTypeUsername",
1447
- "password": "UITextContentTypePassword",
1448
- "new_password": "UITextContentTypeNewPassword",
1449
- "one_time_code": "UITextContentTypeOneTimeCode",
1450
- "email": "UITextContentTypeEmailAddress",
1451
- "email_address": "UITextContentTypeEmailAddress",
1452
- "name": "UITextContentTypeName",
1453
- "url": "UITextContentTypeURL",
1454
- "telephone": "UITextContentTypeTelephoneNumber",
1455
- "telephone_number": "UITextContentTypeTelephoneNumber",
1456
- "phone": "UITextContentTypeTelephoneNumber",
1457
- "phone_number": "UITextContentTypeTelephoneNumber",
1458
- }
1459
- _pn_text_content_type_cache: Dict[str, Any] = {}
1609
+ class ImageHandler(IOSViewHandler):
1610
+ """`UIImageView` with async URL loading via NSURLSession."""
1460
1611
 
1612
+ def _build(self, props: Dict[str, Any]) -> Any:
1613
+ iv = ObjCClass("UIImageView").alloc().init()
1614
+ iv.setTranslatesAutoresizingMaskIntoConstraints_(True)
1615
+ iv.setClipsToBounds_(True)
1616
+ iv.setContentMode_(1) # ScaleAspectFit
1617
+ return iv
1461
1618
 
1462
- def _ui_text_content_type(name: str) -> Any:
1463
- """Resolve a content-type name to its UIKit ``UITextContentType`` constant.
1619
+ def _apply(self, iv: Any, props: Dict[str, Any], initial: bool) -> None:
1620
+ if "tint_color" in props and props["tint_color"] is not None:
1621
+ try:
1622
+ iv.setTintColor_(_uicolor(props["tint_color"]))
1623
+ except Exception:
1624
+ pass
1625
+ if "source" in props and props["source"]:
1626
+ self._load_source(iv, str(props["source"]))
1627
+ if "scale_type" in props and props["scale_type"]:
1628
+ # UIViewContentMode: ScaleToFill=0, ScaleAspectFit=1,
1629
+ # ScaleAspectFill=2, Center=4.
1630
+ mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4}
1631
+ iv.setContentMode_(mapping.get(props["scale_type"], 1))
1632
+ _apply_common_visual(iv, props)
1464
1633
 
1465
- Returns the NSString constant (an ``ObjCInstance``) for a known name,
1466
- or ``None`` for an unknown name / lookup failure (in which case the
1467
- caller should simply leave the content type unset).
1468
- """
1469
- symbol = _TEXT_CONTENT_TYPE_SYMBOLS.get(name.strip().lower())
1470
- if not symbol:
1471
- return None
1472
- if symbol in _pn_text_content_type_cache:
1473
- return _pn_text_content_type_cache[symbol]
1474
- value = None
1634
+ def measure_intrinsic(
1635
+ self,
1636
+ native_view: Any,
1637
+ max_width: float,
1638
+ max_height: float,
1639
+ ) -> Tuple[float, float]:
1640
+ try:
1641
+ img = native_view.image
1642
+ if img is not None:
1643
+ size = img.size
1644
+ w, h = float(size.width), float(size.height)
1645
+ if math.isfinite(max_width) and w > max_width > 0:
1646
+ scale = max_width / w
1647
+ w, h = max_width, h * scale
1648
+ return (w, h)
1649
+ except Exception:
1650
+ pass
1651
+ return (0.0, 0.0)
1652
+
1653
+ def _load_source(self, iv: Any, source: str) -> None:
1654
+ try:
1655
+ if source.startswith(("http://", "https://")):
1656
+ self._load_async(iv, source)
1657
+ else:
1658
+ # Bundle resource or absolute file path.
1659
+ UIImage = ObjCClass("UIImage")
1660
+ image = UIImage.imageNamed_(source)
1661
+ if image is None:
1662
+ image = UIImage.imageWithContentsOfFile_(source)
1663
+ if image:
1664
+ iv.setImage_(image)
1665
+ except Exception:
1666
+ pass
1667
+
1668
+ def _load_async(self, iv: Any, source: str) -> None:
1669
+ """Asynchronously load a remote image off the main thread.
1670
+
1671
+ Uses ``NSURLSession.sharedSession.dataTaskWithURL:completionHandler:``
1672
+ so the main thread is never blocked. The completion handler
1673
+ runs on a background queue; the image is set back on the main
1674
+ queue so UIKit accepts it without threading warnings. The
1675
+ latest requested URI wins if several loads race.
1676
+ """
1677
+ state = _state_of(iv)
1678
+ state["pending_uri"] = source
1679
+ try:
1680
+ iv.retain()
1681
+ _pn_retained_views.append(iv)
1682
+ NSURL = ObjCClass("NSURL")
1683
+ NSURLSession = ObjCClass("NSURLSession")
1684
+ UIImage = ObjCClass("UIImage")
1685
+ url = NSURL.URLWithString_(source)
1686
+ session = NSURLSession.sharedSession
1687
+
1688
+ def completion(data: Any, response: Any, error: Any) -> None:
1689
+ if error is not None or data is None:
1690
+ return
1691
+ try:
1692
+ image = UIImage.imageWithData_(data)
1693
+ if image is None:
1694
+ return
1695
+
1696
+ def apply() -> None:
1697
+ try:
1698
+ if _state_of(iv).get("pending_uri") == source:
1699
+ iv.setImage_(image)
1700
+ except Exception:
1701
+ pass
1702
+
1703
+ from ..runtime import call_on_main_thread
1704
+
1705
+ call_on_main_thread(apply)
1706
+ except Exception:
1707
+ pass
1708
+
1709
+ task = session.dataTaskWithURL_completionHandler_(url, completion)
1710
+ task.resume()
1711
+ except Exception:
1712
+ pass
1713
+
1714
+
1715
+ # ----------------------------------------------------------------------
1716
+ # TextInput — raw libobjc target/delegate
1717
+ # ----------------------------------------------------------------------
1718
+ #
1719
+ # UITextField control events and UITextField/UITextView delegate
1720
+ # callbacks all pass ObjC object arguments, which rubicon's
1721
+ # ``@objc_method`` trampoline handles unreliably on arm64 (see the
1722
+ # module header). The change/submit/focus/blur path is therefore built
1723
+ # on a raw ``PNTextFieldActionTarget`` class registered with libobjc,
1724
+ # exactly like the scroll and tab-bar delegates.
1725
+ #
1726
+ # One target instance is allocated per input view; ``_pn_tf_target_map``
1727
+ # maps the target's raw pointer (the ``self`` of every IMP) back to the
1728
+ # owning input view so the IMPs can consult per-view state (suppress
1729
+ # flag, max_length) and fire through the tag-based event channel.
1730
+
1731
+ _pn_tf_target_map: Dict[int, Any] = {}
1732
+ _PN_TEXTFIELD_TARGET_CLS: Optional[int] = None
1733
+ _textfield_imp_refs: List[Any] = []
1734
+
1735
+
1736
+ def _input_text(view_ptr: int) -> str:
1737
+ """Read ``text`` from a UITextField/UITextView via raw objc_msgSend."""
1738
+ if not view_ptr:
1739
+ return ""
1475
1740
  try:
1476
- from rubicon.objc.api import objc_const
1741
+ _objc_msgSend.restype = _ct.c_void_p
1742
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
1743
+ nsstring_ptr = _objc_msgSend(_ct.c_void_p(view_ptr), _SEL_TEXT)
1744
+ if not nsstring_ptr:
1745
+ return ""
1746
+ _objc_msgSend.restype = _ct.c_char_p
1747
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
1748
+ raw = _objc_msgSend(_ct.c_void_p(nsstring_ptr), _SEL_UTF8STRING)
1749
+ if not raw:
1750
+ return ""
1751
+ return raw.decode("utf-8", errors="replace")
1752
+ except Exception:
1753
+ return ""
1477
1754
 
1478
- uikit = _ct.cdll.LoadLibrary("/System/Library/Frameworks/UIKit.framework/UIKit")
1479
- value = objc_const(uikit, symbol)
1755
+
1756
+ def _input_state_for_target(self_ptr: int) -> Tuple[Optional[Any], Dict[str, Any]]:
1757
+ view = _pn_tf_target_map.get(int(self_ptr))
1758
+ if view is None:
1759
+ return None, {}
1760
+ return view, _state_of(view)
1761
+
1762
+
1763
+ def _tf_on_change_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
1764
+ view, state = _input_state_for_target(self_ptr)
1765
+ if view is None or state.get("suppress"):
1766
+ return
1767
+ text = _input_text(int(sender_ptr or 0))
1768
+ max_length = state.get("max_length")
1769
+ if isinstance(max_length, int) and max_length >= 0 and len(text) > max_length:
1770
+ text = text[:max_length]
1771
+ state["suppress"] = True
1772
+ try:
1773
+ view.setText_(text)
1774
+ except Exception:
1775
+ pass
1776
+ finally:
1777
+ state["suppress"] = False
1778
+ _fire(view, "on_change", text)
1779
+
1780
+
1781
+ def _tf_on_submit_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
1782
+ view, state = _input_state_for_target(self_ptr)
1783
+ if view is None:
1784
+ return
1785
+ _fire(view, "on_submit", _input_text(int(sender_ptr or 0)))
1786
+
1787
+
1788
+ def _tf_should_return_imp(self_ptr: int, _cmd: int, tf_ptr: int) -> bool:
1789
+ """``textFieldShouldReturn:`` — dismiss the keyboard on Return.
1790
+
1791
+ iOS doesn't dismiss the keyboard on Return by default; the standard
1792
+ pattern is for the delegate to resign first responder and return
1793
+ YES, matching React Native's TextInput behavior.
1794
+ """
1795
+ try:
1796
+ _objc_msgSend.restype = None
1797
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
1798
+ _objc_msgSend(_ct.c_void_p(int(tf_ptr or 0)), _SEL_RESIGN_FIRST_RESPONDER)
1480
1799
  except Exception:
1481
- value = None
1482
- _pn_text_content_type_cache[symbol] = value
1483
- return value
1800
+ pass
1801
+ return True
1802
+
1803
+
1804
+ def _tf_did_begin_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
1805
+ view, _state = _input_state_for_target(self_ptr)
1806
+ if view is not None:
1807
+ _fire(view, "on_focus")
1808
+
1809
+
1810
+ def _tf_did_end_imp(self_ptr: int, _cmd: int, sender_ptr: int) -> None:
1811
+ view, _state = _input_state_for_target(self_ptr)
1812
+ if view is not None:
1813
+ _fire(view, "on_blur")
1814
+
1815
+
1816
+ def _ensure_textfield_target_class() -> Optional[int]:
1817
+ global _PN_TEXTFIELD_TARGET_CLS
1818
+ if _PN_TEXTFIELD_TARGET_CLS is not None:
1819
+ return _PN_TEXTFIELD_TARGET_CLS
1820
+ existing = _get_cls(b"PNTextFieldActionTarget")
1821
+ if existing:
1822
+ _PN_TEXTFIELD_TARGET_CLS = int(existing)
1823
+ return _PN_TEXTFIELD_TARGET_CLS
1824
+ cls = _alloc_cls(_NS_OBJECT_CLS, b"PNTextFieldActionTarget", 0)
1825
+ if not cls:
1826
+ return None
1827
+ action_type = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
1828
+ bool_type = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
1829
+ change_imp = action_type(_tf_on_change_imp)
1830
+ submit_imp = action_type(_tf_on_submit_imp)
1831
+ should_return_imp = bool_type(_tf_should_return_imp)
1832
+ begin_imp = action_type(_tf_did_begin_imp)
1833
+ end_imp = action_type(_tf_did_end_imp)
1834
+ # CFUNCTYPE objects must outlive the registered class.
1835
+ _textfield_imp_refs.extend([change_imp, submit_imp, should_return_imp, begin_imp, end_imp])
1836
+ _add_method(cls, _SEL_ON_EDIT, _ct.cast(change_imp, _ct.c_void_p), b"v@:@")
1837
+ _add_method(cls, _SEL_ON_SUBMIT, _ct.cast(submit_imp, _ct.c_void_p), b"v@:@")
1838
+ _add_method(cls, _SEL_TEXT_FIELD_SHOULD_RETURN, _ct.cast(should_return_imp, _ct.c_void_p), b"c@:@")
1839
+ # ``textFieldDidBeginEditing:`` / ``textViewDidBeginEditing:`` share
1840
+ # the focus IMP; the end-editing pair shares the blur IMP, and
1841
+ # ``textViewDidChange:`` shares the change IMP. The same target is
1842
+ # wired as both UITextFieldDelegate and UITextViewDelegate so every
1843
+ # event works for single- and multi-line inputs alike.
1844
+ _add_method(cls, _SEL_TEXT_FIELD_DID_BEGIN, _ct.cast(begin_imp, _ct.c_void_p), b"v@:@")
1845
+ _add_method(cls, _SEL_TEXT_FIELD_DID_END, _ct.cast(end_imp, _ct.c_void_p), b"v@:@")
1846
+ _add_method(cls, _SEL_TEXT_VIEW_DID_BEGIN, _ct.cast(begin_imp, _ct.c_void_p), b"v@:@")
1847
+ _add_method(cls, _SEL_TEXT_VIEW_DID_END, _ct.cast(end_imp, _ct.c_void_p), b"v@:@")
1848
+ _add_method(cls, _SEL_TEXT_VIEW_DID_CHANGE, _ct.cast(change_imp, _ct.c_void_p), b"v@:@")
1849
+ _reg_cls(cls)
1850
+ _PN_TEXTFIELD_TARGET_CLS = int(cls)
1851
+ return _PN_TEXTFIELD_TARGET_CLS
1852
+
1853
+
1854
+ def _attach_input_target(view: Any, *, is_field: bool) -> None:
1855
+ """Allocate the raw target and wire control events + delegate."""
1856
+ cls = _ensure_textfield_target_class()
1857
+ view_ptr = _objc_ptr(view)
1858
+ if not cls or not view_ptr:
1859
+ return
1860
+ _objc_msgSend.restype = _ct.c_void_p
1861
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
1862
+ raw = _objc_msgSend(_ct.c_void_p(cls), _SEL_ALLOC)
1863
+ raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_INIT)
1864
+ raw = _objc_msgSend(_ct.c_void_p(raw), _SEL_RETAIN)
1865
+ if not raw:
1866
+ return
1867
+ target_ptr = int(raw)
1868
+ _pn_tf_target_map[target_ptr] = view
1869
+ _pn_retained_views.append(target_ptr)
1870
+ _state_of(view)["tf_target_ptr"] = target_ptr
1871
+ if is_field:
1872
+ _objc_msgSend.restype = None
1873
+ _objc_msgSend.argtypes = [
1874
+ _ct.c_void_p,
1875
+ _ct.c_void_p,
1876
+ _ct.c_void_p,
1877
+ _ct.c_void_p,
1878
+ _ct.c_ulong,
1879
+ ]
1880
+ # UIControlEventEditingChanged / UIControlEventEditingDidEndOnExit.
1881
+ _objc_msgSend(
1882
+ _ct.c_void_p(view_ptr),
1883
+ _SEL_ADD_TARGET_ACTION_EVENTS,
1884
+ _ct.c_void_p(target_ptr),
1885
+ _SEL_ON_EDIT,
1886
+ 1 << 17,
1887
+ )
1888
+ _objc_msgSend(
1889
+ _ct.c_void_p(view_ptr),
1890
+ _SEL_ADD_TARGET_ACTION_EVENTS,
1891
+ _ct.c_void_p(target_ptr),
1892
+ _SEL_ON_SUBMIT,
1893
+ 1 << 19,
1894
+ )
1895
+ # The delegate carries shouldReturn / focus / blur for UITextField
1896
+ # and change / focus / blur for UITextView.
1897
+ _objc_msgSend.restype = None
1898
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
1899
+ _objc_msgSend(_ct.c_void_p(view_ptr), _SEL_SET_DELEGATE, _ct.c_void_p(target_ptr))
1484
1900
 
1485
1901
 
1486
1902
  class TextInputHandler(IOSViewHandler):
1487
- def create(self, props: Dict[str, Any]) -> Any:
1903
+ """Single-line `UITextField` or multiline `UITextView`.
1904
+
1905
+ The view class is chosen at creation time from the ``multiline``
1906
+ prop. Programmatic ``value`` updates set a suppress flag so the
1907
+ change events do not echo back into ``on_change``.
1908
+ """
1909
+
1910
+ def _build(self, props: Dict[str, Any]) -> Any:
1488
1911
  if props.get("multiline"):
1489
1912
  tv = ObjCClass("UITextView").alloc().init()
1490
1913
  tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
1914
+ tv.setFont_(UIFont.systemFontOfSize_(17.0))
1491
1915
  tv.setBackgroundColor_(_uicolor("#FFFFFF"))
1492
- self._apply_textview(tv, props)
1493
1916
  return tv
1494
1917
  tf = ObjCClass("UITextField").alloc().init()
1495
- tf.setBorderStyle_(2) # RoundedRect
1496
1918
  tf.setTranslatesAutoresizingMaskIntoConstraints_(True)
1497
- self._apply_textfield(tf, props)
1919
+ tf.setBorderStyle_(3) # UITextBorderStyleRoundedRect
1498
1920
  return tf
1499
1921
 
1500
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1501
- # Detect whether the underlying view is a UITextView (multiline).
1922
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
1923
+ view = super().create(tag, props)
1924
+ view.retain()
1925
+ _pn_retained_views.append(view)
1926
+ _attach_input_target(view, is_field=not props.get("multiline"))
1927
+ return view
1928
+
1929
+ def _teardown(self, native_view: Any) -> None:
1930
+ target_ptr = _state_of(native_view).get("tf_target_ptr")
1931
+ if target_ptr is not None:
1932
+ _pn_tf_target_map.pop(int(target_ptr), None)
1933
+
1934
+ def _is_field(self, view: Any) -> bool:
1502
1935
  try:
1503
- cls_name = str(native_view.objc_class.name)
1936
+ return bool(view.isKindOfClass_(ObjCClass("UITextField")))
1504
1937
  except Exception:
1505
- cls_name = ""
1506
- if "UITextView" in cls_name:
1507
- self._apply_textview(native_view, changed)
1508
- else:
1509
- self._apply_textfield(native_view, changed)
1938
+ return True
1510
1939
 
1511
- def _common_apply(self, view: Any, props: Dict[str, Any]) -> None:
1940
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1941
+ state = _state_of(view)
1942
+ is_field = self._is_field(view)
1943
+ if "max_length" in props:
1944
+ state["max_length"] = props["max_length"] if props["max_length"] is None else int(props["max_length"])
1945
+ if "value" in props and props["value"] is not None:
1946
+ current = str(view.text) if view.text is not None else ""
1947
+ new = str(props["value"])
1948
+ if current != new:
1949
+ state["suppress"] = True
1950
+ try:
1951
+ view.setText_(new)
1952
+ finally:
1953
+ state["suppress"] = False
1954
+ if "placeholder" in props and is_field:
1955
+ view.setPlaceholder_(str(props["placeholder"]) if props["placeholder"] is not None else "")
1956
+ if "placeholder_color" in props and props["placeholder_color"] is not None and is_field:
1957
+ try:
1958
+ NSAttributedString = ObjCClass("NSAttributedString")
1959
+ merged = state.get("props") or props
1960
+ p = str(merged.get("placeholder", "") or "")
1961
+ attr = NSAttributedString.alloc().initWithString_attributes_(
1962
+ p,
1963
+ {"NSColor": _uicolor(props["placeholder_color"])},
1964
+ )
1965
+ view.setAttributedPlaceholder_(attr)
1966
+ except Exception:
1967
+ pass
1512
1968
  if "font_size" in props and props["font_size"] is not None:
1513
1969
  view.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
1514
1970
  if "color" in props and props["color"] is not None:
1515
1971
  view.setTextColor_(_uicolor(props["color"]))
1516
1972
  if "background_color" in props and props["background_color"] is not None:
1517
1973
  view.setBackgroundColor_(_uicolor(props["background_color"]))
1518
- if "secure" in props and props["secure"]:
1519
- try:
1520
- view.setSecureTextEntry_(True)
1521
- except Exception:
1522
- pass
1523
- if "auto_capitalize" in props:
1524
- mapping = {"none": 0, "words": 1, "sentences": 2, "characters": 3}
1525
- try:
1526
- view.setAutocapitalizationType_(mapping.get(props["auto_capitalize"], 2))
1527
- except Exception:
1528
- pass
1529
- if "auto_correct" in props:
1974
+ if "secure" in props:
1530
1975
  try:
1531
- view.setAutocorrectionType_(0 if not props["auto_correct"] else 1)
1976
+ view.setSecureTextEntry_(bool(props["secure"]))
1532
1977
  except Exception:
1533
1978
  pass
1534
- if "keyboard_type" in props:
1979
+ if "keyboard_type" in props and props["keyboard_type"] is not None:
1535
1980
  mapping = {
1536
1981
  "default": 0,
1537
1982
  "ascii": 1,
@@ -1546,7 +1991,19 @@ class TextInputHandler(IOSViewHandler):
1546
1991
  view.setKeyboardType_(mapping.get(props["keyboard_type"], 0))
1547
1992
  except Exception:
1548
1993
  pass
1549
- if "return_key_type" in props:
1994
+ if "auto_capitalize" in props and props["auto_capitalize"] is not None:
1995
+ # UITextAutocapitalizationType: none=0, words=1, sentences=2, all=3.
1996
+ mapping = {"none": 0, "words": 1, "sentences": 2, "characters": 3}
1997
+ try:
1998
+ view.setAutocapitalizationType_(mapping.get(props["auto_capitalize"], 2))
1999
+ except Exception:
2000
+ pass
2001
+ if "auto_correct" in props:
2002
+ try:
2003
+ view.setAutocorrectionType_(1 if props["auto_correct"] else 0)
2004
+ except Exception:
2005
+ pass
2006
+ if "return_key_type" in props and props["return_key_type"] is not None:
1550
2007
  mapping = {
1551
2008
  "default": 0,
1552
2009
  "go": 1,
@@ -1574,6 +2031,28 @@ class TextInputHandler(IOSViewHandler):
1574
2031
  view.setTextContentType_(_ui_text_content_type(str(tct)) if tct is not None else None)
1575
2032
  except Exception:
1576
2033
  pass
2034
+ if "clear_button" in props and is_field:
2035
+ try:
2036
+ view.setClearButtonMode_(1 if props["clear_button"] else 0) # 1 = WhileEditing
2037
+ except Exception:
2038
+ pass
2039
+ # ``editable`` is present only when False (read-only). A removed
2040
+ # prop arrives as None on update, which means "editable again".
2041
+ if "editable" in props:
2042
+ editable = props["editable"]
2043
+ resolved = True if editable is None else bool(editable)
2044
+ try:
2045
+ if is_field:
2046
+ view.setEnabled_(resolved)
2047
+ else:
2048
+ view.setEditable_(resolved)
2049
+ except Exception:
2050
+ pass
2051
+ if "auto_focus" in props and props["auto_focus"]:
2052
+ try:
2053
+ view.becomeFirstResponder()
2054
+ except Exception:
2055
+ pass
1577
2056
  _apply_view_border(view, props)
1578
2057
  _apply_shadow(view, props)
1579
2058
  _apply_transform(view, props)
@@ -1584,197 +2063,312 @@ class TextInputHandler(IOSViewHandler):
1584
2063
  except Exception:
1585
2064
  pass
1586
2065
 
1587
- def _apply_textfield(self, tf: Any, props: Dict[str, Any]) -> None:
1588
- if "value" in props:
1589
- tf.setText_(str(props["value"]) if props["value"] is not None else "")
1590
- if "placeholder" in props:
1591
- tf.setPlaceholder_(str(props["placeholder"]) if props["placeholder"] is not None else "")
1592
- if "placeholder_color" in props and props["placeholder_color"] is not None:
2066
+ def measure_intrinsic(
2067
+ self,
2068
+ native_view: Any,
2069
+ max_width: float,
2070
+ max_height: float,
2071
+ ) -> Tuple[float, float]:
2072
+ w, h = super().measure_intrinsic(native_view, max_width, max_height)
2073
+ return (max(w, 100.0), max(h, 36.0))
2074
+
2075
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
2076
+ if name == "focus":
1593
2077
  try:
1594
- NSAttributedString = ObjCClass("NSAttributedString")
1595
- p = str(props.get("placeholder", "") or "")
1596
- attr = NSAttributedString.alloc().initWithString_attributes_(
1597
- p,
1598
- {"NSColor": _uicolor(props["placeholder_color"])},
1599
- )
1600
- tf.setAttributedPlaceholder_(attr)
2078
+ native_view.becomeFirstResponder()
1601
2079
  except Exception:
1602
2080
  pass
1603
- if "auto_focus" in props and props["auto_focus"]:
2081
+ return None
2082
+ if name == "blur":
1604
2083
  try:
1605
- tf.becomeFirstResponder()
2084
+ native_view.resignFirstResponder()
1606
2085
  except Exception:
1607
2086
  pass
1608
- if "max_length" in props:
2087
+ return None
2088
+ if name == "clear":
2089
+ state = _state_of(native_view)
2090
+ state["suppress"] = True
2091
+ try:
2092
+ native_view.setText_("")
2093
+ finally:
2094
+ state["suppress"] = False
2095
+ return None
2096
+ if name == "get_value":
1609
2097
  try:
1610
- tf.setMaxLength_(int(props["max_length"])) # custom; UIKit has no native max
2098
+ return str(native_view.text) if native_view.text is not None else ""
1611
2099
  except Exception:
1612
- pass
1613
- # ``editable`` is present only when False (read-only). A removed
1614
- # prop arrives as None on update, which we treat as "editable again".
1615
- if "editable" in props:
1616
- editable = props["editable"]
2100
+ return ""
2101
+ return None
2102
+
2103
+
2104
+ class SwitchHandler(IOSViewHandler):
2105
+ def _build(self, props: Dict[str, Any]) -> Any:
2106
+ sw = ObjCClass("UISwitch").alloc().init()
2107
+ sw.setTranslatesAutoresizingMaskIntoConstraints_(True)
2108
+ sw.retain()
2109
+ _pn_retained_views.append(sw)
2110
+
2111
+ def _on_toggle() -> None:
2112
+ if _state_of(sw).get("suppress"):
2113
+ return
1617
2114
  try:
1618
- tf.setEnabled_(True if editable is None else bool(editable))
2115
+ value = bool(sw.isOn())
1619
2116
  except Exception:
1620
- pass
1621
- if "clear_button" in props:
2117
+ return
2118
+ _fire(sw, "on_change", value)
2119
+
2120
+ _register_control_action(sw, 1 << 12, _on_toggle) # ValueChanged
2121
+ return sw
2122
+
2123
+ def _apply(self, sw: Any, props: Dict[str, Any], initial: bool) -> None:
2124
+ state = _state_of(sw)
2125
+ if "value" in props:
2126
+ new_val = bool(props["value"])
2127
+ if bool(sw.isOn()) != new_val:
2128
+ state["suppress"] = True
2129
+ try:
2130
+ sw.setOn_animated_(new_val, not initial)
2131
+ finally:
2132
+ state["suppress"] = False
2133
+ _apply_accessibility(sw, props)
2134
+
2135
+ def measure_intrinsic(
2136
+ self,
2137
+ native_view: Any,
2138
+ max_width: float,
2139
+ max_height: float,
2140
+ ) -> Tuple[float, float]:
2141
+ return (51.0, 31.0)
2142
+
2143
+
2144
+ class SliderHandler(IOSViewHandler):
2145
+ def _build(self, props: Dict[str, Any]) -> Any:
2146
+ sl = ObjCClass("UISlider").alloc().init()
2147
+ sl.setTranslatesAutoresizingMaskIntoConstraints_(True)
2148
+ sl.retain()
2149
+ _pn_retained_views.append(sl)
2150
+
2151
+ def _on_slide() -> None:
2152
+ if _state_of(sl).get("suppress"):
2153
+ return
1622
2154
  try:
1623
- tf.setClearButtonMode_(1 if props["clear_button"] else 0) # 1 = WhileEditing
2155
+ value = float(sl.value)
1624
2156
  except Exception:
1625
- pass
1626
- self._common_apply(tf, props)
1627
- # Always wire the action target — even without ``on_change`` /
1628
- # ``on_submit`` we want the textfield's delegate set so Return
1629
- # dismisses the keyboard (textFieldShouldReturn:) and focus/blur
1630
- # (textFieldDidBeginEditing: / textFieldDidEndEditing:) fire.
1631
- _attach_textfield_raw_target(tf, props)
2157
+ return
2158
+ _fire(sl, "on_change", value)
2159
+
2160
+ _register_control_action(sl, 1 << 12, _on_slide) # ValueChanged
2161
+ return sl
2162
+
2163
+ def _apply(self, sl: Any, props: Dict[str, Any], initial: bool) -> None:
2164
+ state = _state_of(sl)
2165
+ if "min_value" in props and props["min_value"] is not None:
2166
+ sl.setMinimumValue_(float(props["min_value"]))
2167
+ if "max_value" in props and props["max_value"] is not None:
2168
+ sl.setMaximumValue_(float(props["max_value"]))
2169
+ if "value" in props and props["value"] is not None:
2170
+ new_val = float(props["value"])
2171
+ if abs(float(sl.value) - new_val) > 1e-9:
2172
+ state["suppress"] = True
2173
+ try:
2174
+ sl.setValue_animated_(new_val, not initial)
2175
+ finally:
2176
+ state["suppress"] = False
2177
+ _apply_accessibility(sl, props)
2178
+
2179
+ def measure_intrinsic(
2180
+ self,
2181
+ native_view: Any,
2182
+ max_width: float,
2183
+ max_height: float,
2184
+ ) -> Tuple[float, float]:
2185
+ w = max_width if math.isfinite(max_width) else 200.0
2186
+ return (max(w, 100.0), 34.0)
2187
+
2188
+
2189
+ class ActivityIndicatorHandler(IOSViewHandler):
2190
+ def _build(self, props: Dict[str, Any]) -> Any:
2191
+ # Style: 100=medium, 101=large (iOS 13+).
2192
+ style = 101 if props.get("size") == "large" else 100
2193
+ ai = ObjCClass("UIActivityIndicatorView").alloc().initWithActivityIndicatorStyle_(style)
2194
+ ai.setTranslatesAutoresizingMaskIntoConstraints_(True)
2195
+ ai.setHidesWhenStopped_(True)
2196
+ return ai
1632
2197
 
1633
- def _apply_textview(self, tv: Any, props: Dict[str, Any]) -> None:
1634
- if "value" in props:
1635
- tv.setText_(str(props["value"]) if props["value"] is not None else "")
1636
- if "auto_focus" in props and props["auto_focus"]:
1637
- try:
1638
- tv.becomeFirstResponder()
1639
- except Exception:
1640
- pass
1641
- # ``editable`` (present only when False) maps to UITextView's own
1642
- # ``editable`` flag — there is no ``enabled`` on a non-UIControl.
1643
- if "editable" in props:
1644
- editable = props["editable"]
2198
+ def _apply(self, ai: Any, props: Dict[str, Any], initial: bool) -> None:
2199
+ if "size" in props and props["size"] is not None and not initial:
2200
+ style = 101 if props["size"] == "large" else 100
1645
2201
  try:
1646
- tv.setEditable_(True if editable is None else bool(editable))
2202
+ ai.setActivityIndicatorViewStyle_(style)
1647
2203
  except Exception:
1648
2204
  pass
1649
- self._common_apply(tv, props)
1650
- # NB: UITextView text-change events still go through the delegate's
1651
- # textViewDidChange: (deliberately left unwired — the multiline path
1652
- # is a pure display + manual `value` round-trip). We only set the
1653
- # delegate when on_focus / on_blur are requested so the begin/end
1654
- # editing callbacks can fire.
1655
- if "on_focus" in props or "on_blur" in props:
1656
- _attach_textview_raw_target(tv, props)
2205
+ if "color" in props and props["color"] is not None:
2206
+ ai.setColor_(_uicolor(props["color"]))
2207
+ animating = props.get("animating")
2208
+ if "animating" in props or initial:
2209
+ should_animate = True if animating is None else bool(animating)
2210
+ if should_animate:
2211
+ ai.startAnimating()
2212
+ else:
2213
+ ai.stopAnimating()
2214
+ _apply_accessibility(ai, props)
1657
2215
 
2216
+ def measure_intrinsic(
2217
+ self,
2218
+ native_view: Any,
2219
+ max_width: float,
2220
+ max_height: float,
2221
+ ) -> Tuple[float, float]:
2222
+ try:
2223
+ size = native_view.intrinsicContentSize()
2224
+ return (float(size.width), float(size.height))
2225
+ except Exception:
2226
+ return (20.0, 20.0)
1658
2227
 
1659
- class ImageHandler(IOSViewHandler):
1660
- def create(self, props: Dict[str, Any]) -> Any:
1661
- iv = ObjCClass("UIImageView").alloc().init()
1662
- iv.setTranslatesAutoresizingMaskIntoConstraints_(True)
1663
- self._apply(iv, props)
1664
- return iv
1665
2228
 
1666
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1667
- self._apply(native_view, changed)
2229
+ class PressableHandler(IOSViewHandler):
2230
+ """Touchable container with press feedback.
2231
+
2232
+ A `UILongPressGestureRecognizer` with ``minimumPressDuration=0``
2233
+ tracks the raw touch for ``on_press_in`` / ``on_press_out`` and the
2234
+ pressed-opacity feedback; a tap recognizer fires ``on_press`` and a
2235
+ standard long-press recognizer fires ``on_long_press``. All three
2236
+ share the simultaneous-recognition delegate. Declarative
2237
+ ``gestures`` from props are attached on top by the base class.
2238
+ """
1668
2239
 
1669
- def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
1670
- if "background_color" in props and props["background_color"] is not None:
1671
- iv.setBackgroundColor_(_uicolor(props["background_color"]))
1672
- if "tint_color" in props and props["tint_color"] is not None:
2240
+ def _build(self, props: Dict[str, Any]) -> Any:
2241
+ v = ObjCClass("UIView").alloc().init()
2242
+ v.setTranslatesAutoresizingMaskIntoConstraints_(True)
2243
+ v.setUserInteractionEnabled_(True)
2244
+ self._wire_press(v)
2245
+ return v
2246
+
2247
+ def _wire_press(self, view: Any) -> None:
2248
+ UITap = ObjCClass("UITapGestureRecognizer")
2249
+ UILong = ObjCClass("UILongPressGestureRecognizer")
2250
+
2251
+ tap = UITap.alloc().init()
2252
+ longp = UILong.alloc().init()
2253
+ # Zero-duration long press == raw touch-down / touch-up tracking.
2254
+ touch = UILong.alloc().init()
2255
+ touch.setMinimumPressDuration_(0.0)
2256
+
2257
+ def on_tap() -> None:
2258
+ _fire(view, "on_press")
2259
+
2260
+ def on_long() -> None:
2261
+ # UILongPressGestureRecognizer fires on every state
2262
+ # transition; only Began (1) counts as the trigger.
1673
2263
  try:
1674
- iv.setTintColor_(_uicolor(props["tint_color"]))
2264
+ state = int(longp.state)
1675
2265
  except Exception:
1676
- pass
1677
- if "source" in props and props["source"]:
1678
- self._load_source(iv, props["source"])
1679
- if "scale_type" in props and props["scale_type"]:
1680
- mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4}
1681
- iv.setContentMode_(mapping.get(props["scale_type"], 1))
1682
- _apply_view_border(iv, props)
1683
- _apply_shadow(iv, props)
1684
- _apply_transform(iv, props)
1685
- _apply_accessibility(iv, props)
1686
- if "opacity" in props and props["opacity"] is not None:
2266
+ state = 1
2267
+ if state == 1:
2268
+ _fire(view, "on_long_press")
2269
+
2270
+ def on_touch() -> None:
2271
+ try:
2272
+ state = int(touch.state)
2273
+ except Exception:
2274
+ return
2275
+ if state == 1: # began
2276
+ _press_feedback(view, True)
2277
+ _fire(view, "on_press_in")
2278
+ elif state in (3, 4, 5): # ended / cancelled / failed
2279
+ _press_feedback(view, False)
2280
+ _fire(view, "on_press_out")
2281
+
2282
+ for rec, handler in ((tap, on_tap), (longp, on_long), (touch, on_touch)):
1687
2283
  try:
1688
- iv.setAlpha_(float(props["opacity"]))
2284
+ rec.setCancelsTouchesInView_(False)
1689
2285
  except Exception:
1690
2286
  pass
2287
+ _set_recognizer_delegate(rec)
2288
+ view.addGestureRecognizer_(rec)
2289
+ rec.retain()
2290
+ _register_action(rec, handler)
1691
2291
 
1692
- def _load_source(self, iv: Any, source: str) -> None:
1693
- try:
1694
- if source.startswith(("http://", "https://")):
1695
- self._load_async(iv, source)
1696
- else:
1697
- UIImage = ObjCClass("UIImage")
1698
- image = UIImage.imageNamed_(source)
1699
- if image:
1700
- iv.setImage_(image)
1701
- except Exception:
1702
- pass
2292
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
2293
+ _apply_common_visual(view, props)
2294
+ if "enabled" in props:
2295
+ view.setUserInteractionEnabled_(props["enabled"] is not False)
1703
2296
 
1704
- def _load_async(self, iv: Any, source: str) -> None:
1705
- """Asynchronously load a remote image off the main thread.
1706
2297
 
1707
- Uses ``NSURLSession.sharedSession.dataTaskWithURL:completionHandler:``
1708
- so the main thread is never blocked. The completion handler
1709
- runs on a background queue; the image is set back on the main
1710
- queue via ``dispatch_async`` so UIKit accepts it without
1711
- threading warnings.
1712
- """
1713
- try:
1714
- iv.retain()
1715
- _pn_retained_views.append(iv)
1716
- NSURL = ObjCClass("NSURL")
1717
- NSURLSession = ObjCClass("NSURLSession")
1718
- UIImage = ObjCClass("UIImage")
1719
- url = NSURL.URLWithString_(source)
1720
- session = NSURLSession.sharedSession
2298
+ # ----------------------------------------------------------------------
2299
+ # UITextContentType constants
2300
+ # ----------------------------------------------------------------------
2301
+ #
2302
+ # ``textContentType`` powers iOS AutoFill (passwords, OTP codes, etc.).
2303
+ # The values are NSString *constants*, not literals, so we read the real
2304
+ # constants out of UIKit via ``objc_const`` instead of hardcoding their
2305
+ # string values.
2306
+ _TEXT_CONTENT_TYPE_SYMBOLS = {
2307
+ "username": "UITextContentTypeUsername",
2308
+ "password": "UITextContentTypePassword",
2309
+ "new_password": "UITextContentTypeNewPassword",
2310
+ "one_time_code": "UITextContentTypeOneTimeCode",
2311
+ "email": "UITextContentTypeEmailAddress",
2312
+ "email_address": "UITextContentTypeEmailAddress",
2313
+ "name": "UITextContentTypeName",
2314
+ "url": "UITextContentTypeURL",
2315
+ "telephone": "UITextContentTypeTelephoneNumber",
2316
+ "telephone_number": "UITextContentTypeTelephoneNumber",
2317
+ "phone": "UITextContentTypeTelephoneNumber",
2318
+ "phone_number": "UITextContentTypeTelephoneNumber",
2319
+ }
2320
+ _pn_text_content_type_cache: Dict[str, Any] = {}
1721
2321
 
1722
- def completion(data: Any, response: Any, error: Any) -> None:
1723
- if error is not None or data is None:
1724
- return
1725
- try:
1726
- image = UIImage.imageWithData_(data)
1727
- if image is None:
1728
- return
1729
2322
 
1730
- def apply() -> None:
1731
- try:
1732
- iv.setImage_(image)
1733
- except Exception:
1734
- pass
2323
+ def _ui_text_content_type(name: str) -> Any:
2324
+ """Resolve a content-type name to its ``UITextContentType`` constant.
1735
2325
 
1736
- # Marshal back to main thread.
1737
- try:
1738
- from rubicon.objc import dispatch_async, dispatch_get_main_queue
2326
+ Returns the NSString constant (an ``ObjCInstance``) for a known name,
2327
+ or ``None`` for an unknown name / lookup failure (in which case the
2328
+ caller should simply leave the content type unset).
2329
+ """
2330
+ symbol = _TEXT_CONTENT_TYPE_SYMBOLS.get(name.strip().lower())
2331
+ if not symbol:
2332
+ return None
2333
+ if symbol in _pn_text_content_type_cache:
2334
+ return _pn_text_content_type_cache[symbol]
2335
+ value = None
2336
+ try:
2337
+ from rubicon.objc.api import objc_const
1739
2338
 
1740
- dispatch_async(dispatch_get_main_queue(), apply)
1741
- except Exception:
1742
- try:
1743
- apply()
1744
- except Exception:
1745
- pass
1746
- except Exception:
1747
- pass
2339
+ uikit = _ct.cdll.LoadLibrary("/System/Library/Frameworks/UIKit.framework/UIKit")
2340
+ value = objc_const(uikit, symbol)
2341
+ except Exception:
2342
+ value = None
2343
+ _pn_text_content_type_cache[symbol] = value
2344
+ return value
1748
2345
 
1749
- task = session.dataTaskWithURL_completionHandler_(url, completion)
1750
- task.resume()
1751
- except Exception:
1752
- pass
1753
2346
 
2347
+ # ----------------------------------------------------------------------
2348
+ # Pressable feedback
2349
+ # ----------------------------------------------------------------------
1754
2350
 
1755
- class SwitchHandler(IOSViewHandler):
1756
- def create(self, props: Dict[str, Any]) -> Any:
1757
- sw = ObjCClass("UISwitch").alloc().init()
1758
- sw.setTranslatesAutoresizingMaskIntoConstraints_(True)
1759
- self._apply(sw, props)
1760
- return sw
1761
2351
 
1762
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1763
- self._apply(native_view, changed)
2352
+ def _press_feedback(view: Any, pressed: bool) -> None:
2353
+ """Animate the pressed-opacity visual feedback."""
2354
+ try:
2355
+ merged = _state_of(view).get("props") or {}
2356
+ if pressed:
2357
+ opacity = float(merged.get("pressed_opacity", 0.6))
2358
+ duration = 0.05
2359
+ else:
2360
+ raw = merged.get("opacity")
2361
+ opacity = float(raw) if raw is not None else 1.0
2362
+ duration = 0.1
2363
+ UIView = ObjCClass("UIView")
2364
+ UIView.animateWithDuration_animations_(duration, lambda: view.setAlpha_(opacity))
2365
+ except Exception:
2366
+ pass
1764
2367
 
1765
- def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
1766
- if "value" in props:
1767
- sw.setOn_animated_(bool(props["value"]), False)
1768
- _apply_accessibility(sw, props)
1769
- if "on_change" in props:
1770
- existing = _pn_switch_handler_map.get(id(sw))
1771
- if existing is not None:
1772
- existing._callback = props["on_change"]
1773
- else:
1774
- handler = _PNSwitchTarget.new()
1775
- handler._callback = props["on_change"]
1776
- _pn_switch_handler_map[id(sw)] = handler
1777
- sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12)
2368
+
2369
+ # ======================================================================
2370
+ # ProgressBar
2371
+ # ======================================================================
1778
2372
 
1779
2373
 
1780
2374
  class ProgressBarHandler(IOSViewHandler):
@@ -1782,17 +2376,15 @@ class ProgressBarHandler(IOSViewHandler):
1782
2376
 
1783
2377
  ``UIProgressView`` has no indeterminate mode, so when
1784
2378
  ``indeterminate`` is set the handler instead creates an animating
1785
- ``UIActivityIndicatorView`` the simplest, crash-free way to convey
1786
- open-ended progress. The view type is chosen at ``create`` time;
1787
- toggling ``indeterminate`` on an existing bar keeps the original
1788
- view (a deliberate, safe limitation).
2379
+ ``UIActivityIndicatorView``. The view type is chosen at create
2380
+ time; toggling ``indeterminate`` on an existing bar keeps the
2381
+ original view (a deliberate, safe limitation).
1789
2382
  """
1790
2383
 
1791
- def create(self, props: Dict[str, Any]) -> Any:
2384
+ def _build(self, props: Dict[str, Any]) -> Any:
1792
2385
  if props.get("indeterminate"):
1793
2386
  ai = ObjCClass("UIActivityIndicatorView").alloc().init()
1794
2387
  ai.setTranslatesAutoresizingMaskIntoConstraints_(True)
1795
- self._apply_indeterminate(ai, props)
1796
2388
  try:
1797
2389
  ai.startAnimating()
1798
2390
  except Exception:
@@ -1800,86 +2392,61 @@ class ProgressBarHandler(IOSViewHandler):
1800
2392
  return ai
1801
2393
  pv = ObjCClass("UIProgressView").alloc().init()
1802
2394
  pv.setTranslatesAutoresizingMaskIntoConstraints_(True)
1803
- self._apply_determinate(pv, props)
1804
2395
  return pv
1805
2396
 
1806
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2397
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
1807
2398
  try:
1808
- cls_name = str(native_view.objc_class.name)
2399
+ cls_name = str(view.objc_class.name)
1809
2400
  except Exception:
1810
2401
  cls_name = ""
1811
2402
  if "UIActivityIndicatorView" in cls_name:
1812
- self._apply_indeterminate(native_view, changed)
1813
- else:
1814
- self._apply_determinate(native_view, changed)
1815
-
1816
- def _apply_determinate(self, pv: Any, props: Dict[str, Any]) -> None:
1817
- if "value" in props and props["value"] is not None:
1818
- try:
1819
- pv.setProgress_(float(props["value"]))
1820
- except Exception:
1821
- pass
1822
- if "color" in props and props["color"] is not None:
1823
- try:
1824
- pv.setProgressTintColor_(_uicolor(props["color"]))
1825
- except Exception:
1826
- pass
1827
- if "track_color" in props and props["track_color"] is not None:
2403
+ if "color" in props and props["color"] is not None:
2404
+ try:
2405
+ view.setColor_(_uicolor(props["color"]))
2406
+ except Exception:
2407
+ pass
1828
2408
  try:
1829
- pv.setTrackTintColor_(_uicolor(props["track_color"]))
2409
+ view.startAnimating()
1830
2410
  except Exception:
1831
2411
  pass
2412
+ else:
2413
+ if "value" in props and props["value"] is not None:
2414
+ try:
2415
+ view.setProgress_(float(props["value"]))
2416
+ except Exception:
2417
+ pass
2418
+ if "color" in props and props["color"] is not None:
2419
+ try:
2420
+ view.setProgressTintColor_(_uicolor(props["color"]))
2421
+ except Exception:
2422
+ pass
2423
+ if "track_color" in props and props["track_color"] is not None:
2424
+ try:
2425
+ view.setTrackTintColor_(_uicolor(props["track_color"]))
2426
+ except Exception:
2427
+ pass
2428
+ _apply_accessibility(view, props)
1832
2429
 
1833
- def _apply_indeterminate(self, ai: Any, props: Dict[str, Any]) -> None:
1834
- if "color" in props and props["color"] is not None:
1835
- try:
1836
- ai.setColor_(_uicolor(props["color"]))
1837
- except Exception:
1838
- pass
2430
+ def measure_intrinsic(
2431
+ self,
2432
+ native_view: Any,
2433
+ max_width: float,
2434
+ max_height: float,
2435
+ ) -> Tuple[float, float]:
1839
2436
  try:
1840
- ai.startAnimating()
2437
+ cls_name = str(native_view.objc_class.name)
1841
2438
  except Exception:
1842
- pass
1843
-
1844
-
1845
- class ActivityIndicatorHandler(IOSViewHandler):
1846
- def create(self, props: Dict[str, Any]) -> Any:
1847
- ai = ObjCClass("UIActivityIndicatorView").alloc().init()
1848
- ai.setTranslatesAutoresizingMaskIntoConstraints_(True)
1849
- self._apply_style(ai, props)
1850
- self._apply_color(ai, props)
1851
- if props.get("animating", True):
1852
- ai.startAnimating()
1853
- return ai
1854
-
1855
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1856
- self._apply_style(native_view, changed)
1857
- self._apply_color(native_view, changed)
1858
- if "animating" in changed:
1859
- if changed["animating"]:
1860
- native_view.startAnimating()
1861
- else:
1862
- native_view.stopAnimating()
1863
-
1864
- def _apply_style(self, ai: Any, props: Dict[str, Any]) -> None:
1865
- if "size" in props and props["size"] is not None:
1866
- # UIActivityIndicatorViewStyle (iOS 13+): medium = 100, large = 101.
1867
- style = 101 if props["size"] == "large" else 100
1868
- try:
1869
- ai.setActivityIndicatorViewStyle_(style)
1870
- except Exception:
1871
- pass
2439
+ cls_name = ""
2440
+ if "UIActivityIndicatorView" in cls_name:
2441
+ return (20.0, 20.0)
2442
+ w = max_width if math.isfinite(max_width) else 200.0
2443
+ return (max(w, 40.0), 4.0)
1872
2444
 
1873
- def _apply_color(self, ai: Any, props: Dict[str, Any]) -> None:
1874
- if "color" in props and props["color"] is not None:
1875
- try:
1876
- ai.setColor_(_uicolor(props["color"]))
1877
- except Exception:
1878
- pass
1879
2445
 
2446
+ # ======================================================================
2447
+ # WebView — WKWebView with navigation + script-message delegates
2448
+ # ======================================================================
1880
2449
 
1881
- # Maps ``id(delegate)`` -> ``{"on_load", "on_nav", "on_message", "inject_js"}``.
1882
- _pn_webview_state: dict = {}
1883
2450
  # WKWebView.scrollView isn't auto-detected as a property by rubicon, so it
1884
2451
  # must be declared once (lazily, to avoid forcing a WebKit load at import).
1885
2452
  _pn_wkwebview_declared = False
@@ -1896,75 +2463,110 @@ def _webview_url(webview: Any) -> str:
1896
2463
  return ""
1897
2464
 
1898
2465
 
1899
- class _PNWebViewDelegate(NSObject): # type: ignore[valid-type]
1900
- """WKNavigationDelegate + WKScriptMessageHandler bridge.
2466
+ # WKNavigationDelegate + WKScriptMessageHandler bridge. WebKit passes
2467
+ # object arguments (``WKNavigation*`` / ``WKScriptMessage*``) to these
2468
+ # delegate callbacks, which rubicon's ``@objc_method`` FFI bridge
2469
+ # mismarshals on iOS 18.x — the app dies with EXC_BAD_ACCESS inside
2470
+ # ``objc_msgSend`` (see the module header note). Like the scroll and
2471
+ # tab-bar delegates we therefore build the class with raw libobjc and
2472
+ # CFUNCTYPE IMPs, keep per-delegate state keyed by the delegate
2473
+ # *pointer*, and only touch the retained rubicon webview from Python.
1901
2474
 
1902
- Forwards page-load / navigation events to ``on_load`` /
1903
- ``on_navigation_state_change``, runs ``inject_javascript`` after each
1904
- load, and surfaces ``window.webkit.messageHandlers.pythonnative``
1905
- posts through ``on_message``.
1906
- """
2475
+ # Maps delegate ptr -> {"view": rubicon WKWebView, "inject_js": str|None}.
2476
+ _pn_webview_state: Dict[int, Dict[str, Any]] = {}
1907
2477
 
1908
- @objc_method
1909
- def webView_didFinishNavigation_(self, webview: object, navigation: object) -> None:
1910
- info = _pn_webview_state.get(id(self))
1911
- if not info:
1912
- return
1913
- js = info.get("inject_js")
1914
- if js:
1915
- try:
1916
- webview.evaluateJavaScript_completionHandler_(str(js), None)
1917
- except Exception:
1918
- pass
1919
- cb = info.get("on_load")
1920
- if cb is not None:
1921
- try:
1922
- cb(_webview_url(webview))
1923
- except Exception:
1924
- pass
2478
+ _WEBVIEW_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
1925
2479
 
1926
- @objc_method
1927
- def webView_didStartProvisionalNavigation_(self, webview: object, navigation: object) -> None:
1928
- info = _pn_webview_state.get(id(self))
1929
- if not info:
1930
- return
1931
- cb = info.get("on_nav")
1932
- if cb is not None:
1933
- try:
1934
- cb(_webview_url(webview))
1935
- except Exception:
1936
- pass
1937
2480
 
1938
- @objc_method
1939
- def userContentController_didReceiveScriptMessage_(self, controller: object, message: object) -> None:
1940
- info = _pn_webview_state.get(id(self))
1941
- if not info:
1942
- return
1943
- cb = info.get("on_message")
1944
- if cb is None:
1945
- return
1946
- body = ""
1947
- try:
1948
- raw = message.body
1949
- body = str(raw) if raw is not None else ""
1950
- except Exception:
1951
- body = ""
2481
+ def _webview_did_finish_imp(self_ptr: int, _cmd_ptr: int, _webview_ptr: int, _nav_ptr: int) -> None:
2482
+ """Raw C callback for ``webView:didFinishNavigation:``."""
2483
+ info = _pn_webview_state.get(int(self_ptr or 0))
2484
+ if not info:
2485
+ return
2486
+ wv = info.get("view")
2487
+ if wv is None:
2488
+ return
2489
+ js = info.get("inject_js")
2490
+ if js:
1952
2491
  try:
1953
- cb(body)
2492
+ wv.evaluateJavaScript_completionHandler_(str(js), None)
1954
2493
  except Exception:
1955
2494
  pass
2495
+ _fire(wv, "on_load", _webview_url(wv))
2496
+
2497
+
2498
+ def _webview_did_start_imp(self_ptr: int, _cmd_ptr: int, _webview_ptr: int, _nav_ptr: int) -> None:
2499
+ """Raw C callback for ``webView:didStartProvisionalNavigation:``."""
2500
+ info = _pn_webview_state.get(int(self_ptr or 0))
2501
+ if not info:
2502
+ return
2503
+ wv = info.get("view")
2504
+ if wv is not None:
2505
+ _fire(wv, "on_navigation_state_change", _webview_url(wv))
2506
+
2507
+
2508
+ def _webview_script_message_imp(self_ptr: int, _cmd_ptr: int, _controller_ptr: int, message_ptr: int) -> None:
2509
+ """Raw C callback for ``userContentController:didReceiveScriptMessage:``."""
2510
+ info = _pn_webview_state.get(int(self_ptr or 0))
2511
+ if not info:
2512
+ return
2513
+ wv = info.get("view")
2514
+ if wv is None:
2515
+ return
2516
+ body = ""
2517
+ try:
2518
+ # Wrapping the raw pointer ourselves (outbound rubicon call) is
2519
+ # safe; it's the @objc_method *callback* marshaling that breaks.
2520
+ message = ObjCInstance(_ct.c_void_p(message_ptr))
2521
+ raw = message.body
2522
+ body = str(raw) if raw is not None else ""
2523
+ except Exception:
2524
+ body = ""
2525
+ _fire(wv, "on_message", body)
2526
+
2527
+
2528
+ _webview_did_finish_imp_ref = _WEBVIEW_IMP_TYPE(_webview_did_finish_imp)
2529
+ _webview_did_start_imp_ref = _WEBVIEW_IMP_TYPE(_webview_did_start_imp)
2530
+ _webview_script_message_imp_ref = _WEBVIEW_IMP_TYPE(_webview_script_message_imp)
2531
+
2532
+ _PN_WEBVIEW_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNWebViewDelegateCTypes", 0)
2533
+ if _PN_WEBVIEW_DELEGATE_CLS:
2534
+ for sel_name, imp_ref in (
2535
+ (b"webView:didFinishNavigation:", _webview_did_finish_imp_ref),
2536
+ (b"webView:didStartProvisionalNavigation:", _webview_did_start_imp_ref),
2537
+ (b"userContentController:didReceiveScriptMessage:", _webview_script_message_imp_ref),
2538
+ ):
2539
+ _add_method(
2540
+ _PN_WEBVIEW_DELEGATE_CLS,
2541
+ _sel_reg(sel_name),
2542
+ _ct.cast(imp_ref, _ct.c_void_p),
2543
+ b"v@:@@",
2544
+ )
2545
+ _reg_cls(_PN_WEBVIEW_DELEGATE_CLS)
2546
+
2547
+
2548
+ def _new_webview_delegate_ptr() -> Optional[int]:
2549
+ """Alloc/init/retain one raw ``_PNWebViewDelegateCTypes`` instance."""
2550
+ if not _PN_WEBVIEW_DELEGATE_CLS:
2551
+ return None
2552
+ _objc_msgSend.restype = _ct.c_void_p
2553
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
2554
+ d = _objc_msgSend(_PN_WEBVIEW_DELEGATE_CLS, _SEL_ALLOC)
2555
+ d = _objc_msgSend(d, _SEL_INIT)
2556
+ d = _objc_msgSend(d, _SEL_RETAIN)
2557
+ return int(d) if d else None
1956
2558
 
1957
2559
 
1958
2560
  class WebViewHandler(IOSViewHandler):
1959
- def create(self, props: Dict[str, Any]) -> Any:
2561
+ def _build(self, props: Dict[str, Any]) -> Any:
1960
2562
  global _pn_wkwebview_declared
1961
2563
  WKWebView = ObjCClass("WKWebView")
1962
2564
  WKWebViewConfiguration = ObjCClass("WKWebViewConfiguration")
1963
2565
  if not _pn_wkwebview_declared:
1964
2566
  # Some WebKit @property declarations aren't auto-detected by
1965
2567
  # rubicon's runtime introspection (same class of issue as
1966
- # UIView.superview above); declare the ones we read so attribute
1967
- # access returns the object instead of a bound method.
2568
+ # UIView.superview above); declare the ones we read so
2569
+ # attribute access returns the object instead of a method.
1968
2570
  for cls, prop in (
1969
2571
  (WKWebView, "scrollView"),
1970
2572
  (WKWebView, "URL"),
@@ -1976,58 +2578,57 @@ class WebViewHandler(IOSViewHandler):
1976
2578
  pass
1977
2579
  _pn_wkwebview_declared = True
1978
2580
  config = WKWebViewConfiguration.alloc().init()
1979
- delegate = _PNWebViewDelegate.new()
1980
- delegate.retain()
1981
- _pn_retained_views.append(delegate)
1982
- _pn_webview_state[id(delegate)] = {
1983
- "on_load": props.get("on_load"),
1984
- "on_nav": props.get("on_navigation_state_change"),
1985
- "on_message": props.get("on_message"),
1986
- "inject_js": props.get("inject_javascript"),
1987
- }
2581
+ delegate_ptr = _new_webview_delegate_ptr()
2582
+ delegate = ObjCInstance(_ct.c_void_p(delegate_ptr)) if delegate_ptr else None
1988
2583
  # Register the message handler up front so page JS calling
1989
- # ``window.webkit.messageHandlers.pythonnative.postMessage(x)`` can
1990
- # reach ``on_message`` even if it is wired in a later update().
1991
- try:
1992
- config.userContentController.addScriptMessageHandler_name_(delegate, "pythonnative")
1993
- except Exception:
1994
- pass
2584
+ # ``window.webkit.messageHandlers.pythonnative.postMessage(x)``
2585
+ # can reach ``on_message`` even if it's wired in a later render.
2586
+ if delegate is not None:
2587
+ try:
2588
+ config.userContentController.addScriptMessageHandler_name_(delegate, "pythonnative")
2589
+ except Exception:
2590
+ pass
1995
2591
  wv = WKWebView.alloc().initWithFrame_configuration_(((0, 0), (0, 0)), config)
1996
- wv.setTranslatesAutoresizingMaskIntoConstraints_(True)
1997
- try:
1998
- wv.setNavigationDelegate_(delegate)
1999
- except Exception:
2000
- pass
2001
- wv._pn_webview_delegate_id = id(delegate)
2002
- self._apply_content(wv, props)
2003
- self._apply_scroll_enabled(wv, props)
2592
+ wv.setTranslatesAutoresizingMaskIntoConstraints_(True)
2593
+ if delegate is not None:
2594
+ try:
2595
+ wv.setNavigationDelegate_(delegate)
2596
+ except Exception:
2597
+ pass
2598
+ if delegate_ptr:
2599
+ _pn_webview_state[delegate_ptr] = {"view": wv, "inject_js": None}
2600
+ self._delegate_ids[_objc_ptr(wv) or 0] = delegate_ptr
2004
2601
  return wv
2005
2602
 
2006
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2007
- delegate_id = getattr(native_view, "_pn_webview_delegate_id", None)
2008
- if delegate_id is not None and delegate_id in _pn_webview_state:
2009
- info = _pn_webview_state[delegate_id]
2010
- if "on_load" in changed:
2011
- info["on_load"] = changed["on_load"]
2012
- if "on_navigation_state_change" in changed:
2013
- info["on_nav"] = changed["on_navigation_state_change"]
2014
- if "on_message" in changed:
2015
- info["on_message"] = changed["on_message"]
2016
- if "inject_javascript" in changed:
2017
- info["inject_js"] = changed["inject_javascript"]
2018
- self._apply_content(native_view, changed)
2019
- if "scroll_enabled" in changed:
2020
- self._apply_scroll_enabled(native_view, changed)
2021
-
2022
- def _apply_content(self, wv: Any, props: Dict[str, Any]) -> None:
2603
+ _delegate_ids: Dict[int, int] = {}
2604
+
2605
+ def create(self, tag: int, props: Dict[str, Any]) -> Any:
2606
+ view = super().create(tag, props)
2607
+ delegate_id = self._delegate_ids.pop(_objc_ptr(view) or 0, None)
2608
+ if delegate_id is not None:
2609
+ _state_of(view)["webview_delegate_id"] = delegate_id
2610
+ if props.get("inject_javascript"):
2611
+ _pn_webview_state[delegate_id]["inject_js"] = props["inject_javascript"]
2612
+ return view
2613
+
2614
+ def _teardown(self, native_view: Any) -> None:
2615
+ delegate_id = _state_of(native_view).get("webview_delegate_id")
2616
+ if delegate_id is not None:
2617
+ _pn_webview_state.pop(delegate_id, None)
2618
+
2619
+ def _apply(self, wv: Any, props: Dict[str, Any], initial: bool) -> None:
2620
+ delegate_id = _state_of(wv).get("webview_delegate_id")
2621
+ if delegate_id is not None and "inject_javascript" in props:
2622
+ info = _pn_webview_state.get(delegate_id)
2623
+ if info is not None:
2624
+ info["inject_js"] = props["inject_javascript"]
2023
2625
  # ``html`` wins over ``url`` (matches the component contract).
2024
2626
  if "html" in props and props["html"]:
2025
2627
  try:
2026
2628
  wv.loadHTMLString_baseURL_(str(props["html"]), None)
2027
- return
2028
2629
  except Exception:
2029
2630
  pass
2030
- if "url" in props and props["url"]:
2631
+ elif "url" in props and props["url"]:
2031
2632
  try:
2032
2633
  NSURL = ObjCClass("NSURL")
2033
2634
  NSURLRequest = ObjCClass("NSURLRequest")
@@ -2035,8 +2636,6 @@ class WebViewHandler(IOSViewHandler):
2035
2636
  wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
2036
2637
  except Exception:
2037
2638
  pass
2038
-
2039
- def _apply_scroll_enabled(self, wv: Any, props: Dict[str, Any]) -> None:
2040
2639
  if "scroll_enabled" in props:
2041
2640
  enabled = props["scroll_enabled"]
2042
2641
  try:
@@ -2044,6 +2643,38 @@ class WebViewHandler(IOSViewHandler):
2044
2643
  except Exception:
2045
2644
  pass
2046
2645
 
2646
+ def command(self, native_view: Any, name: str, args: Dict[str, Any]) -> Any:
2647
+ if name == "eval_js":
2648
+ try:
2649
+ native_view.evaluateJavaScript_completionHandler_(str(args.get("source", "")), None)
2650
+ except Exception:
2651
+ pass
2652
+ return None
2653
+ if name == "reload":
2654
+ try:
2655
+ native_view.reload()
2656
+ except Exception:
2657
+ pass
2658
+ return None
2659
+ if name == "go_back":
2660
+ try:
2661
+ native_view.goBack()
2662
+ except Exception:
2663
+ pass
2664
+ return None
2665
+ if name == "go_forward":
2666
+ try:
2667
+ native_view.goForward()
2668
+ except Exception:
2669
+ pass
2670
+ return None
2671
+ return None
2672
+
2673
+
2674
+ # ======================================================================
2675
+ # Spacer / SafeAreaView
2676
+ # ======================================================================
2677
+
2047
2678
 
2048
2679
  class SpacerHandler(IOSViewHandler):
2049
2680
  """Empty layout placeholder used as a flexible gap.
@@ -2052,35 +2683,23 @@ class SpacerHandler(IOSViewHandler):
2052
2683
  behaves identically to a `View` with the same style props.
2053
2684
  """
2054
2685
 
2055
- def create(self, props: Dict[str, Any]) -> Any:
2686
+ def _build(self, props: Dict[str, Any]) -> Any:
2056
2687
  v = ObjCClass("UIView").alloc().init()
2057
2688
  v.setTranslatesAutoresizingMaskIntoConstraints_(True)
2058
2689
  return v
2059
2690
 
2060
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2691
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
2061
2692
  pass
2062
2693
 
2063
2694
 
2064
2695
  class SafeAreaViewHandler(IOSViewHandler):
2065
- def create(self, props: Dict[str, Any]) -> Any:
2696
+ """Plain container; safe-area insets are applied by the layout engine."""
2697
+
2698
+ def _build(self, props: Dict[str, Any]) -> Any:
2066
2699
  v = ObjCClass("UIView").alloc().init()
2067
2700
  v.setTranslatesAutoresizingMaskIntoConstraints_(True)
2068
- _apply_common_visual(v, props)
2069
2701
  return v
2070
2702
 
2071
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2072
- _apply_common_visual(native_view, changed)
2073
-
2074
- def add_child(self, parent: Any, child: Any) -> None:
2075
- try:
2076
- child.setTranslatesAutoresizingMaskIntoConstraints_(True)
2077
- except Exception:
2078
- pass
2079
- parent.addSubview_(child)
2080
-
2081
- def remove_child(self, parent: Any, child: Any) -> None:
2082
- child.removeFromSuperview()
2083
-
2084
2703
 
2085
2704
  # ======================================================================
2086
2705
  # Modal — actually presents a UIViewController
@@ -2091,88 +2710,76 @@ class ModalHandler(IOSViewHandler):
2091
2710
  """Real modal presentation backed by a presented `UIViewController`.
2092
2711
 
2093
2712
  The on-tree placeholder is a hidden ``UIView`` (so the layout
2094
- engine can ignore it). When ``visible`` flips to ``True``, a
2095
- fresh ``UIViewController`` is allocated, its view is configured
2096
- as the container into which the modal's children mount, and the
2097
- controller is presented from the topmost view controller.
2713
+ engine can ignore it). When ``visible`` flips to ``True``, a fresh
2714
+ ``UIViewController`` is allocated, its view is configured as the
2715
+ container into which the modal's children mount, and the controller
2716
+ is presented from the topmost view controller.
2098
2717
 
2099
2718
  Children are added to the *content view* of the presented
2100
2719
  controller, not the on-tree placeholder, so the reconciler's
2101
- ``add_child`` / ``insert_child`` calls are forwarded there.
2720
+ ``insert_child`` / ``remove_child`` calls are forwarded there.
2102
2721
  """
2103
2722
 
2104
- def create(self, props: Dict[str, Any]) -> Any:
2723
+ def _build(self, props: Dict[str, Any]) -> Any:
2105
2724
  v = ObjCClass("UIView").alloc().init()
2106
2725
  v.setHidden_(True)
2107
- v._pn_modal_state = None
2108
- self._apply(v, props, mounting=True)
2109
2726
  return v
2110
2727
 
2111
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2112
- self._apply(native_view, changed, mounting=False)
2728
+ def _apply(self, placeholder: Any, props: Dict[str, Any], initial: bool) -> None:
2729
+ state = _state_of(placeholder)
2730
+ merged = state.get("props") or props
2731
+ visible = bool(merged.get("visible", False))
2732
+ presented = state.get("modal") is not None
2733
+ if visible and not presented:
2734
+ self._present(placeholder, merged)
2735
+ elif not visible and presented:
2736
+ self._dismiss(placeholder)
2737
+
2738
+ def _teardown(self, native_view: Any) -> None:
2739
+ if _state_of(native_view).get("modal") is not None:
2740
+ self._dismiss(native_view)
2113
2741
 
2114
- def add_child(self, parent: Any, child: Any) -> None:
2115
- # Forward to the modal content view if present.
2116
- state = _pn_modal_states.get(id(parent))
2117
- if state and state.get("content_view") is not None:
2742
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
2743
+ state = _state_of(parent)
2744
+ modal = state.get("modal")
2745
+ if modal is not None:
2118
2746
  try:
2119
2747
  child.setTranslatesAutoresizingMaskIntoConstraints_(True)
2120
2748
  except Exception:
2121
2749
  pass
2122
- state["content_view"].addSubview_(child)
2750
+ try:
2751
+ content = modal["content_view"]
2752
+ count = len(list(content.subviews or []))
2753
+ content.insertSubview_atIndex_(child, max(0, min(index, count)))
2754
+ except Exception:
2755
+ pass
2123
2756
  else:
2124
- # Buffer for later, once the modal becomes visible.
2125
- buf = _pn_modal_pending.setdefault(id(parent), [])
2126
- buf.append(child)
2757
+ buf = state.setdefault("pending_children", [])
2758
+ buf.insert(max(0, min(index, len(buf))), child)
2127
2759
 
2128
2760
  def remove_child(self, parent: Any, child: Any) -> None:
2129
2761
  try:
2130
2762
  child.removeFromSuperview()
2131
2763
  except Exception:
2132
2764
  pass
2133
- buf = _pn_modal_pending.get(id(parent))
2765
+ buf = _state_of(parent).get("pending_children")
2134
2766
  if buf and child in buf:
2135
2767
  buf.remove(child)
2136
2768
 
2137
- def insert_child(self, parent: Any, child: Any, index: int) -> None:
2138
- state = _pn_modal_states.get(id(parent))
2139
- if state and state.get("content_view") is not None:
2140
- try:
2141
- child.setTranslatesAutoresizingMaskIntoConstraints_(True)
2142
- except Exception:
2143
- pass
2144
- state["content_view"].insertSubview_atIndex_(child, index)
2145
- else:
2146
- buf = _pn_modal_pending.setdefault(id(parent), [])
2147
- buf.insert(index, child)
2148
-
2149
2769
  def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2150
2770
  # Modal is a virtual placeholder — not rendered inline.
2151
2771
  return
2152
2772
 
2153
- def _apply(self, placeholder: Any, props: Dict[str, Any], *, mounting: bool) -> None:
2154
- # Accumulate props across renders. Presentation is frequently
2155
- # triggered by an update whose ``changed`` dict carries only
2156
- # ``visible``; merging lets ``_present`` still see config props
2157
- # (presentation_style, on_show, dismiss_on_backdrop, on_dismiss)
2158
- # that were set on an earlier render.
2159
- merged = _pn_modal_props.setdefault(id(placeholder), {})
2160
- for key, value in props.items():
2161
- if value is None:
2162
- merged.pop(key, None)
2163
- else:
2164
- merged[key] = value
2165
- visible = bool(merged.get("visible", False))
2166
- state = _pn_modal_states.get(id(placeholder))
2167
- if visible and state is None:
2168
- self._present(placeholder, merged)
2169
- elif not visible and state is not None:
2170
- self._dismiss(placeholder)
2171
- elif visible and state is not None:
2172
- # Already presented; refresh the on_dismiss callback.
2173
- state["on_dismiss"] = merged.get("on_dismiss")
2773
+ def measure_intrinsic(
2774
+ self,
2775
+ native_view: Any,
2776
+ max_width: float,
2777
+ max_height: float,
2778
+ ) -> Tuple[float, float]:
2779
+ return (0.0, 0.0)
2174
2780
 
2175
2781
  def _present(self, placeholder: Any, props: Dict[str, Any]) -> None:
2782
+ state = _state_of(placeholder)
2176
2783
  try:
2177
2784
  UIViewController = ObjCClass("UIViewController")
2178
2785
  UIApplication = ObjCClass("UIApplication")
@@ -2190,7 +2797,6 @@ class ModalHandler(IOSViewHandler):
2190
2797
  content.setTranslatesAutoresizingMaskIntoConstraints_(True)
2191
2798
  controller.view.addSubview_(content)
2192
2799
  controller.view.setBackgroundColor_(_uicolor("#66000000" if is_overlay else "#FFFFFF"))
2193
- # Stretch the content view to the controller's view.
2194
2800
  try:
2195
2801
  bounds = controller.view.bounds
2196
2802
  content.setFrame_(((0, 0), (bounds.size.width, bounds.size.height)))
@@ -2199,165 +2805,56 @@ class ModalHandler(IOSViewHandler):
2199
2805
  pass
2200
2806
 
2201
2807
  # UIModalPresentationStyle: fullScreen=0, pageSheet=1,
2202
- # formSheet=2, overCurrentContext=6 (used for the dimmed overlay).
2808
+ # formSheet=2, overCurrentContext=6 (the dimmed overlay).
2203
2809
  style_map = {"full_screen": 0, "page_sheet": 1, "form_sheet": 2, "overlay": 6}
2204
2810
  style_int = 6 if is_overlay else style_map.get(presentation_style, 1)
2205
2811
  try:
2206
2812
  controller.setModalPresentationStyle_(style_int)
2207
2813
  except Exception:
2208
2814
  pass
2209
- # ``dismiss_on_backdrop`` is present only when False. For sheet
2210
- # styles, lock interactive (swipe / outside-tap) dismissal so
2211
- # the modal stays put until ``visible`` is driven back to False.
2815
+ # For sheet styles, ``dismiss_on_backdrop=False`` locks
2816
+ # interactive (swipe / outside-tap) dismissal so the modal
2817
+ # stays put until ``visible`` is driven back to False.
2212
2818
  if not is_overlay and props.get("dismiss_on_backdrop") is False:
2213
2819
  try:
2214
2820
  controller.setModalInPresentation_(True)
2215
2821
  except Exception:
2216
2822
  pass
2217
2823
 
2218
- on_show = props.get("on_show")
2219
-
2220
2824
  def _on_present_complete() -> None:
2221
- if on_show is not None:
2222
- try:
2223
- on_show()
2224
- except Exception:
2225
- pass
2825
+ _fire(placeholder, "on_show")
2226
2826
 
2227
- _pn_modal_states[id(placeholder)] = {
2827
+ state["modal"] = {
2228
2828
  "controller": controller,
2229
2829
  "content_view": content,
2230
- "on_dismiss": props.get("on_dismiss"),
2231
2830
  # Keep the completion block alive past this call.
2232
2831
  "on_show": _on_present_complete,
2233
2832
  }
2234
- # Drain any pending children.
2235
- for child in _pn_modal_pending.pop(id(placeholder), []):
2833
+ for child in state.pop("pending_children", []):
2236
2834
  try:
2835
+ child.setTranslatesAutoresizingMaskIntoConstraints_(True)
2237
2836
  content.addSubview_(child)
2238
2837
  except Exception:
2239
2838
  pass
2240
2839
 
2241
- top = UIApplication.sharedApplication.keyWindow.rootViewController
2242
- while top is not None and top.presentedViewController is not None:
2243
- top = top.presentedViewController
2840
+ top = _top_view_controller_for_alert(UIApplication.sharedApplication)
2244
2841
  if top is not None:
2245
2842
  top.presentViewController_animated_completion_(controller, True, _on_present_complete)
2246
2843
  except Exception:
2247
- pass
2844
+ state.pop("modal", None)
2248
2845
 
2249
2846
  def _dismiss(self, placeholder: Any) -> None:
2250
- state = _pn_modal_states.pop(id(placeholder), None)
2251
- if state is None:
2847
+ state = _state_of(placeholder)
2848
+ modal = state.pop("modal", None)
2849
+ if modal is None:
2252
2850
  return
2253
- controller = state.get("controller")
2254
- on_dismiss = state.get("on_dismiss")
2851
+ controller = modal.get("controller")
2255
2852
  if controller is not None:
2256
2853
  try:
2257
2854
  controller.dismissViewControllerAnimated_completion_(True, None)
2258
2855
  except Exception:
2259
2856
  pass
2260
- if on_dismiss is not None:
2261
- try:
2262
- on_dismiss()
2263
- except Exception:
2264
- pass
2265
-
2266
-
2267
- _pn_modal_states: Dict[int, dict] = {}
2268
- _pn_modal_pending: Dict[int, List[Any]] = {}
2269
- # Accumulated (non-None) props per modal placeholder, so presentation can
2270
- # read config props set on a render prior to the one that flips ``visible``.
2271
- _pn_modal_props: Dict[int, dict] = {}
2272
-
2273
-
2274
- class SliderHandler(IOSViewHandler):
2275
- def create(self, props: Dict[str, Any]) -> Any:
2276
- sl = ObjCClass("UISlider").alloc().init()
2277
- sl.setTranslatesAutoresizingMaskIntoConstraints_(True)
2278
- self._apply(sl, props)
2279
- return sl
2280
-
2281
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2282
- self._apply(native_view, changed)
2283
-
2284
- def _apply(self, sl: Any, props: Dict[str, Any]) -> None:
2285
- if "min_value" in props:
2286
- sl.setMinimumValue_(float(props["min_value"]))
2287
- if "max_value" in props:
2288
- sl.setMaximumValue_(float(props["max_value"]))
2289
- if "value" in props:
2290
- sl.setValue_(float(props["value"]))
2291
- _apply_accessibility(sl, props)
2292
- if "on_change" in props:
2293
- existing = _pn_slider_handler_map.get(id(sl))
2294
- if existing is not None:
2295
- existing._callback = props["on_change"]
2296
- else:
2297
- handler = _PNSliderTarget.new()
2298
- handler._callback = props["on_change"]
2299
- _pn_slider_handler_map[id(sl)] = handler
2300
- sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12)
2301
-
2302
-
2303
- # ======================================================================
2304
- # Pressable — visual touch feedback + tap/long-press callbacks
2305
- # ======================================================================
2306
-
2307
-
2308
- class PressableHandler(IOSViewHandler):
2309
- def create(self, props: Dict[str, Any]) -> Any:
2310
- v = ObjCClass("UIView").alloc().init()
2311
- v.setTranslatesAutoresizingMaskIntoConstraints_(True)
2312
- v.setUserInteractionEnabled_(True)
2313
- target = _PNPressableTarget.new()
2314
- target.retain()
2315
- _pn_retained_views.append(target)
2316
- # UITapGestureRecognizer for press (single tap) — provides
2317
- # touchDown/up via UIControl events on subclassed UIControl,
2318
- # but since this is a UIView we register a tap gesture for
2319
- # on_press and a long-press gesture for on_long_press.
2320
- try:
2321
- UITapGestureRecognizer = ObjCClass("UITapGestureRecognizer")
2322
- tap = UITapGestureRecognizer.alloc().initWithTarget_action_(target, SEL("onTouchUp:"))
2323
- v.addGestureRecognizer_(tap)
2324
- UILongPressGestureRecognizer = ObjCClass("UILongPressGestureRecognizer")
2325
- longp = UILongPressGestureRecognizer.alloc().initWithTarget_action_(target, SEL("onLongPress:"))
2326
- v.addGestureRecognizer_(longp)
2327
- except Exception:
2328
- pass
2329
- _pn_pressable_state[id(target)] = {
2330
- "view": v,
2331
- "on_press": props.get("on_press"),
2332
- "on_long_press": props.get("on_long_press"),
2333
- "pressed_opacity": float(props.get("pressed_opacity", 0.6)),
2334
- }
2335
- # Stash the target id so update() can reach it.
2336
- v._pn_press_target_id = id(target)
2337
- _apply_common_visual(v, props)
2338
- return v
2339
-
2340
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2341
- target_id = getattr(native_view, "_pn_press_target_id", None)
2342
- if target_id is not None and target_id in _pn_pressable_state:
2343
- info = _pn_pressable_state[target_id]
2344
- if "on_press" in changed:
2345
- info["on_press"] = changed["on_press"]
2346
- if "on_long_press" in changed:
2347
- info["on_long_press"] = changed["on_long_press"]
2348
- if "pressed_opacity" in changed and changed["pressed_opacity"] is not None:
2349
- info["pressed_opacity"] = float(changed["pressed_opacity"])
2350
- _apply_common_visual(native_view, changed)
2351
-
2352
- def add_child(self, parent: Any, child: Any) -> None:
2353
- try:
2354
- child.setTranslatesAutoresizingMaskIntoConstraints_(True)
2355
- except Exception:
2356
- pass
2357
- parent.addSubview_(child)
2358
-
2359
- def remove_child(self, parent: Any, child: Any) -> None:
2360
- child.removeFromSuperview()
2857
+ _fire(placeholder, "on_dismiss")
2361
2858
 
2362
2859
 
2363
2860
  # ======================================================================
@@ -2368,471 +2865,122 @@ class PressableHandler(IOSViewHandler):
2368
2865
  class StatusBarHandler(IOSViewHandler):
2369
2866
  """Apply status-bar style/visibility to the key window.
2370
2867
 
2371
- Status bar configuration on iOS is a per-view-controller value;
2372
- we use the legacy UIApplication setters which still work on
2373
- iOS 13+ (with ``UIViewControllerBasedStatusBarAppearance`` set
2374
- to ``NO`` in Info.plist for full effect). The placeholder view
2375
- is hidden and contributes nothing to the layout.
2868
+ Status bar configuration on iOS is a per-view-controller value; we
2869
+ use the legacy UIApplication setters which still work on iOS 13+
2870
+ (with ``UIViewControllerBasedStatusBarAppearance`` set to ``NO`` in
2871
+ Info.plist for full effect). The placeholder view is hidden and
2872
+ contributes nothing to the layout.
2376
2873
  """
2377
2874
 
2378
- def create(self, props: Dict[str, Any]) -> Any:
2875
+ def _build(self, props: Dict[str, Any]) -> Any:
2379
2876
  v = ObjCClass("UIView").alloc().init()
2380
2877
  v.setHidden_(True)
2381
- self._apply(props)
2382
2878
  return v
2383
2879
 
2384
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2385
- self._apply(changed)
2386
-
2387
- def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2388
- return
2389
-
2390
- def _apply(self, props: Dict[str, Any]) -> None:
2880
+ def _apply(self, view: Any, props: Dict[str, Any], initial: bool) -> None:
2391
2881
  try:
2392
2882
  UIApplication = ObjCClass("UIApplication")
2393
2883
  app = UIApplication.sharedApplication
2394
2884
  if "hidden" in props and props["hidden"] is not None:
2395
2885
  app.setStatusBarHidden_animated_(bool(props["hidden"]), True)
2396
2886
  if "bar_style" in props and props["bar_style"] is not None:
2397
- # 0 = default (dark content on iOS 12-), 1 = lightContent,
2398
- # 3 = darkContent (iOS 13+).
2887
+ # 1 = lightContent, 3 = darkContent (iOS 13+).
2399
2888
  mapping = {"default": 3, "light": 1, "dark": 3}
2400
2889
  app.setStatusBarStyle_animated_(mapping.get(props["bar_style"], 0), True)
2401
2890
  except Exception:
2402
2891
  pass
2403
2892
 
2404
-
2405
- # ======================================================================
2406
- # KeyboardAvoidingView — wraps children and offsets them by the keyboard
2407
- # ======================================================================
2408
-
2409
-
2410
- _pn_keyboard_observer: Any = None
2411
-
2412
-
2413
- class _PNKeyboardObserver(NSObject): # type: ignore[valid-type]
2414
- @objc_method
2415
- def keyboardWillShow_(self, notification: object) -> None:
2416
- try:
2417
- info = notification.userInfo
2418
- kbd_frame = info.objectForKey_("UIKeyboardFrameEndUserInfoKey")
2419
- # Frame is wrapped in NSValue; the Python side reads
2420
- # CGRectValue() which returns a tuple of structs.
2421
- rect = kbd_frame.CGRectValue
2422
- height = float(rect.size.height)
2423
- except Exception:
2424
- height = 0.0
2425
- from .. import platform_metrics
2426
-
2427
- platform_metrics.set_keyboard_height(height)
2428
-
2429
- @objc_method
2430
- def keyboardWillHide_(self, notification: object) -> None:
2431
- from .. import platform_metrics
2432
-
2433
- platform_metrics.set_keyboard_height(0.0)
2434
-
2435
-
2436
- def _ensure_keyboard_observer() -> None:
2437
- global _pn_keyboard_observer
2438
- if _pn_keyboard_observer is not None:
2439
- return
2440
- try:
2441
- observer = _PNKeyboardObserver.new()
2442
- observer.retain()
2443
- _pn_keyboard_observer = observer
2444
- NSNotificationCenter = ObjCClass("NSNotificationCenter")
2445
- center = NSNotificationCenter.defaultCenter
2446
- center.addObserver_selector_name_object_(
2447
- observer,
2448
- SEL("keyboardWillShow:"),
2449
- "UIKeyboardWillShowNotification",
2450
- None,
2451
- )
2452
- center.addObserver_selector_name_object_(
2453
- observer,
2454
- SEL("keyboardWillHide:"),
2455
- "UIKeyboardWillHideNotification",
2456
- None,
2457
- )
2458
- except Exception:
2459
- pass
2460
-
2461
-
2462
- class KeyboardAvoidingViewHandler(IOSViewHandler):
2463
- """Container that listens to the system keyboard and re-publishes its height.
2464
-
2465
- The actual layout shift is implemented in user-land by the
2466
- [`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView]
2467
- component, which subscribes to ``platform_metrics.subscribe`` via
2468
- [`use_keyboard_height`][pythonnative.use_keyboard_height] and
2469
- applies the offset as bottom padding. The native handler is just
2470
- a vanilla UIView that ensures the observer is installed.
2471
- """
2472
-
2473
- def create(self, props: Dict[str, Any]) -> Any:
2474
- _ensure_keyboard_observer()
2475
- v = ObjCClass("UIView").alloc().init()
2476
- v.setTranslatesAutoresizingMaskIntoConstraints_(True)
2477
- _apply_common_visual(v, props)
2478
- return v
2479
-
2480
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2481
- _apply_common_visual(native_view, changed)
2482
-
2483
- def add_child(self, parent: Any, child: Any) -> None:
2484
- try:
2485
- child.setTranslatesAutoresizingMaskIntoConstraints_(True)
2486
- except Exception:
2487
- pass
2488
- parent.addSubview_(child)
2489
-
2490
- def remove_child(self, parent: Any, child: Any) -> None:
2491
- child.removeFromSuperview()
2492
-
2493
-
2494
- # ======================================================================
2495
- # VirtualList — UITableView-backed virtualized list
2496
- # ======================================================================
2497
- #
2498
- # We register a raw libobjc class ``_PNTableSourceCTypes`` rather than
2499
- # using rubicon-objc's ``@objc_method`` because UIKit invokes
2500
- # ``tableView:cellForRowAtIndexPath:`` with a tagged-pointer
2501
- # NSIndexPath that crashes inside CPython's ``_ctypes.O_get`` when
2502
- # rubicon-objc's FFI closure tries to wrap it as a PyObject*.
2503
- #
2504
- # Each UITableView gets its own dataSource instance; per-instance
2505
- # state lives in ``_pn_table_state`` keyed by the dataSource's raw
2506
- # pointer (the integer value passed as ``self`` to every IMP).
2507
-
2508
- _pn_table_state: Dict[int, dict] = {}
2509
-
2510
-
2511
- _PN_CELL_REUSE_ID = "PNCell"
2512
-
2513
-
2514
- _TABLE_NUM_SECTIONS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
2515
- _TABLE_NUM_ROWS_TYPE = _ct.CFUNCTYPE(_ct.c_long, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_long)
2516
- _TABLE_HEIGHT_TYPE = _ct.CFUNCTYPE(_ct.c_double, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
2517
- _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)
2518
- _TABLE_DID_SELECT_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
2519
-
2520
-
2521
- def _table_num_sections_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int) -> int:
2522
- return 1
2523
-
2524
-
2525
- def _table_num_rows_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, section: int) -> int:
2526
- try:
2527
- info = _pn_table_state.get(int(self_ptr))
2528
- return int(info.get("count", 0)) if info else 0
2529
- except Exception:
2530
- import traceback as _tb
2531
-
2532
- print("[VirtualList][iOS] _table_num_rows_imp raised:")
2533
- _tb.print_exc()
2534
- return 0
2535
-
2536
-
2537
- def _table_height_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> float:
2538
- try:
2539
- info = _pn_table_state.get(int(self_ptr))
2540
- return float(info.get("row_height", 44.0)) if info else 44.0
2541
- except Exception:
2542
- import traceback as _tb
2543
-
2544
- print("[VirtualList][iOS] _table_height_imp raised:")
2545
- _tb.print_exc()
2546
- return 44.0
2547
-
2548
-
2549
- def _table_cell_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> int:
2550
- """Build (or reuse) a cell for ``tableView:cellForRowAtIndexPath:``.
2551
-
2552
- ``ip_ptr`` is read raw via ``[indexPath row]`` to avoid the
2553
- rubicon-objc tagged-pointer crash. The table view itself is a
2554
- real heap object so we can wrap it as an ObjCInstance for the
2555
- convenience of dequeue / cell allocation. We retain the freshly
2556
- allocated cell explicitly so it survives past the Python wrapper
2557
- going out of scope at the end of this frame.
2558
- """
2559
- import traceback as _tb
2560
-
2561
- try:
2562
- _objc_msgSend.restype = _ct.c_long
2563
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
2564
- row = int(_objc_msgSend(_ct.c_void_p(ip_ptr), _SEL_ROW))
2565
- except Exception:
2566
- print("[VirtualList][iOS] _table_cell_imp: indexPath.row read raised:")
2567
- _tb.print_exc()
2568
- row = 0
2569
-
2570
- try:
2571
- from rubicon.objc import ObjCInstance
2572
-
2573
- UITableViewCell = ObjCClass("UITableViewCell")
2574
- tv = ObjCInstance(_ct.c_void_p(tv_ptr))
2575
- info = _pn_table_state.get(int(self_ptr))
2576
- row_h = float(info.get("row_height", 44.0)) if info else 44.0
2577
-
2578
- try:
2579
- tv_bounds = tv.bounds
2580
- cell_w = float(tv_bounds.size.width)
2581
- except Exception:
2582
- cell_w = 0.0
2583
- if cell_w <= 0:
2584
- try:
2585
- screen = ObjCClass("UIScreen").mainScreen()
2586
- cell_w = float(screen.bounds.size.width)
2587
- except Exception:
2588
- cell_w = 320.0
2589
-
2590
- cell = tv.dequeueReusableCellWithIdentifier_(_PN_CELL_REUSE_ID)
2591
- if cell is None or (hasattr(cell, "ptr") and cell.ptr.value == 0):
2592
- cell = UITableViewCell.alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
2593
- transparent = _uicolor("#00000000")
2594
- cell.setBackgroundColor_(transparent)
2595
- cell.contentView.setBackgroundColor_(transparent)
2596
- cell.retain() # offset the Python wrapper's release on __del__
2597
-
2598
- try:
2599
- cell.setFrame_(((0, 0), (cell_w, row_h)))
2600
- cell.contentView.setFrame_(((0, 0), (cell_w, row_h)))
2601
- except Exception:
2602
- print("[VirtualList][iOS] _table_cell_imp: cell pre-size raised:")
2603
- _tb.print_exc()
2604
-
2605
- try:
2606
- existing_subs = cell.contentView.subviews
2607
- if callable(existing_subs):
2608
- existing_subs = existing_subs()
2609
- for sub in list(existing_subs):
2610
- sub.removeFromSuperview()
2611
- except Exception:
2612
- print("[VirtualList][iOS] _table_cell_imp: strip prior subviews raised:")
2613
- _tb.print_exc()
2614
-
2615
- if info is not None:
2616
- mount = info.get("mount_row")
2617
- if mount is not None:
2618
- try:
2619
- mount(row, cell.contentView, cell_w, row_h)
2620
- except Exception:
2621
- print(f"[VirtualList][iOS] _table_cell_imp mount_row({row}) raised:")
2622
- _tb.print_exc()
2623
-
2624
- cell_ptr = cell.ptr.value
2625
- return int(cell_ptr) if cell_ptr is not None else 0
2626
- except Exception:
2627
- print(f"[VirtualList][iOS] _table_cell_imp raised for row={row}:")
2628
- _tb.print_exc()
2629
- try:
2630
- UITableViewCell = ObjCClass("UITableViewCell")
2631
- cell = UITableViewCell.alloc().initWithStyle_reuseIdentifier_(0, _PN_CELL_REUSE_ID)
2632
- cell.retain()
2633
- return int(cell.ptr.value)
2634
- except Exception:
2635
- return 0
2636
-
2637
-
2638
- def _table_did_select_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> None:
2639
- try:
2640
- _objc_msgSend.restype = _ct.c_long
2641
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
2642
- row = int(_objc_msgSend(_ct.c_void_p(ip_ptr), _SEL_ROW))
2643
- except Exception:
2644
- import traceback as _tb
2645
-
2646
- print("[VirtualList][iOS] _table_did_select_imp: indexPath.row read raised:")
2647
- _tb.print_exc()
2893
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2648
2894
  return
2649
2895
 
2650
- try:
2651
- _objc_msgSend.restype = None
2652
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_bool]
2653
- _objc_msgSend(_ct.c_void_p(tv_ptr), _SEL_DESELECT_ROW, _ct.c_void_p(ip_ptr), True)
2654
- except Exception:
2655
- import traceback as _tb
2656
-
2657
- print("[VirtualList][iOS] _table_did_select_imp: deselect raised:")
2658
- _tb.print_exc()
2659
-
2660
- try:
2661
- info = _pn_table_state.get(int(self_ptr))
2662
- if info is None:
2663
- return
2664
- cb = info.get("on_row_press")
2665
- if cb is not None:
2666
- cb(row)
2667
- except Exception:
2668
- import traceback as _tb
2896
+ def measure_intrinsic(
2897
+ self,
2898
+ native_view: Any,
2899
+ max_width: float,
2900
+ max_height: float,
2901
+ ) -> Tuple[float, float]:
2902
+ return (0.0, 0.0)
2669
2903
 
2670
- print("[VirtualList][iOS] _table_did_select_imp: on_row_press raised:")
2671
- _tb.print_exc()
2672
2904
 
2905
+ # ======================================================================
2906
+ # KeyboardAvoidingView — publishes the keyboard height to Python
2907
+ # ======================================================================
2673
2908
 
2674
- _table_num_sections_imp_ref = _TABLE_NUM_SECTIONS_TYPE(_table_num_sections_imp)
2675
- _table_num_rows_imp_ref = _TABLE_NUM_ROWS_TYPE(_table_num_rows_imp)
2676
- _table_height_imp_ref = _TABLE_HEIGHT_TYPE(_table_height_imp)
2677
- _table_cell_imp_ref = _TABLE_CELL_TYPE(_table_cell_imp)
2678
- _table_did_select_imp_ref = _TABLE_DID_SELECT_TYPE(_table_did_select_imp)
2679
2909
 
2910
+ _pn_keyboard_observer: Any = None
2680
2911
 
2681
- _PN_TABLE_SOURCE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTableSourceCTypes", 0)
2682
- if _PN_TABLE_SOURCE_CLS:
2683
- _add_method(
2684
- _PN_TABLE_SOURCE_CLS,
2685
- _sel_reg(b"numberOfSectionsInTableView:"),
2686
- _ct.cast(_table_num_sections_imp_ref, _ct.c_void_p),
2687
- b"q@:@",
2688
- )
2689
- _add_method(
2690
- _PN_TABLE_SOURCE_CLS,
2691
- _sel_reg(b"tableView:numberOfRowsInSection:"),
2692
- _ct.cast(_table_num_rows_imp_ref, _ct.c_void_p),
2693
- b"q@:@q",
2694
- )
2695
- _add_method(
2696
- _PN_TABLE_SOURCE_CLS,
2697
- _sel_reg(b"tableView:heightForRowAtIndexPath:"),
2698
- _ct.cast(_table_height_imp_ref, _ct.c_void_p),
2699
- b"d@:@@",
2700
- )
2701
- _add_method(
2702
- _PN_TABLE_SOURCE_CLS,
2703
- _sel_reg(b"tableView:cellForRowAtIndexPath:"),
2704
- _ct.cast(_table_cell_imp_ref, _ct.c_void_p),
2705
- b"@@:@@",
2706
- )
2707
- _add_method(
2708
- _PN_TABLE_SOURCE_CLS,
2709
- _sel_reg(b"tableView:didSelectRowAtIndexPath:"),
2710
- _ct.cast(_table_did_select_imp_ref, _ct.c_void_p),
2711
- b"v@:@@",
2712
- )
2713
- _reg_cls(_PN_TABLE_SOURCE_CLS)
2714
2912
 
2913
+ class _PNKeyboardObserver(NSObject): # type: ignore[valid-type]
2914
+ @objc_method
2915
+ def keyboardWillShow_(self, notification: object) -> None:
2916
+ try:
2917
+ info = notification.userInfo
2918
+ kbd_frame = info.objectForKey_("UIKeyboardFrameEndUserInfoKey")
2919
+ # Frame is wrapped in NSValue; CGRectValue unwraps the rect.
2920
+ rect = kbd_frame.CGRectValue
2921
+ height = float(rect.size.height)
2922
+ except Exception:
2923
+ height = 0.0
2924
+ from .. import platform_metrics
2715
2925
 
2716
- def _alloc_table_source_instance() -> int:
2717
- """Allocate and retain a fresh ``_PNTableSourceCTypes`` instance.
2926
+ platform_metrics.set_keyboard_height(height)
2718
2927
 
2719
- Returns the raw pointer (integer) for the new dataSource. Callers
2720
- must keep the pointer alive themselves — UITableView's dataSource
2721
- relationship is non-retaining.
2722
- """
2723
- if not _PN_TABLE_SOURCE_CLS:
2724
- raise RuntimeError("_PNTableSourceCTypes class registration failed")
2725
- _objc_msgSend.restype = _ct.c_void_p
2726
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
2727
- raw = _objc_msgSend(_PN_TABLE_SOURCE_CLS, _SEL_ALLOC)
2728
- raw = _objc_msgSend(raw, _SEL_INIT)
2729
- raw = _objc_msgSend(raw, _SEL_RETAIN)
2730
- return int(raw) if raw is not None else 0
2731
-
2732
-
2733
- class VirtualListHandler(IOSViewHandler):
2734
- """Backed by ``UITableView``; rows are mounted on demand from Python.
2735
-
2736
- Expects props:
2737
-
2738
- - ``count``: total number of rows.
2739
- - ``row_height``: fixed row height in points (variable heights
2740
- would require a per-row measurement pass; out of scope for v1).
2741
- - ``mount_row``: callable ``(row_index, content_view) -> None``
2742
- that inserts the row's native view into ``content_view``. The
2743
- Python side computes this by mounting a fresh sub-tree using
2744
- its own reconciler, then calling ``add_child`` on the supplied
2745
- content view.
2746
- - ``on_row_press``: optional ``(row_index) -> None`` callback.
2747
- """
2928
+ @objc_method
2929
+ def keyboardWillHide_(self, notification: object) -> None:
2930
+ from .. import platform_metrics
2748
2931
 
2749
- def create(self, props: Dict[str, Any]) -> Any:
2750
- import traceback as _tb
2932
+ platform_metrics.set_keyboard_height(0.0)
2751
2933
 
2752
- try:
2753
- UITableView = ObjCClass("UITableView")
2754
- tv = UITableView.alloc().initWithFrame_style_(((0, 0), (0, 0)), 0)
2755
- except Exception:
2756
- print("[VirtualList][iOS] UITableView alloc raised:")
2757
- _tb.print_exc()
2758
- raise
2759
- try:
2760
- tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
2761
- tv.setBackgroundColor_(_uicolor(props.get("background_color") or "#FFFFFF"))
2762
- except Exception:
2763
- print("[VirtualList][iOS] tv basic setup raised:")
2764
- _tb.print_exc()
2765
- try:
2766
- tv.setSeparatorStyle_(0) # None by default; users can opt in.
2767
- except Exception:
2768
- print("[VirtualList][iOS] setSeparatorStyle_ raised:")
2769
- _tb.print_exc()
2770
2934
 
2771
- try:
2772
- source_ptr = _alloc_table_source_instance()
2773
- except Exception:
2774
- print("[VirtualList][iOS] raw dataSource allocation raised:")
2775
- _tb.print_exc()
2776
- raise
2777
- if source_ptr == 0:
2778
- raise RuntimeError("[VirtualList][iOS] dataSource alloc returned NULL")
2935
+ def _ensure_keyboard_observer() -> None:
2936
+ global _pn_keyboard_observer
2937
+ if _pn_keyboard_observer is not None:
2938
+ return
2939
+ try:
2940
+ observer = _PNKeyboardObserver.new()
2941
+ observer.retain()
2942
+ _pn_keyboard_observer = observer
2943
+ center = ObjCClass("NSNotificationCenter").defaultCenter
2944
+ center.addObserver_selector_name_object_(
2945
+ observer,
2946
+ SEL("keyboardWillShow:"),
2947
+ "UIKeyboardWillShowNotification",
2948
+ None,
2949
+ )
2950
+ center.addObserver_selector_name_object_(
2951
+ observer,
2952
+ SEL("keyboardWillHide:"),
2953
+ "UIKeyboardWillHideNotification",
2954
+ None,
2955
+ )
2956
+ except Exception:
2957
+ pass
2779
2958
 
2780
- _pn_table_state[source_ptr] = {
2781
- "count": int(props.get("count", 0)),
2782
- "row_height": float(props.get("row_height", 44.0)),
2783
- "mount_row": props.get("mount_row"),
2784
- "on_row_press": props.get("on_row_press"),
2785
- }
2786
2959
 
2787
- try:
2788
- _objc_msgSend.restype = None
2789
- _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
2790
- tv_ptr = tv.ptr if hasattr(tv, "ptr") else tv
2791
- _objc_msgSend(tv_ptr, _SEL_SET_DATA_SOURCE, _ct.c_void_p(source_ptr))
2792
- _objc_msgSend(tv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(source_ptr))
2793
- except Exception:
2794
- print("[VirtualList][iOS] raw setDataSource:/setDelegate: raised:")
2795
- _tb.print_exc()
2796
- raise
2960
+ class KeyboardAvoidingViewHandler(IOSViewHandler):
2961
+ """Container that listens to the system keyboard and re-publishes its height.
2797
2962
 
2798
- try:
2799
- tv._pn_source_id = source_ptr
2800
- except Exception:
2801
- print("[VirtualList][iOS] attaching _pn_source_id raised:")
2802
- _tb.print_exc()
2803
- return tv
2963
+ The actual layout shift is implemented in user-land by the
2964
+ [`KeyboardAvoidingView`][pythonnative.KeyboardAvoidingView]
2965
+ component, which subscribes via
2966
+ [`use_keyboard_height`][pythonnative.use_keyboard_height] and
2967
+ applies the offset as bottom padding. The native handler is just a
2968
+ vanilla UIView that ensures the observer is installed.
2969
+ """
2804
2970
 
2805
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2806
- sid = getattr(native_view, "_pn_source_id", None)
2807
- if sid is None or sid not in _pn_table_state:
2808
- return
2809
- info = _pn_table_state[sid]
2810
- if "count" in changed:
2811
- info["count"] = int(changed["count"])
2812
- if "row_height" in changed and changed["row_height"] is not None:
2813
- info["row_height"] = float(changed["row_height"])
2814
- if "mount_row" in changed:
2815
- info["mount_row"] = changed["mount_row"]
2816
- if "on_row_press" in changed:
2817
- info["on_row_press"] = changed["on_row_press"]
2818
- if "background_color" in changed and changed["background_color"] is not None:
2819
- native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
2820
- try:
2821
- native_view.reloadData()
2822
- except Exception:
2823
- pass
2971
+ def _build(self, props: Dict[str, Any]) -> Any:
2972
+ _ensure_keyboard_observer()
2973
+ v = ObjCClass("UIView").alloc().init()
2974
+ v.setTranslatesAutoresizingMaskIntoConstraints_(True)
2975
+ return v
2824
2976
 
2825
2977
 
2826
2978
  # ======================================================================
2827
- # UITabBar delegate via raw libobjc
2979
+ # TabBar — UITabBar with a raw ctypes delegate
2828
2980
  # ======================================================================
2829
2981
  #
2830
- # Uses the shared raw-libobjc helpers above. See the section comment
2831
- # there for why we sidestep rubicon-objc for delegate callbacks.
2832
-
2833
- _pn_tabbar_state: dict = {"callback": None, "items": []}
2834
- _pn_tabbar_delegate_installed: bool = False
2835
- _pn_tabbar_delegate_ptr: Any = None
2982
+ # ``tabBar:didSelectItem:`` passes the UITabBarItem as an ObjC object;
2983
+ # see the module header for why we sidestep rubicon-objc here.
2836
2984
 
2837
2985
  _DELEGATE_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
2838
2986
 
@@ -2842,35 +2990,38 @@ def _tabbar_did_select_imp(self_ptr: int, cmd_ptr: int, tabbar_ptr: int, item_pt
2842
2990
  try:
2843
2991
  _objc_msgSend.restype = _ct.c_long
2844
2992
  _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
2845
- tag: int = _objc_msgSend(item_ptr, _SEL_TAG)
2993
+ index: int = _objc_msgSend(item_ptr, _SEL_TAG)
2846
2994
 
2847
- cb = _pn_tabbar_state["callback"]
2848
- tab_items = _pn_tabbar_state["items"]
2849
- if cb is not None and tab_items and 0 <= tag < len(tab_items):
2850
- cb(tab_items[tag].get("name", ""))
2995
+ tag = _view_tags.get(int(tabbar_ptr or 0))
2996
+ state = _view_state.get(tag) if tag is not None else None
2997
+ items = (state or {}).get("props", {}).get("items") or []
2998
+ if 0 <= index < len(items):
2999
+ _fire_ptr(int(tabbar_ptr), "on_tab_select", items[index].get("name", ""))
2851
3000
  except Exception:
2852
3001
  pass
2853
3002
 
2854
3003
 
2855
3004
  _tabbar_imp_ref = _DELEGATE_IMP_TYPE(_tabbar_did_select_imp)
2856
3005
 
2857
- _PN_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTabBarDelegateCTypes", 0)
2858
- if _PN_DELEGATE_CLS:
3006
+ _PN_TABBAR_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTabBarDelegateCTypes", 0)
3007
+ if _PN_TABBAR_DELEGATE_CLS:
2859
3008
  _add_method(
2860
- _PN_DELEGATE_CLS,
3009
+ _PN_TABBAR_DELEGATE_CLS,
2861
3010
  _sel_reg(b"tabBar:didSelectItem:"),
2862
3011
  _ct.cast(_tabbar_imp_ref, _ct.c_void_p),
2863
3012
  b"v@:@@",
2864
3013
  )
2865
- _reg_cls(_PN_DELEGATE_CLS)
3014
+ _reg_cls(_PN_TABBAR_DELEGATE_CLS)
3015
+
3016
+ _pn_tabbar_delegate_ptr: Any = None
2866
3017
 
2867
3018
 
2868
3019
  def _ensure_tabbar_delegate(tab_bar: Any) -> None:
2869
3020
  global _pn_tabbar_delegate_ptr
2870
- if _pn_tabbar_delegate_ptr is None and _PN_DELEGATE_CLS:
3021
+ if _pn_tabbar_delegate_ptr is None and _PN_TABBAR_DELEGATE_CLS:
2871
3022
  _objc_msgSend.restype = _ct.c_void_p
2872
3023
  _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
2873
- raw = _objc_msgSend(_PN_DELEGATE_CLS, _SEL_ALLOC)
3024
+ raw = _objc_msgSend(_PN_TABBAR_DELEGATE_CLS, _SEL_ALLOC)
2874
3025
  raw = _objc_msgSend(raw, _SEL_INIT)
2875
3026
  raw = _objc_msgSend(raw, _SEL_RETAIN)
2876
3027
  _pn_tabbar_delegate_ptr = raw
@@ -2885,12 +3036,12 @@ def _ensure_tabbar_delegate(tab_bar: Any) -> None:
2885
3036
  class TabBarHandler(IOSViewHandler):
2886
3037
  """Native tab bar using ``UITabBar``.
2887
3038
 
2888
- Each tab is a ``UITabBarItem`` with a ``tag`` matching its index
2889
- in the items list. A raw ctypes delegate forwards selection
2890
- events back to the Python ``on_tab_select`` callback.
3039
+ Each tab is a ``UITabBarItem`` with a ``tag`` matching its index in
3040
+ the items list. A raw ctypes delegate forwards selection events
3041
+ into the ``on_tab_select`` channel.
2891
3042
  """
2892
3043
 
2893
- def create(self, props: Dict[str, Any]) -> Any:
3044
+ def _build(self, props: Dict[str, Any]) -> Any:
2894
3045
  from .. import platform_metrics
2895
3046
 
2896
3047
  initial_h = platform_metrics.ios_tab_bar_height()
@@ -2898,12 +3049,9 @@ class TabBarHandler(IOSViewHandler):
2898
3049
  tab_bar.setTranslatesAutoresizingMaskIntoConstraints_(True)
2899
3050
  tab_bar.retain()
2900
3051
  _pn_retained_views.append(tab_bar)
2901
- self._apply_full(tab_bar, props)
3052
+ _ensure_tabbar_delegate(tab_bar)
2902
3053
  return tab_bar
2903
3054
 
2904
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
2905
- self._apply_partial(native_view, changed)
2906
-
2907
3055
  def measure_intrinsic(
2908
3056
  self,
2909
3057
  native_view: Any,
@@ -2913,26 +3061,16 @@ class TabBarHandler(IOSViewHandler):
2913
3061
  from .. import platform_metrics
2914
3062
 
2915
3063
  w = max_width if math.isfinite(max_width) else 320.0
2916
- h = platform_metrics.ios_tab_bar_height()
2917
- return (w, h)
2918
-
2919
- def _apply_full(self, tab_bar: Any, props: Dict[str, Any]) -> None:
2920
- items = props.get("items", [])
2921
- self._set_bar_items(tab_bar, items)
2922
- self._set_active(tab_bar, props.get("active_tab"), items)
2923
- self._set_callback(tab_bar, props.get("on_tab_select"), items)
2924
-
2925
- def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None:
2926
- prev_items = _pn_tabbar_state["items"]
2927
- if "items" in changed:
2928
- items = changed["items"]
3064
+ return (w, platform_metrics.ios_tab_bar_height())
3065
+
3066
+ def _apply(self, tab_bar: Any, props: Dict[str, Any], initial: bool) -> None:
3067
+ merged = _state_of(tab_bar).get("props") or props
3068
+ items = merged.get("items") or []
3069
+ if "items" in props:
2929
3070
  self._set_bar_items(tab_bar, items)
2930
- else:
2931
- items = prev_items
2932
- if "active_tab" in changed:
2933
- self._set_active(tab_bar, changed["active_tab"], items)
2934
- if "on_tab_select" in changed:
2935
- self._set_callback(tab_bar, changed["on_tab_select"], items)
3071
+ if "active_tab" in props or "items" in props:
3072
+ self._set_active(tab_bar, merged.get("active_tab"), items)
3073
+ _apply_accessibility(tab_bar, props)
2936
3074
 
2937
3075
  def _set_bar_items(self, tab_bar: Any, items: list) -> None:
2938
3076
  UITabBarItem = ObjCClass("UITabBarItem")
@@ -2943,14 +3081,16 @@ class TabBarHandler(IOSViewHandler):
2943
3081
  image = self._resolve_icon(UIImage, item.get("icon"))
2944
3082
  bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), image, i)
2945
3083
  bar_items.append(bar_item)
2946
- tab_bar.setItems_animated_(bar_items, False)
3084
+ try:
3085
+ tab_bar.setItems_animated_(bar_items, False)
3086
+ except Exception:
3087
+ pass
2947
3088
 
2948
3089
  def _resolve_icon(self, UIImage: Any, icon: Any) -> Any:
2949
3090
  """Resolve a tab icon spec to a UIImage, or return None.
2950
3091
 
2951
3092
  Accepts a bare string (treated as an SF Symbol name) or a dict
2952
- of the form ``{"ios": "house.fill", "android": "..."}``. SF
2953
- Symbols are looked up via ``UIImage.systemImageNamed:``; names
3093
+ of the form ``{"ios": "house.fill", "android": "..."}``. Names
2954
3094
  that don't resolve produce a text-only tab.
2955
3095
  """
2956
3096
  if icon is None:
@@ -2981,11 +3121,6 @@ class TabBarHandler(IOSViewHandler):
2981
3121
  pass
2982
3122
  break
2983
3123
 
2984
- def _set_callback(self, tab_bar: Any, cb: Any, items: list) -> None:
2985
- _pn_tabbar_state["callback"] = cb
2986
- _pn_tabbar_state["items"] = items
2987
- _ensure_tabbar_delegate(tab_bar)
2988
-
2989
3124
 
2990
3125
  # ======================================================================
2991
3126
  # Alert / Picker imperative helpers
@@ -3028,8 +3163,8 @@ def _top_view_controller_for_alert(app: Any) -> Any:
3028
3163
  except Exception:
3029
3164
  return None
3030
3165
 
3031
- # If the root is a navigation controller, presenting from the visible
3032
- # controller gives UIKit the most specific presentation context.
3166
+ # If the root is a navigation controller, presenting from the
3167
+ # visible controller gives UIKit the most specific context.
3033
3168
  try:
3034
3169
  visible = getattr(top, "visibleViewController", None)
3035
3170
  if visible is not None:
@@ -3064,11 +3199,11 @@ def _present_alert(
3064
3199
  Returns immediately; the alert appears on the next main-loop tick.
3065
3200
 
3066
3201
  ``buttons`` is a list of ``{"label": str, "style":
3067
- "default"|"cancel"|"destructive"}`` dicts (no ``on_press``). When
3068
- the user picks button ``i`` the helper invokes ``on_result(i)``
3069
- exactly once. A dismiss (e.g. swipe-to-cancel on iPad) delivers
3070
- ``-1``. ``on_result`` always runs on the main thread; if it needs
3071
- to wake an asyncio.Future, use
3202
+ "default"|"cancel"|"destructive"}`` dicts. When the user picks
3203
+ button ``i`` the helper invokes ``on_result(i)`` exactly once. A
3204
+ dismiss (e.g. swipe-to-cancel on iPad) delivers ``-1``.
3205
+ ``on_result`` always runs on the main thread; if it needs to wake
3206
+ an asyncio.Future, use
3072
3207
  [`pythonnative.runtime.resolve_future`][pythonnative.runtime.resolve_future]
3073
3208
  to hop back onto the loop thread.
3074
3209
  """
@@ -3126,22 +3261,16 @@ def _present_alert(
3126
3261
 
3127
3262
 
3128
3263
  # ======================================================================
3129
- # Picker — native dropdown / select widget
3264
+ # Picker — action-sheet dropdown
3130
3265
  # ======================================================================
3131
3266
  #
3132
- # The PythonNative `Picker` element renders as a `UIButton` whose tap
3133
- # presents a native action sheet (``UIAlertController``) listing the
3134
- # options. Selecting a row fires ``on_change(value)``. Action sheets
3135
- # are the standard iOS dropdown pattern for a small-to-medium set of
3136
- # choices; for very large lists, paginate or use a custom navigator.
3137
-
3138
-
3139
- _pn_picker_state: dict = {}
3140
- # Maps ``id(target)`` -> ``id(button)`` so the single shared
3141
- # ``_PNPickerTarget`` class can look up per-instance picker state on tap.
3142
- _pn_picker_target_to_button: dict = {}
3267
+ # The PythonNative `Picker` renders as a `UIButton` whose tap presents
3268
+ # a native action sheet (``UIAlertController``) listing the options.
3269
+ # Selecting a row fires ``on_change(value)``. Action sheets are the
3270
+ # standard iOS dropdown pattern for a small-to-medium set of choices.
3143
3271
 
3144
3272
 
3273
+ # Maps ``id(target)`` -> owning Picker button.
3145
3274
  def _picker_button_title(props: Dict[str, Any]) -> str:
3146
3275
  """Render the selected label, falling back to the placeholder."""
3147
3276
  items = props.get("items") or []
@@ -3152,85 +3281,46 @@ def _picker_button_title(props: Dict[str, Any]) -> str:
3152
3281
  return str(props.get("placeholder") or "Select…")
3153
3282
 
3154
3283
 
3155
- class _PNPickerTarget(NSObject): # type: ignore[valid-type]
3156
- """Shared ObjC target for every Picker button.
3157
-
3158
- Defined exactly once at module load. ``UIButton`` instances each
3159
- retain their own ``_PNPickerTarget.new()`` instance, and the per-
3160
- instance picker state is looked up in
3161
- :data:`_pn_picker_target_to_button` / :data:`_pn_picker_state`.
3162
- """
3284
+ def _present_picker_sheet(btn: Any) -> None:
3285
+ """Present the option action-sheet for a Picker button."""
3286
+ merged = _state_of(btn).get("props") or {}
3287
+ items = [item for item in (merged.get("items") or []) if isinstance(item, dict)]
3288
+ placeholder = merged.get("placeholder") or "Select…"
3163
3289
 
3164
- @objc_method
3165
- def onTap_(self, sender: object) -> None: # noqa: ARG002
3166
- bid = _pn_picker_target_to_button.get(id(self))
3167
- if bid is None:
3168
- return
3169
- state = _pn_picker_state.get(bid)
3170
- if not state:
3171
- return
3172
- items = list(state.get("items") or [])
3173
- on_change = state.get("on_change")
3174
- placeholder = state.get("placeholder") or "Select…"
3175
-
3176
- def _make_press(value: Any) -> Callable[[], None]:
3177
- def _press() -> None:
3178
- if on_change is not None:
3179
- try:
3180
- on_change(value)
3181
- except Exception:
3182
- pass
3183
-
3184
- return _press
3185
-
3186
- buttons: List[Dict[str, Any]] = []
3187
- for item in items:
3188
- if not isinstance(item, dict):
3189
- continue
3190
- label = str(item.get("label", item.get("value", "")))
3191
- buttons.append({"label": label, "on_press": _make_press(item.get("value"))})
3192
- buttons.append({"label": "Cancel", "style": "cancel"})
3193
- _present_alert(title=str(placeholder), message=None, buttons=buttons, style="action_sheet")
3290
+ buttons: List[Dict[str, Any]] = [{"label": str(item.get("label", item.get("value", "")))} for item in items]
3291
+ buttons.append({"label": "Cancel", "style": "cancel"})
3194
3292
 
3293
+ def _on_result(index: int) -> None:
3294
+ if 0 <= index < len(items):
3295
+ _fire(btn, "on_change", items[index].get("value"))
3195
3296
 
3196
- def _picker_make_target(button_id: int) -> Any:
3197
- """Build a retained ObjC target wired to ``button_id``'s picker state."""
3198
- target = _PNPickerTarget.new()
3199
- target.retain()
3200
- _pn_retained_views.append(target)
3201
- _pn_picker_target_to_button[id(target)] = button_id
3202
- return target
3297
+ _present_alert(
3298
+ title=str(placeholder),
3299
+ message=None,
3300
+ buttons=buttons,
3301
+ style="action_sheet",
3302
+ on_result=_on_result,
3303
+ )
3203
3304
 
3204
3305
 
3205
3306
  class PickerHandler(IOSViewHandler):
3206
3307
  """``Picker`` element handler — native action-sheet dropdown."""
3207
3308
 
3208
- def create(self, props: Dict[str, Any]) -> Any:
3209
- UIButton = ObjCClass("UIButton")
3210
- btn = UIButton.buttonWithType_(1) # UIButtonTypeSystem
3309
+ def _build(self, props: Dict[str, Any]) -> Any:
3310
+ btn = ObjCClass("UIButton").buttonWithType_(1) # UIButtonTypeSystem
3211
3311
  btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
3212
- bid = id(btn)
3213
- _pn_picker_state[bid] = {
3214
- "items": list(props.get("items") or []),
3215
- "on_change": props.get("on_change"),
3216
- "placeholder": props.get("placeholder") or "Select…",
3217
- "value": props.get("value"),
3218
- }
3219
- target = _picker_make_target(bid)
3220
- _pn_picker_state[bid]["target"] = target
3221
- btn.addTarget_action_forControlEvents_(target, SEL("onTap:"), 1 << 6) # touchUpInside
3222
- btn.setTitle_forState_(_picker_button_title(props), 0)
3223
- _apply_accessibility(btn, props)
3312
+ btn.retain()
3313
+ _pn_retained_views.append(btn)
3314
+ _register_control_action(btn, 1 << 6, lambda: _present_picker_sheet(btn)) # TouchUpInside
3224
3315
  return btn
3225
3316
 
3226
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
3227
- bid = id(native_view)
3228
- state = _pn_picker_state.setdefault(bid, {})
3229
- for key in ("items", "on_change", "placeholder", "value"):
3230
- if key in changed:
3231
- state[key] = changed[key]
3232
- native_view.setTitle_forState_(_picker_button_title(state), 0)
3233
- _apply_accessibility(native_view, changed)
3317
+ def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
3318
+ merged = _state_of(btn).get("props") or props
3319
+ try:
3320
+ btn.setTitle_forState_(_picker_button_title(merged), 0)
3321
+ except Exception:
3322
+ pass
3323
+ _apply_accessibility(btn, props)
3234
3324
 
3235
3325
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
3236
3326
  try:
@@ -3247,13 +3337,10 @@ class PickerHandler(IOSViewHandler):
3247
3337
  # ======================================================================
3248
3338
 
3249
3339
 
3250
- _pn_checkbox_state: dict = {}
3251
- # Maps ``id(target)`` -> ``id(button)`` for per-instance checkbox lookup.
3252
- _pn_checkbox_target_to_button: dict = {}
3253
-
3254
-
3255
- def _checkbox_set_image(btn: Any, state: Dict[str, Any]) -> None:
3340
+ def _checkbox_set_image(btn: Any) -> None:
3256
3341
  """Set the box image from the current checked state (tinted when checked)."""
3342
+ state = _state_of(btn)
3343
+ merged = state.get("props") or {}
3257
3344
  checked = bool(state.get("value"))
3258
3345
  try:
3259
3346
  UIImage = ObjCClass("UIImage")
@@ -3261,7 +3348,7 @@ def _checkbox_set_image(btn: Any, state: Dict[str, Any]) -> None:
3261
3348
  image = UIImage.systemImageNamed_(name)
3262
3349
  if image is None:
3263
3350
  return
3264
- color = state.get("color")
3351
+ color = merged.get("color")
3265
3352
  if checked and color is not None:
3266
3353
  try:
3267
3354
  tinted = image.imageWithTintColor_(_uicolor(color))
@@ -3274,54 +3361,67 @@ def _checkbox_set_image(btn: Any, state: Dict[str, Any]) -> None:
3274
3361
  pass
3275
3362
 
3276
3363
 
3277
- class _PNCheckboxTarget(NSObject): # type: ignore[valid-type]
3278
- @objc_method
3279
- def onToggle_(self, sender: object) -> None: # noqa: ARG002
3280
- bid = _pn_checkbox_target_to_button.get(id(self))
3281
- if bid is None:
3282
- return
3283
- state = _pn_checkbox_state.get(bid)
3284
- if not state or state.get("disabled"):
3285
- return
3286
- new_value = not bool(state.get("value"))
3287
- state["value"] = new_value
3288
- btn = state.get("view")
3289
- if btn is not None:
3290
- _checkbox_set_image(btn, state)
3291
- cb = state.get("on_change")
3292
- if cb is not None:
3293
- try:
3294
- cb(new_value)
3295
- except Exception:
3296
- pass
3364
+ def _checkbox_toggle(btn: Any) -> None:
3365
+ """Flip a Checkbox button's checked state and fire ``on_change``."""
3366
+ state = _state_of(btn)
3367
+ merged = state.get("props") or {}
3368
+ if merged.get("disabled"):
3369
+ return
3370
+ new_value = not bool(state.get("value"))
3371
+ # Optimistic local flip so the box feels instant even if the
3372
+ # app's re-render is a frame behind; the authoritative ``value``
3373
+ # prop re-syncs it on the next update.
3374
+ state["value"] = new_value
3375
+ _checkbox_set_image(btn)
3376
+ _fire(btn, "on_change", new_value)
3297
3377
 
3298
3378
 
3299
3379
  class CheckboxHandler(IOSViewHandler):
3300
- def create(self, props: Dict[str, Any]) -> Any:
3301
- UIButton = ObjCClass("UIButton")
3302
- btn = UIButton.buttonWithType_(0) # UIButtonTypeCustom
3380
+ def _build(self, props: Dict[str, Any]) -> Any:
3381
+ btn = ObjCClass("UIButton").buttonWithType_(0) # UIButtonTypeCustom
3303
3382
  btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
3304
3383
  btn.retain()
3305
3384
  _pn_retained_views.append(btn)
3306
- bid = id(btn)
3307
- _pn_checkbox_state[bid] = {
3308
- "value": bool(props.get("value")),
3309
- "on_change": props.get("on_change"),
3310
- "color": props.get("color"),
3311
- "disabled": bool(props.get("disabled")),
3312
- "view": btn,
3313
- }
3314
- target = _PNCheckboxTarget.new()
3315
- target.retain()
3316
- _pn_retained_views.append(target)
3317
- _pn_checkbox_target_to_button[id(target)] = bid
3318
- _pn_checkbox_state[bid]["target"] = target
3319
- btn.addTarget_action_forControlEvents_(target, SEL("onToggle:"), 1 << 6) # touchUpInside
3320
- self._apply(btn, props)
3385
+ _register_control_action(btn, 1 << 6, lambda: _checkbox_toggle(btn)) # TouchUpInside
3321
3386
  return btn
3322
3387
 
3323
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
3324
- self._apply(native_view, changed)
3388
+ def _apply(self, btn: Any, props: Dict[str, Any], initial: bool) -> None:
3389
+ state = _state_of(btn)
3390
+ if initial:
3391
+ # UIButtonTypeCustom defaults to a white title and inherits
3392
+ # no useful tint, so both the label and the SF Symbol box
3393
+ # are invisible on light backgrounds without an explicit
3394
+ # color.
3395
+ try:
3396
+ btn.setTitleColor_forState_(_uicolor("#111111"), 0)
3397
+ btn.setTintColor_(_uicolor("#111111"))
3398
+ except Exception:
3399
+ pass
3400
+ if "value" in props:
3401
+ state["value"] = bool(props["value"])
3402
+ if "label" in props:
3403
+ label = props["label"]
3404
+ try:
3405
+ btn.setTitle_forState_(str(label) if label is not None else "", 0)
3406
+ except Exception:
3407
+ pass
3408
+ # An image-bearing custom button is not exposed to the
3409
+ # accessibility tree by title alone; mirror the label
3410
+ # explicitly (an accessibility_label prop still wins below).
3411
+ try:
3412
+ btn.setAccessibilityLabel_(str(label) if label is not None else "")
3413
+ except Exception:
3414
+ pass
3415
+ if "disabled" in props:
3416
+ # ``disabled`` is present only when True; a removed prop
3417
+ # (None) re-enables the control.
3418
+ disabled = bool(props["disabled"]) if props["disabled"] is not None else False
3419
+ try:
3420
+ btn.setEnabled_(not disabled)
3421
+ except Exception:
3422
+ pass
3423
+ _checkbox_set_image(btn)
3424
+ _apply_accessibility(btn, props)
3325
3425
 
3326
3426
  def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
3327
3427
  try:
@@ -3336,110 +3436,40 @@ class CheckboxHandler(IOSViewHandler):
3336
3436
  except Exception:
3337
3437
  return (28.0, 28.0)
3338
3438
 
3339
- def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
3340
- state = _pn_checkbox_state.setdefault(id(btn), {"view": btn})
3341
- if "value" in props:
3342
- state["value"] = bool(props["value"])
3343
- if "on_change" in props:
3344
- state["on_change"] = props["on_change"]
3345
- if "color" in props:
3346
- state["color"] = props["color"]
3347
- if "label" in props:
3348
- label = props["label"]
3349
- btn.setTitle_forState_(str(label) if label is not None else "", 0)
3350
- if "disabled" in props:
3351
- # ``disabled`` is present only when True; a removed prop (None)
3352
- # re-enables the control.
3353
- disabled = bool(props["disabled"]) if props["disabled"] is not None else False
3354
- state["disabled"] = disabled
3355
- try:
3356
- btn.setEnabled_(not disabled)
3357
- except Exception:
3358
- pass
3359
- _checkbox_set_image(btn, state)
3360
- _apply_accessibility(btn, props)
3361
-
3362
3439
 
3363
3440
  # ======================================================================
3364
3441
  # SegmentedControl — native UISegmentedControl
3365
3442
  # ======================================================================
3366
3443
 
3367
3444
 
3368
- _pn_segmented_state: dict = {}
3369
- # Maps ``id(target)`` -> ``id(control)`` for per-instance lookup on change.
3370
- _pn_segmented_target_to_control: dict = {}
3371
-
3372
-
3373
- class _PNSegmentedTarget(NSObject): # type: ignore[valid-type]
3374
- @objc_method
3375
- def onChange_(self, sender: object) -> None:
3376
- cid = _pn_segmented_target_to_control.get(id(self))
3377
- if cid is None:
3378
- return
3379
- state = _pn_segmented_state.get(cid)
3380
- if not state or state.get("suppress"):
3381
- return
3382
- cb = state.get("on_change")
3383
- if cb is None:
3384
- return
3385
- try:
3386
- index = int(sender.selectedSegmentIndex)
3387
- except Exception:
3388
- return
3389
- try:
3390
- cb(index)
3391
- except Exception:
3392
- pass
3393
-
3394
-
3395
3445
  class SegmentedControlHandler(IOSViewHandler):
3396
- def create(self, props: Dict[str, Any]) -> Any:
3446
+ def _build(self, props: Dict[str, Any]) -> Any:
3397
3447
  UISegmentedControl = ObjCClass("UISegmentedControl")
3398
3448
  segments = [str(s) for s in (props.get("segments") or [])]
3399
3449
  control = UISegmentedControl.alloc().initWithItems_(segments)
3400
3450
  control.setTranslatesAutoresizingMaskIntoConstraints_(True)
3401
3451
  control.retain()
3402
3452
  _pn_retained_views.append(control)
3403
- cid = id(control)
3404
- _pn_segmented_state[cid] = {
3405
- "segments": list(segments),
3406
- "selected_index": 0,
3407
- "on_change": props.get("on_change"),
3408
- "suppress": False,
3409
- }
3410
- target = _PNSegmentedTarget.new()
3411
- target.retain()
3412
- _pn_retained_views.append(target)
3413
- _pn_segmented_target_to_control[id(target)] = cid
3414
- _pn_segmented_state[cid]["target"] = target
3415
- control.addTarget_action_forControlEvents_(target, SEL("onChange:"), 1 << 12) # ValueChanged
3416
- self._apply(control, props)
3417
- return control
3418
3453
 
3419
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
3420
- self._apply(native_view, changed)
3454
+ def _on_change() -> None:
3455
+ if _state_of(control).get("suppress"):
3456
+ return
3457
+ try:
3458
+ index = int(control.selectedSegmentIndex)
3459
+ except Exception:
3460
+ return
3461
+ _fire(control, "on_change", index)
3421
3462
 
3422
- def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
3423
- try:
3424
- mw = _safe_max(max_width, fallback=10000.0)
3425
- mh = _safe_max(max_height, fallback=10000.0)
3426
- size = native_view.sizeThatFits_((mw, mh))
3427
- w = float(size.width)
3428
- if math.isfinite(max_width):
3429
- w = min(w, max_width)
3430
- return (max(w, 0.0), max(float(size.height), 0.0))
3431
- except Exception:
3432
- return (0.0, 0.0)
3463
+ _register_control_action(control, 1 << 12, _on_change) # ValueChanged
3464
+ return control
3433
3465
 
3434
- def _apply(self, control: Any, props: Dict[str, Any]) -> None:
3435
- state = _pn_segmented_state.setdefault(id(control), {"suppress": False, "segments": [], "selected_index": 0})
3436
- if "on_change" in props:
3437
- state["on_change"] = props["on_change"]
3466
+ def _apply(self, control: Any, props: Dict[str, Any], initial: bool) -> None:
3467
+ state = _state_of(control)
3468
+ merged = state.get("props") or props
3438
3469
  rebuilt = False
3439
- if "segments" in props and props["segments"] is not None:
3470
+ if "segments" in props and props["segments"] is not None and not initial:
3440
3471
  new_segments = [str(s) for s in props["segments"]]
3441
3472
  if new_segments != state.get("segments"):
3442
- state["segments"] = new_segments
3443
3473
  state["suppress"] = True
3444
3474
  try:
3445
3475
  control.removeAllSegments()
@@ -3450,14 +3480,14 @@ class SegmentedControlHandler(IOSViewHandler):
3450
3480
  finally:
3451
3481
  state["suppress"] = False
3452
3482
  rebuilt = True
3453
- if "selected_index" in props and props["selected_index"] is not None:
3454
- state["selected_index"] = int(props["selected_index"])
3483
+ if "segments" in props and props["segments"] is not None:
3484
+ state["segments"] = [str(s) for s in props["segments"]]
3455
3485
  # Apply the selection when it changed or after a segment rebuild
3456
3486
  # (rebuilding resets the control to "no segment selected").
3457
- if rebuilt or ("selected_index" in props and props["selected_index"] is not None):
3487
+ if rebuilt or ("selected_index" in props and props["selected_index"] is not None) or initial:
3458
3488
  state["suppress"] = True
3459
3489
  try:
3460
- control.setSelectedSegmentIndex_(int(state.get("selected_index", 0)))
3490
+ control.setSelectedSegmentIndex_(int(merged.get("selected_index", 0) or 0))
3461
3491
  except Exception:
3462
3492
  pass
3463
3493
  finally:
@@ -3473,8 +3503,8 @@ class SegmentedControlHandler(IOSViewHandler):
3473
3503
  except Exception:
3474
3504
  pass
3475
3505
  if "enabled" in props:
3476
- # ``enabled`` is present only when False; a removed prop (None)
3477
- # re-enables the control.
3506
+ # ``enabled`` is present only when False; a removed prop
3507
+ # (None) re-enables the control.
3478
3508
  enabled = props["enabled"]
3479
3509
  try:
3480
3510
  control.setEnabled_(True if enabled is None else bool(enabled))
@@ -3482,6 +3512,18 @@ class SegmentedControlHandler(IOSViewHandler):
3482
3512
  pass
3483
3513
  _apply_accessibility(control, props)
3484
3514
 
3515
+ def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
3516
+ try:
3517
+ mw = _safe_max(max_width, fallback=10000.0)
3518
+ mh = _safe_max(max_height, fallback=10000.0)
3519
+ size = native_view.sizeThatFits_((mw, mh))
3520
+ w = float(size.width)
3521
+ if math.isfinite(max_width):
3522
+ w = min(w, max_width)
3523
+ return (max(w, 0.0), max(float(size.height), 0.0))
3524
+ except Exception:
3525
+ return (0.0, 0.0)
3526
+
3485
3527
 
3486
3528
  # ======================================================================
3487
3529
  # DatePicker — native UIDatePicker (compact style on iOS 13.4+)
@@ -3489,10 +3531,7 @@ class SegmentedControlHandler(IOSViewHandler):
3489
3531
 
3490
3532
 
3491
3533
  _DATE_PICKER_FORMATS = {"date": "yyyy-MM-dd", "time": "HH:mm", "datetime": "yyyy-MM-dd'T'HH:mm"}
3492
- _pn_date_formatters: dict = {}
3493
- _pn_datepicker_state: dict = {}
3494
- # Maps ``id(target)`` -> ``id(picker)`` for per-instance lookup on change.
3495
- _pn_datepicker_target_to_picker: dict = {}
3534
+ _pn_date_formatters: Dict[str, Any] = {}
3496
3535
 
3497
3536
 
3498
3537
  def _date_formatter(mode: str) -> Any:
@@ -3501,8 +3540,7 @@ def _date_formatter(mode: str) -> Any:
3501
3540
  cached = _pn_date_formatters.get(fmt)
3502
3541
  if cached is not None:
3503
3542
  return cached
3504
- NSDateFormatter = ObjCClass("NSDateFormatter")
3505
- formatter = NSDateFormatter.alloc().init()
3543
+ formatter = ObjCClass("NSDateFormatter").alloc().init()
3506
3544
  formatter.setDateFormat_(fmt)
3507
3545
  # A fixed POSIX locale keeps fixed-format parsing deterministic
3508
3546
  # (24-hour clock, no calendar/locale surprises) per Apple guidance.
@@ -3516,31 +3554,8 @@ def _date_formatter(mode: str) -> Any:
3516
3554
  return formatter
3517
3555
 
3518
3556
 
3519
- class _PNDatePickerTarget(NSObject): # type: ignore[valid-type]
3520
- @objc_method
3521
- def onChange_(self, sender: object) -> None:
3522
- pid = _pn_datepicker_target_to_picker.get(id(self))
3523
- if pid is None:
3524
- return
3525
- state = _pn_datepicker_state.get(pid)
3526
- if not state or state.get("suppress"):
3527
- return
3528
- cb = state.get("on_change")
3529
- if cb is None:
3530
- return
3531
- mode = state.get("mode", "date")
3532
- try:
3533
- iso = str(_date_formatter(mode).stringFromDate_(sender.date))
3534
- except Exception:
3535
- return
3536
- try:
3537
- cb(iso)
3538
- except Exception:
3539
- pass
3540
-
3541
-
3542
3557
  class DatePickerHandler(IOSViewHandler):
3543
- def create(self, props: Dict[str, Any]) -> Any:
3558
+ def _build(self, props: Dict[str, Any]) -> Any:
3544
3559
  picker = ObjCClass("UIDatePicker").alloc().init()
3545
3560
  picker.setTranslatesAutoresizingMaskIntoConstraints_(True)
3546
3561
  picker.retain()
@@ -3551,44 +3566,26 @@ class DatePickerHandler(IOSViewHandler):
3551
3566
  picker.setPreferredDatePickerStyle_(2) # UIDatePickerStyleCompact
3552
3567
  except Exception:
3553
3568
  pass
3554
- pid = id(picker)
3555
- _pn_datepicker_state[pid] = {
3556
- "mode": props.get("mode", "date"),
3557
- "on_change": props.get("on_change"),
3558
- "suppress": False,
3559
- }
3560
- target = _PNDatePickerTarget.new()
3561
- target.retain()
3562
- _pn_retained_views.append(target)
3563
- _pn_datepicker_target_to_picker[id(target)] = pid
3564
- _pn_datepicker_state[pid]["target"] = target
3565
- picker.addTarget_action_forControlEvents_(target, SEL("onChange:"), 1 << 12) # ValueChanged
3566
- self._apply(picker, props)
3567
- return picker
3568
3569
 
3569
- def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
3570
- self._apply(native_view, changed)
3570
+ def _on_change() -> None:
3571
+ state = _state_of(picker)
3572
+ if state.get("suppress"):
3573
+ return
3574
+ mode = (state.get("props") or {}).get("mode", "date")
3575
+ try:
3576
+ iso = str(_date_formatter(mode).stringFromDate_(picker.date))
3577
+ except Exception:
3578
+ return
3579
+ _fire(picker, "on_change", iso)
3571
3580
 
3572
- def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
3573
- try:
3574
- mw = _safe_max(max_width, fallback=10000.0)
3575
- mh = _safe_max(max_height, fallback=10000.0)
3576
- size = native_view.sizeThatFits_((mw, mh))
3577
- w = float(size.width)
3578
- if math.isfinite(max_width):
3579
- w = min(w, max_width)
3580
- return (max(w, 0.0), max(float(size.height), 0.0))
3581
- except Exception:
3582
- return (0.0, 0.0)
3581
+ _register_control_action(picker, 1 << 12, _on_change) # ValueChanged
3582
+ return picker
3583
3583
 
3584
- def _apply(self, picker: Any, props: Dict[str, Any]) -> None:
3585
- state = _pn_datepicker_state.setdefault(id(picker), {"suppress": False, "mode": "date"})
3586
- if "on_change" in props:
3587
- state["on_change"] = props["on_change"]
3588
- mode = state.get("mode", "date")
3584
+ def _apply(self, picker: Any, props: Dict[str, Any], initial: bool) -> None:
3585
+ state = _state_of(picker)
3586
+ merged = state.get("props") or props
3587
+ mode = str(merged.get("mode", "date") or "date")
3589
3588
  if "mode" in props and props["mode"] is not None:
3590
- mode = str(props["mode"])
3591
- state["mode"] = mode
3592
3589
  mode_map = {"time": 0, "date": 1, "datetime": 2}
3593
3590
  try:
3594
3591
  picker.setDatePickerMode_(mode_map.get(mode, 1))
@@ -3612,8 +3609,8 @@ class DatePickerHandler(IOSViewHandler):
3612
3609
  finally:
3613
3610
  state["suppress"] = False
3614
3611
  if "enabled" in props:
3615
- # ``enabled`` is present only when False; a removed prop (None)
3616
- # re-enables the picker.
3612
+ # ``enabled`` is present only when False; a removed prop
3613
+ # (None) re-enables the picker.
3617
3614
  enabled = props["enabled"]
3618
3615
  try:
3619
3616
  picker.setEnabled_(True if enabled is None else bool(enabled))
@@ -3631,6 +3628,18 @@ class DatePickerHandler(IOSViewHandler):
3631
3628
  except Exception:
3632
3629
  pass
3633
3630
 
3631
+ def measure_intrinsic(self, native_view: Any, max_width: float, max_height: float) -> Tuple[float, float]:
3632
+ try:
3633
+ mw = _safe_max(max_width, fallback=10000.0)
3634
+ mh = _safe_max(max_height, fallback=10000.0)
3635
+ size = native_view.sizeThatFits_((mw, mh))
3636
+ w = float(size.width)
3637
+ if math.isfinite(max_width):
3638
+ w = min(w, max_width)
3639
+ return (max(w, 0.0), max(float(size.height), 0.0))
3640
+ except Exception:
3641
+ return (0.0, 0.0)
3642
+
3634
3643
 
3635
3644
  # ======================================================================
3636
3645
  # Registration
@@ -3640,12 +3649,11 @@ class DatePickerHandler(IOSViewHandler):
3640
3649
  def register_handlers(registry: Any) -> None:
3641
3650
  """Register all iOS view handlers with the given registry."""
3642
3651
  flex = FlexContainerHandler()
3643
- registry.register("Text", TextHandler())
3644
- registry.register("Button", ButtonHandler())
3652
+ registry.register("View", flex)
3645
3653
  registry.register("Column", flex)
3646
3654
  registry.register("Row", flex)
3647
- registry.register("View", flex)
3648
- registry.register("ScrollView", ScrollViewHandler())
3655
+ registry.register("Text", TextHandler())
3656
+ registry.register("Button", ButtonHandler())
3649
3657
  registry.register("TextInput", TextInputHandler())
3650
3658
  registry.register("Image", ImageHandler())
3651
3659
  registry.register("Switch", SwitchHandler())
@@ -3653,6 +3661,7 @@ def register_handlers(registry: Any) -> None:
3653
3661
  registry.register("ActivityIndicator", ActivityIndicatorHandler())
3654
3662
  registry.register("WebView", WebViewHandler())
3655
3663
  registry.register("Spacer", SpacerHandler())
3664
+ registry.register("ScrollView", ScrollViewHandler())
3656
3665
  registry.register("SafeAreaView", SafeAreaViewHandler())
3657
3666
  registry.register("Modal", ModalHandler())
3658
3667
  registry.register("Slider", SliderHandler())
@@ -3660,7 +3669,6 @@ def register_handlers(registry: Any) -> None:
3660
3669
  registry.register("Pressable", PressableHandler())
3661
3670
  registry.register("StatusBar", StatusBarHandler())
3662
3671
  registry.register("KeyboardAvoidingView", KeyboardAvoidingViewHandler())
3663
- registry.register("VirtualList", VirtualListHandler())
3664
3672
  registry.register("Picker", PickerHandler())
3665
3673
  registry.register("Checkbox", CheckboxHandler())
3666
3674
  registry.register("SegmentedControl", SegmentedControlHandler())
@@ -3687,15 +3695,9 @@ __all__ = [
3687
3695
  "PressableHandler",
3688
3696
  "StatusBarHandler",
3689
3697
  "KeyboardAvoidingViewHandler",
3690
- "VirtualListHandler",
3691
3698
  "PickerHandler",
3692
3699
  "CheckboxHandler",
3693
3700
  "SegmentedControlHandler",
3694
3701
  "DatePickerHandler",
3695
3702
  "register_handlers",
3696
3703
  ]
3697
-
3698
-
3699
- # Avoid an unused-import warning from threading; it's available for
3700
- # future delegate use (e.g., Camera/Location callbacks).
3701
- _ = threading