pythonnative 0.23.0__py3-none-any.whl → 0.25.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.
@@ -576,7 +576,10 @@ class ModuleReloader:
576
576
  if getattr(vnode, "element", None) is not None:
577
577
  rewrite_element_tree(vnode.element)
578
578
  rendered = getattr(vnode, "_rendered", None)
579
- if rendered is not None:
579
+ if isinstance(rendered, (list, tuple)):
580
+ for rendered_el in rendered:
581
+ rewrite_element_tree(rendered_el)
582
+ elif rendered is not None:
580
583
  rewrite_element_tree(rendered)
581
584
  for child in getattr(vnode, "children", []) or []:
582
585
  visit(child)
@@ -598,6 +601,11 @@ class ModuleReloader:
598
601
  if not replacement_map:
599
602
  return False
600
603
  rewrites = ModuleReloader.swap_components_in_tree(reconciler, replacement_map)
604
+ if rewrites > 0 and hasattr(reconciler, "reset_hook_signatures"):
605
+ # New component bodies may legitimately call a different
606
+ # hook sequence; forget the recorded signatures so the
607
+ # dev-mode order guard doesn't flag the refresh itself.
608
+ reconciler.reset_hook_signatures()
601
609
  return rewrites > 0
602
610
 
603
611
  @staticmethod
@@ -28,11 +28,16 @@ Example:
28
28
  from __future__ import annotations
29
29
 
30
30
  import asyncio
31
- from typing import Any
31
+ from typing import Any, Dict
32
32
 
33
33
  from ..runtime import resolve_future
34
34
  from ..utils import IS_ANDROID
35
35
 
36
+ # Delayed Android deliveries in flight, keyed by identifier, so
37
+ # ``cancel`` can abort a notification that hasn't posted yet. (iOS
38
+ # delivers delays natively through UNTimeIntervalNotificationTrigger.)
39
+ _android_delayed: Dict[str, "asyncio.Task[Any]"] = {}
40
+
36
41
 
37
42
  class Notifications:
38
43
  """Local notification interface."""
@@ -82,7 +87,13 @@ class Notifications:
82
87
  """
83
88
  del options
84
89
  if IS_ANDROID:
85
- return await asyncio.to_thread(_android_schedule, title, body, delay_seconds, identifier)
90
+ if delay_seconds > 0:
91
+ _cancel_android_delayed(identifier)
92
+ _android_delayed[identifier] = asyncio.ensure_future(
93
+ _android_schedule_later(title, body, delay_seconds, identifier)
94
+ )
95
+ return True
96
+ return await asyncio.to_thread(_android_schedule, title, body, identifier)
86
97
  return await asyncio.to_thread(_ios_schedule, title, body, delay_seconds, identifier)
87
98
 
88
99
  @staticmethod
@@ -94,6 +105,7 @@ class Notifications:
94
105
  [`schedule`][pythonnative.native_modules.notifications.Notifications.schedule].
95
106
  """
96
107
  if IS_ANDROID:
108
+ _cancel_android_delayed(identifier)
97
109
  await asyncio.to_thread(_android_cancel, identifier)
98
110
  return
99
111
  await asyncio.to_thread(_ios_cancel, identifier)
@@ -104,8 +116,28 @@ class Notifications:
104
116
  # ======================================================================
105
117
 
106
118
 
107
- def _android_schedule(title: str, body: str, delay_seconds: float, identifier: str) -> bool:
108
- del delay_seconds # Android schedule is fire-and-forget for now.
119
+ def _cancel_android_delayed(identifier: str) -> None:
120
+ pending = _android_delayed.pop(identifier, None)
121
+ if pending is not None and not pending.done():
122
+ pending.cancel()
123
+
124
+
125
+ async def _android_schedule_later(title: str, body: str, delay_seconds: float, identifier: str) -> None:
126
+ """Post an Android notification after ``delay_seconds`` on the framework loop.
127
+
128
+ The delay lives in the app process (matching the semantics of
129
+ ``UNTimeIntervalNotificationTrigger`` closely enough for in-app
130
+ reminders); notifications whose delay outlives the process need
131
+ ``AlarmManager``, which is out of scope for the built-in module.
132
+ """
133
+ try:
134
+ await asyncio.sleep(delay_seconds)
135
+ await asyncio.to_thread(_android_schedule, title, body, identifier)
136
+ finally:
137
+ _android_delayed.pop(identifier, None)
138
+
139
+
140
+ def _android_schedule(title: str, body: str, identifier: str) -> bool:
109
141
  try:
110
142
  from java import jclass
111
143
 
@@ -193,8 +193,31 @@ def _ios_request(permission: str, on_done: Callable[[PermissionStatus], None]) -
193
193
 
194
194
  AVCaptureDevice.requestAccessForMediaType_completionHandler_(media, Block(_granted, None, bool))
195
195
  return
196
+
197
+ if permission == "photos":
198
+ PHPhotoLibrary = ObjCClass("PHPhotoLibrary")
199
+
200
+ def _photos_done(status: int) -> None:
201
+ # 0 notDetermined, 1 restricted, 2 denied, 3 authorized,
202
+ # 4 limited (counts as granted).
203
+ on_done({0: UNDETERMINED, 1: BLOCKED, 2: DENIED, 3: GRANTED, 4: GRANTED}.get(int(status), UNDETERMINED))
204
+
205
+ PHPhotoLibrary.requestAuthorization_(Block(_photos_done, None, int))
206
+ return
207
+
208
+ if permission == "notifications":
209
+ center = ObjCClass("UNUserNotificationCenter").currentNotificationCenter()
210
+
211
+ def _notif_done(granted: bool, _error: Any) -> None:
212
+ on_done(GRANTED if granted else DENIED)
213
+
214
+ # 0x07 = badge | sound | alert.
215
+ center.requestAuthorizationWithOptions_completionHandler_(0x07, Block(_notif_done, None, bool, object))
216
+ return
196
217
  except Exception:
197
218
  pass
198
219
  # Fall back to reporting the current status for permissions whose
199
- # request flow isn't wired up natively.
220
+ # request flow isn't wired up natively (location needs a
221
+ # CLLocationManager delegate; use the Location module's own
222
+ # request path for that).
200
223
  on_done(_ios_check(permission))
@@ -612,11 +612,16 @@ def _make_end_listener(anim_id: int) -> Any:
612
612
  AnimatorListener = jclass("android.animation.Animator$AnimatorListener")
613
613
 
614
614
  class _EndProxy(dynamic_proxy(AnimatorListener)):
615
+ # ``onAnimationStart`` / ``onAnimationEnd`` accept an optional
616
+ # ``isReverse``: API 26 added default two-arg overloads to
617
+ # ``AnimatorListener``, and ``ValueAnimator`` invokes those, so
618
+ # the java.lang.reflect.Proxy forwards two arguments here.
619
+
615
620
  def __init__(self) -> None:
616
621
  super().__init__()
617
622
  self._cancelled = False
618
623
 
619
- def onAnimationStart(self, animation: Any) -> None:
624
+ def onAnimationStart(self, animation: Any, isReverse: bool = False) -> None:
620
625
  pass
621
626
 
622
627
  def onAnimationRepeat(self, animation: Any) -> None:
@@ -625,7 +630,7 @@ def _make_end_listener(anim_id: int) -> Any:
625
630
  def onAnimationCancel(self, animation: Any) -> None:
626
631
  self._cancelled = True
627
632
 
628
- def onAnimationEnd(self, animation: Any) -> None:
633
+ def onAnimationEnd(self, animation: Any, isReverse: bool = False) -> None:
629
634
  entry = _native_anims.pop(anim_id, None)
630
635
  if entry is None:
631
636
  return
@@ -1073,7 +1078,7 @@ class ButtonHandler(AndroidViewHandler):
1073
1078
 
1074
1079
  class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1075
1080
  def onClick(self, view: Any) -> None:
1076
- _fire(view, "on_click")
1081
+ _fire(view, "on_press")
1077
1082
 
1078
1083
  btn.setOnClickListener(ClickProxy())
1079
1084
  return btn
@@ -2094,6 +2099,81 @@ class ModalHandler(AndroidViewHandler):
2094
2099
  pass
2095
2100
 
2096
2101
 
2102
+ # ======================================================================
2103
+ # Portal: floats its children over the screen in a top-level overlay
2104
+ # ======================================================================
2105
+
2106
+
2107
+ class PortalHandler(AndroidViewHandler):
2108
+ """Overlay container hosting [`Portal`][pythonnative.Portal] children.
2109
+
2110
+ The portal's native view is a full-size, non-clickable
2111
+ ``FrameLayout`` added directly to the screen's fragment container
2112
+ (the same parent as the screen root, so portal coordinates equal
2113
+ viewport coordinates). It is appended last, which stacks it above
2114
+ the main content; because the overlay itself never consumes
2115
+ touches, taps on empty areas fall through to the screen below
2116
+ while the portal's own children stay interactive.
2117
+
2118
+ The overlay attaches lazily on the first child insert and re-homes
2119
+ itself if the fragment container was rebuilt (e.g. after popping
2120
+ back to a previously mounted screen).
2121
+ """
2122
+
2123
+ def _build(self, props: Dict[str, Any]) -> Any:
2124
+ overlay = jclass("android.widget.FrameLayout")(_ctx())
2125
+ overlay.setClickable(False)
2126
+ return overlay
2127
+
2128
+ def _apply(self, overlay: Any, props: Dict[str, Any], initial: bool) -> None:
2129
+ _apply_common_visual(overlay, props)
2130
+ if not initial:
2131
+ self._ensure_attached(overlay)
2132
+
2133
+ def _ensure_attached(self, overlay: Any) -> None:
2134
+ try:
2135
+ from ..utils import get_android_fragment_container
2136
+
2137
+ container = get_android_fragment_container()
2138
+ except Exception:
2139
+ return
2140
+ try:
2141
+ parent = overlay.getParent()
2142
+ if parent is not None and _java_id(parent) == _java_id(container):
2143
+ overlay.bringToFront()
2144
+ return
2145
+ if parent is not None:
2146
+ parent.removeView(overlay)
2147
+ LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
2148
+ lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
2149
+ container.addView(overlay, lp)
2150
+ except Exception:
2151
+ pass
2152
+
2153
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
2154
+ self._ensure_attached(parent)
2155
+ _insert_view(parent, child, index)
2156
+
2157
+ def remove_child(self, parent: Any, child: Any) -> None:
2158
+ try:
2159
+ parent.removeView(child)
2160
+ except Exception:
2161
+ pass
2162
+
2163
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2164
+ # The overlay fills its container via MATCH_PARENT layout params;
2165
+ # the engine frames only the portal's children.
2166
+ return
2167
+
2168
+ def _teardown(self, overlay: Any) -> None:
2169
+ try:
2170
+ parent = overlay.getParent()
2171
+ if parent is not None:
2172
+ parent.removeView(overlay)
2173
+ except Exception:
2174
+ pass
2175
+
2176
+
2097
2177
  class SliderHandler(AndroidViewHandler):
2098
2178
  def _build(self, props: Dict[str, Any]) -> Any:
2099
2179
  sb = jclass("android.widget.SeekBar")(_ctx())
@@ -3211,6 +3291,7 @@ def register_handlers(registry: Any) -> None:
3211
3291
  registry.register("Spacer", SpacerHandler())
3212
3292
  registry.register("SafeAreaView", SafeAreaViewHandler())
3213
3293
  registry.register("Modal", ModalHandler())
3294
+ registry.register("Portal", PortalHandler())
3214
3295
  registry.register("Slider", SliderHandler())
3215
3296
  registry.register("TabBar", TabBarHandler())
3216
3297
  registry.register("Pressable", PressableHandler())
@@ -3238,6 +3319,7 @@ __all__ = [
3238
3319
  "SpacerHandler",
3239
3320
  "SafeAreaViewHandler",
3240
3321
  "ModalHandler",
3322
+ "PortalHandler",
3241
3323
  "SliderHandler",
3242
3324
  "TabBarHandler",
3243
3325
  "PressableHandler",
@@ -803,7 +803,7 @@ class TextHandler(DesktopViewHandler):
803
803
  class ButtonHandler(DesktopViewHandler):
804
804
  def build(self, props: Dict[str, Any]) -> Any:
805
805
  button = tk.Button(_master(), highlightthickness=0, takefocus=0)
806
- button.configure(command=lambda: _fire(button, "on_click"))
806
+ button.configure(command=lambda: _fire(button, "on_press"))
807
807
  return button
808
808
 
809
809
  def apply(self, button: Any, props: Dict[str, Any]) -> None:
@@ -1328,6 +1328,42 @@ class ModalHandler(DesktopViewHandler):
1328
1328
  return
1329
1329
 
1330
1330
 
1331
+ # ======================================================================
1332
+ # Portal
1333
+ # ======================================================================
1334
+
1335
+
1336
+ class PortalHandler(DesktopViewHandler):
1337
+ """Portal host that floats children over the whole stage.
1338
+
1339
+ Tkinter has no view that can cover the stage without also
1340
+ swallowing clicks, so the portal keeps its own frame unmapped and
1341
+ instead places each child directly against the stage (``_place``
1342
+ with no logical parent targets the root container). The detached
1343
+ layout pass produces viewport coordinates, which is exactly the
1344
+ stage coordinate space, and ``_place`` lifts children above the
1345
+ main content. Empty portal area has no widget at all, so clicks
1346
+ there reach whatever sits underneath.
1347
+ """
1348
+
1349
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
1350
+ # No ``_pn_parent``: placement composes against the stage.
1351
+ child._pn_parent = None
1352
+ _register_child(parent, child, index)
1353
+ _place(child)
1354
+
1355
+ def remove_child(self, parent: Any, child: Any) -> None:
1356
+ _unregister_child(parent, child)
1357
+ try:
1358
+ child.place_forget()
1359
+ except Exception:
1360
+ pass
1361
+
1362
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
1363
+ # The portal frame itself stays unmapped; only children render.
1364
+ return
1365
+
1366
+
1331
1367
  # ======================================================================
1332
1368
  # TabBar
1333
1369
  # ======================================================================
@@ -1580,6 +1616,7 @@ def register_handlers(registry: Any) -> None:
1580
1616
  registry.register("ScrollView", ScrollViewHandler())
1581
1617
  registry.register("SafeAreaView", SafeAreaViewHandler())
1582
1618
  registry.register("Modal", ModalHandler())
1619
+ registry.register("Portal", PortalHandler())
1583
1620
  registry.register("Slider", SliderHandler())
1584
1621
  registry.register("TabBar", TabBarHandler())
1585
1622
  registry.register("Pressable", PressableHandler())
@@ -1589,7 +1589,7 @@ class ButtonHandler(IOSViewHandler):
1589
1589
  btn.setTranslatesAutoresizingMaskIntoConstraints_(True)
1590
1590
  btn.retain()
1591
1591
  _pn_retained_views.append(btn)
1592
- _register_control_action(btn, 1 << 6, lambda: _fire(btn, "on_click")) # TouchUpInside
1592
+ _register_control_action(btn, 1 << 6, lambda: _fire(btn, "on_press")) # TouchUpInside
1593
1593
  return btn
1594
1594
 
1595
1595
  def measure_intrinsic(
@@ -3137,6 +3137,185 @@ class ModalHandler(IOSViewHandler):
3137
3137
  _fire(placeholder, "on_dismiss")
3138
3138
 
3139
3139
 
3140
+ # ======================================================================
3141
+ # Portal: floats its children over the screen in a top-level overlay
3142
+ # ======================================================================
3143
+ #
3144
+ # The overlay is an instance of ``_PNPortalViewCTypes``, a raw libobjc
3145
+ # subclass of ``UIView`` overriding ``pointInside:withEvent:`` so the
3146
+ # overlay only claims touches that land on one of its subviews. UIKit's
3147
+ # default hit-testing skips a view (and its whole subtree) when
3148
+ # ``pointInside:`` returns NO, so taps on empty portal area fall
3149
+ # through to the screen content below while the portal's own children
3150
+ # stay fully interactive. Registered raw (not via rubicon's
3151
+ # ``@objc_method``) for the same arm64 FFI reliability reasons as the
3152
+ # other delegate classes in this module.
3153
+
3154
+
3155
+ class _PNCGPoint(_ct.Structure):
3156
+ _fields_ = [("x", _ct.c_double), ("y", _ct.c_double)]
3157
+
3158
+
3159
+ _PORTAL_POINT_INSIDE_TYPE = _ct.CFUNCTYPE(_ct.c_bool, _ct.c_void_p, _ct.c_void_p, _PNCGPoint, _ct.c_void_p)
3160
+
3161
+
3162
+ def _portal_point_inside_imp(self_ptr: int, _cmd_ptr: int, point: Any, _event_ptr: int) -> bool:
3163
+ """Return whether ``point`` lands on any visible, interactive subview."""
3164
+ try:
3165
+ view = ObjCInstance(_ct.c_void_p(self_ptr))
3166
+ px = float(point.x)
3167
+ py = float(point.y)
3168
+ # On instances of this raw-registered class rubicon resolves
3169
+ # ``subviews`` as a bound method rather than a property, so it
3170
+ # must be called to get the NSArray.
3171
+ subs = view.subviews
3172
+ if callable(subs):
3173
+ subs = subs()
3174
+ for sub in list(subs or []):
3175
+ try:
3176
+ if bool(sub.isHidden()) or float(sub.alpha) <= 0.01:
3177
+ continue
3178
+ if not bool(sub.isUserInteractionEnabled()):
3179
+ continue
3180
+ frame = sub.frame
3181
+ fx = float(frame.origin.x)
3182
+ fy = float(frame.origin.y)
3183
+ fw = float(frame.size.width)
3184
+ fh = float(frame.size.height)
3185
+ if fx <= px <= fx + fw and fy <= py <= fy + fh:
3186
+ return True
3187
+ except Exception:
3188
+ continue
3189
+ return False
3190
+ except Exception:
3191
+ return False
3192
+
3193
+
3194
+ _portal_point_inside_imp_ref = _PORTAL_POINT_INSIDE_TYPE(_portal_point_inside_imp)
3195
+
3196
+ _UIVIEW_CLS = _get_cls(b"UIView")
3197
+ _PN_PORTAL_VIEW_CLS = _alloc_cls(_UIVIEW_CLS, b"_PNPortalViewCTypes", 0) if _UIVIEW_CLS else None
3198
+ if _PN_PORTAL_VIEW_CLS:
3199
+ _add_method(
3200
+ _PN_PORTAL_VIEW_CLS,
3201
+ _sel_reg(b"pointInside:withEvent:"),
3202
+ _ct.cast(_portal_point_inside_imp_ref, _ct.c_void_p),
3203
+ b"c@:{CGPoint=dd}@",
3204
+ )
3205
+ _reg_cls(_PN_PORTAL_VIEW_CLS)
3206
+
3207
+
3208
+ class PortalHandler(IOSViewHandler):
3209
+ """Overlay container hosting [`Portal`][pythonnative.Portal] children.
3210
+
3211
+ The portal's native view is a transparent, full-size overlay added
3212
+ to the topmost view controller's view (the same view the screen
3213
+ root is attached to). Its frame mirrors the screen host's root
3214
+ frame (below the top safe-area inset), so portal coordinates equal
3215
+ viewport coordinates. Attachment happens lazily on the first child
3216
+ insert and re-homes itself if the host view changed (e.g. after a
3217
+ native modal closed).
3218
+
3219
+ When the ``_PNPortalViewCTypes`` registration is unavailable the
3220
+ handler degrades to a plain ``UIView`` overlay: portal children
3221
+ still render and receive touches, but empty portal area blocks the
3222
+ content below instead of passing touches through.
3223
+ """
3224
+
3225
+ def _build(self, props: Dict[str, Any]) -> Any:
3226
+ overlay = None
3227
+ if _PN_PORTAL_VIEW_CLS:
3228
+ try:
3229
+ overlay = ObjCClass("_PNPortalViewCTypes").alloc().init()
3230
+ except Exception:
3231
+ overlay = None
3232
+ if overlay is None:
3233
+ overlay = ObjCClass("UIView").alloc().init()
3234
+ overlay.setTranslatesAutoresizingMaskIntoConstraints_(True)
3235
+ overlay.setBackgroundColor_(_uicolor("#00000000"))
3236
+ overlay.retain()
3237
+ _pn_retained_views.append(overlay)
3238
+ return overlay
3239
+
3240
+ def _apply(self, overlay: Any, props: Dict[str, Any], initial: bool) -> None:
3241
+ _apply_common_visual(overlay, props)
3242
+ if not initial:
3243
+ self._ensure_attached(overlay)
3244
+
3245
+ def _ensure_attached(self, overlay: Any) -> None:
3246
+ try:
3247
+ UIApplication = ObjCClass("UIApplication")
3248
+ top = _top_view_controller_for_alert(UIApplication.sharedApplication)
3249
+ host = top.view if top is not None else None
3250
+ if host is None:
3251
+ return
3252
+ current = overlay.superview
3253
+ if current is not None and _objc_ptr(current) == _objc_ptr(host):
3254
+ # Already attached to the right host. Only reorder when
3255
+ # the overlay is not frontmost: reordering the subview
3256
+ # array during an active touch sequence (a re-render can
3257
+ # flush between touch-down and touch-up) can cancel the
3258
+ # touch and leave the portal's controls stuck
3259
+ # highlighted with their action never fired.
3260
+ try:
3261
+ front = host.subviews.lastObject()
3262
+ if front is None or _objc_ptr(front) != _objc_ptr(overlay):
3263
+ host.bringSubviewToFront_(overlay)
3264
+ except Exception:
3265
+ host.bringSubviewToFront_(overlay)
3266
+ self._sync_overlay_frame(overlay, host)
3267
+ return
3268
+ if current is not None:
3269
+ overlay.removeFromSuperview()
3270
+ host.addSubview_(overlay)
3271
+ self._sync_overlay_frame(overlay, host)
3272
+ except Exception:
3273
+ pass
3274
+
3275
+ @staticmethod
3276
+ def _sync_overlay_frame(overlay: Any, host: Any) -> None:
3277
+ """Mirror the screen host's root frame (see ``_sync_root_frame``)."""
3278
+ try:
3279
+ bounds = host.bounds
3280
+ insets = host.safeAreaInsets
3281
+ top = float(insets.top)
3282
+ left = float(insets.left)
3283
+ right = float(insets.right)
3284
+ w = max(0.0, float(bounds.size.width) - left - right)
3285
+ h = max(0.0, float(bounds.size.height) - top)
3286
+ overlay.setFrame_(((left, top), (w, h)))
3287
+ overlay.setAutoresizingMask_(2 | 16) # FlexibleWidth | FlexibleHeight
3288
+ except Exception:
3289
+ pass
3290
+
3291
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
3292
+ self._ensure_attached(parent)
3293
+ super().insert_child(parent, child, index)
3294
+
3295
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
3296
+ # The overlay frames itself against the host view; the engine
3297
+ # frames only the portal's children.
3298
+ return
3299
+
3300
+ def measure_intrinsic(
3301
+ self,
3302
+ native_view: Any,
3303
+ max_width: float,
3304
+ max_height: float,
3305
+ ) -> Tuple[float, float]:
3306
+ return (0.0, 0.0)
3307
+
3308
+ def _teardown(self, overlay: Any) -> None:
3309
+ try:
3310
+ _pn_retained_views.remove(overlay)
3311
+ except ValueError:
3312
+ pass
3313
+ try:
3314
+ overlay.release()
3315
+ except Exception:
3316
+ pass
3317
+
3318
+
3140
3319
  # ======================================================================
3141
3320
  # StatusBar: global side effect, no view in the tree
3142
3321
  # ======================================================================
@@ -3972,6 +4151,28 @@ def _table_row_height(info: dict, row: int) -> float:
3972
4151
  return float(info.get("row_height", 44.0))
3973
4152
 
3974
4153
 
4154
+ def _table_row_start(info: dict, row: int) -> float:
4155
+ """Content-coordinate y where ``row`` begins."""
4156
+ starts = info.get("row_starts")
4157
+ if starts is not None and 0 <= row < len(starts):
4158
+ return float(starts[row])
4159
+ return float(info.get("row_height", 44.0)) * row
4160
+
4161
+
4162
+ def _table_set_heights(info: dict, heights: Optional[List[float]]) -> None:
4163
+ """Store per-row heights plus their prefix sums (for row starts)."""
4164
+ info["row_heights"] = heights
4165
+ if not heights:
4166
+ info["row_starts"] = None
4167
+ return
4168
+ starts = [0.0] * len(heights)
4169
+ acc = 0.0
4170
+ for i, h in enumerate(heights):
4171
+ starts[i] = acc
4172
+ acc += float(h)
4173
+ info["row_starts"] = starts
4174
+
4175
+
3975
4176
  def _table_num_sections_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int) -> int:
3976
4177
  return 1
3977
4178
 
@@ -4041,7 +4242,37 @@ def _table_cell_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> in
4041
4242
  except Exception:
4042
4243
  pass
4043
4244
 
4044
- if info is not None:
4245
+ # Only rows in (or near) the visible content window get a
4246
+ # mounted subtree. UITableView's accessibility container calls
4247
+ # ``cellForRowAtIndexPath:`` for *every* row whenever an
4248
+ # accessibility client (VoiceOver, XCUITest / UI-test drivers)
4249
+ # snapshots the hierarchy, no matter the estimated heights.
4250
+ # Mounting those would defeat virtualization -- a 10k-row list
4251
+ # would mount 10k Python subtrees on the first snapshot -- and
4252
+ # the never-displayed cells would sit stacked at the table
4253
+ # origin where tests read them as visible. Off-window requests
4254
+ # get an empty accessibility-hidden placeholder; when the row
4255
+ # really scrolls on screen UIKit asks again and the content
4256
+ # window has moved, so the real subtree mounts.
4257
+ try:
4258
+ offset_y = float(tv.contentOffset.y)
4259
+ except Exception:
4260
+ offset_y = 0.0
4261
+ try:
4262
+ viewport_h = float(tv.bounds.size.height)
4263
+ except Exception:
4264
+ viewport_h = 0.0
4265
+ row_start = _table_row_start(info, row) if info else 0.0
4266
+ margin = viewport_h if viewport_h > 0 else 800.0
4267
+ in_window = (row_start + row_h >= offset_y - margin) and (
4268
+ row_start <= offset_y + max(viewport_h, row_h) + margin
4269
+ )
4270
+ try:
4271
+ cell.setAccessibilityElementsHidden_(not in_window)
4272
+ except Exception:
4273
+ pass
4274
+
4275
+ if in_window and info is not None:
4045
4276
  render_row = info.get("render_row")
4046
4277
  pool = info.get("pool")
4047
4278
  if render_row is not None and pool is not None:
@@ -4195,7 +4426,19 @@ class VirtualListHandler(IOSViewHandler):
4195
4426
  tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
4196
4427
  tv.setSeparatorStyle_(0) # none by default; rows draw their own
4197
4428
  try:
4198
- tv.setEstimatedRowHeight_(0.0) # force exact heightForRow
4429
+ # A *nonzero* estimate keeps UITableView lazy: with
4430
+ # ``estimatedRowHeight = 0`` modern UIKit builds cells for
4431
+ # every row up front to size its content, which mounts a
4432
+ # Python subtree per row (defeating virtualization) and
4433
+ # leaves the never-displayed cells visible to the
4434
+ # accessibility tree. ``heightForRowAtIndexPath:`` still
4435
+ # returns exact heights for the rows that do come on screen.
4436
+ heights = props.get("row_heights")
4437
+ if heights:
4438
+ estimate = sum(float(h) for h in heights) / max(1, len(heights))
4439
+ else:
4440
+ estimate = float(props.get("row_height", 44.0) or 44.0)
4441
+ tv.setEstimatedRowHeight_(max(1.0, estimate))
4199
4442
  except Exception:
4200
4443
  pass
4201
4444
  tv.retain()
@@ -4205,14 +4448,15 @@ class VirtualListHandler(IOSViewHandler):
4205
4448
  if source_ptr == 0:
4206
4449
  raise RuntimeError("[VirtualList][iOS] dataSource alloc returned NULL")
4207
4450
 
4208
- _pn_table_state[source_ptr] = {
4451
+ state = {
4209
4452
  "count": int(props.get("count", 0) or 0),
4210
4453
  "row_height": float(props.get("row_height", 44.0) or 44.0),
4211
- "row_heights": props.get("row_heights"),
4212
4454
  "render_row": props.get("render_row"),
4213
4455
  "pool": RowHostPool(),
4214
4456
  "tv": tv,
4215
4457
  }
4458
+ _table_set_heights(state, props.get("row_heights"))
4459
+ _pn_table_state[source_ptr] = state
4216
4460
 
4217
4461
  _objc_msgSend.restype = None
4218
4462
  _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
@@ -4220,7 +4464,6 @@ class VirtualListHandler(IOSViewHandler):
4220
4464
  _objc_msgSend(tv_ptr, _SEL_SET_DATA_SOURCE, _ct.c_void_p(source_ptr))
4221
4465
  _objc_msgSend(tv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(source_ptr))
4222
4466
 
4223
- state = _pn_table_state[source_ptr]
4224
4467
  state["source_ptr"] = source_ptr
4225
4468
  return tv
4226
4469
 
@@ -4245,7 +4488,7 @@ class VirtualListHandler(IOSViewHandler):
4245
4488
  info["row_height"] = float(props["row_height"])
4246
4489
  data_changed = True
4247
4490
  if "row_heights" in props:
4248
- info["row_heights"] = props.get("row_heights")
4491
+ _table_set_heights(info, props.get("row_heights"))
4249
4492
  data_changed = True
4250
4493
  if "render_row" in props:
4251
4494
  info["render_row"] = props.get("render_row")
@@ -4350,6 +4593,7 @@ def register_handlers(registry: Any) -> None:
4350
4593
  registry.register("ScrollView", ScrollViewHandler())
4351
4594
  registry.register("SafeAreaView", SafeAreaViewHandler())
4352
4595
  registry.register("Modal", ModalHandler())
4596
+ registry.register("Portal", PortalHandler())
4353
4597
  registry.register("Slider", SliderHandler())
4354
4598
  registry.register("TabBar", TabBarHandler())
4355
4599
  registry.register("Pressable", PressableHandler())
@@ -4377,6 +4621,7 @@ __all__ = [
4377
4621
  "SpacerHandler",
4378
4622
  "SafeAreaViewHandler",
4379
4623
  "ModalHandler",
4624
+ "PortalHandler",
4380
4625
  "SliderHandler",
4381
4626
  "TabBarHandler",
4382
4627
  "PressableHandler",