pythonnative 0.23.0__py3-none-any.whl → 0.24.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
@@ -1073,7 +1073,7 @@ class ButtonHandler(AndroidViewHandler):
1073
1073
 
1074
1074
  class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
1075
1075
  def onClick(self, view: Any) -> None:
1076
- _fire(view, "on_click")
1076
+ _fire(view, "on_press")
1077
1077
 
1078
1078
  btn.setOnClickListener(ClickProxy())
1079
1079
  return btn
@@ -2094,6 +2094,81 @@ class ModalHandler(AndroidViewHandler):
2094
2094
  pass
2095
2095
 
2096
2096
 
2097
+ # ======================================================================
2098
+ # Portal: floats its children over the screen in a top-level overlay
2099
+ # ======================================================================
2100
+
2101
+
2102
+ class PortalHandler(AndroidViewHandler):
2103
+ """Overlay container hosting [`Portal`][pythonnative.Portal] children.
2104
+
2105
+ The portal's native view is a full-size, non-clickable
2106
+ ``FrameLayout`` added directly to the screen's fragment container
2107
+ (the same parent as the screen root, so portal coordinates equal
2108
+ viewport coordinates). It is appended last, which stacks it above
2109
+ the main content; because the overlay itself never consumes
2110
+ touches, taps on empty areas fall through to the screen below
2111
+ while the portal's own children stay interactive.
2112
+
2113
+ The overlay attaches lazily on the first child insert and re-homes
2114
+ itself if the fragment container was rebuilt (e.g. after popping
2115
+ back to a previously mounted screen).
2116
+ """
2117
+
2118
+ def _build(self, props: Dict[str, Any]) -> Any:
2119
+ overlay = jclass("android.widget.FrameLayout")(_ctx())
2120
+ overlay.setClickable(False)
2121
+ return overlay
2122
+
2123
+ def _apply(self, overlay: Any, props: Dict[str, Any], initial: bool) -> None:
2124
+ _apply_common_visual(overlay, props)
2125
+ if not initial:
2126
+ self._ensure_attached(overlay)
2127
+
2128
+ def _ensure_attached(self, overlay: Any) -> None:
2129
+ try:
2130
+ from ..utils import get_android_fragment_container
2131
+
2132
+ container = get_android_fragment_container()
2133
+ except Exception:
2134
+ return
2135
+ try:
2136
+ parent = overlay.getParent()
2137
+ if parent is not None and _java_id(parent) == _java_id(container):
2138
+ overlay.bringToFront()
2139
+ return
2140
+ if parent is not None:
2141
+ parent.removeView(overlay)
2142
+ LayoutParams = jclass("android.view.ViewGroup$LayoutParams")
2143
+ lp = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
2144
+ container.addView(overlay, lp)
2145
+ except Exception:
2146
+ pass
2147
+
2148
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
2149
+ self._ensure_attached(parent)
2150
+ _insert_view(parent, child, index)
2151
+
2152
+ def remove_child(self, parent: Any, child: Any) -> None:
2153
+ try:
2154
+ parent.removeView(child)
2155
+ except Exception:
2156
+ pass
2157
+
2158
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
2159
+ # The overlay fills its container via MATCH_PARENT layout params;
2160
+ # the engine frames only the portal's children.
2161
+ return
2162
+
2163
+ def _teardown(self, overlay: Any) -> None:
2164
+ try:
2165
+ parent = overlay.getParent()
2166
+ if parent is not None:
2167
+ parent.removeView(overlay)
2168
+ except Exception:
2169
+ pass
2170
+
2171
+
2097
2172
  class SliderHandler(AndroidViewHandler):
2098
2173
  def _build(self, props: Dict[str, Any]) -> Any:
2099
2174
  sb = jclass("android.widget.SeekBar")(_ctx())
@@ -3211,6 +3286,7 @@ def register_handlers(registry: Any) -> None:
3211
3286
  registry.register("Spacer", SpacerHandler())
3212
3287
  registry.register("SafeAreaView", SafeAreaViewHandler())
3213
3288
  registry.register("Modal", ModalHandler())
3289
+ registry.register("Portal", PortalHandler())
3214
3290
  registry.register("Slider", SliderHandler())
3215
3291
  registry.register("TabBar", TabBarHandler())
3216
3292
  registry.register("Pressable", PressableHandler())
@@ -3238,6 +3314,7 @@ __all__ = [
3238
3314
  "SpacerHandler",
3239
3315
  "SafeAreaViewHandler",
3240
3316
  "ModalHandler",
3317
+ "PortalHandler",
3241
3318
  "SliderHandler",
3242
3319
  "TabBarHandler",
3243
3320
  "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,174 @@ 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
+ host.bringSubviewToFront_(overlay)
3255
+ self._sync_overlay_frame(overlay, host)
3256
+ return
3257
+ if current is not None:
3258
+ overlay.removeFromSuperview()
3259
+ host.addSubview_(overlay)
3260
+ self._sync_overlay_frame(overlay, host)
3261
+ except Exception:
3262
+ pass
3263
+
3264
+ @staticmethod
3265
+ def _sync_overlay_frame(overlay: Any, host: Any) -> None:
3266
+ """Mirror the screen host's root frame (see ``_sync_root_frame``)."""
3267
+ try:
3268
+ bounds = host.bounds
3269
+ insets = host.safeAreaInsets
3270
+ top = float(insets.top)
3271
+ left = float(insets.left)
3272
+ right = float(insets.right)
3273
+ w = max(0.0, float(bounds.size.width) - left - right)
3274
+ h = max(0.0, float(bounds.size.height) - top)
3275
+ overlay.setFrame_(((left, top), (w, h)))
3276
+ overlay.setAutoresizingMask_(2 | 16) # FlexibleWidth | FlexibleHeight
3277
+ except Exception:
3278
+ pass
3279
+
3280
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
3281
+ self._ensure_attached(parent)
3282
+ super().insert_child(parent, child, index)
3283
+
3284
+ def set_frame(self, native_view: Any, x: float, y: float, width: float, height: float) -> None:
3285
+ # The overlay frames itself against the host view; the engine
3286
+ # frames only the portal's children.
3287
+ return
3288
+
3289
+ def measure_intrinsic(
3290
+ self,
3291
+ native_view: Any,
3292
+ max_width: float,
3293
+ max_height: float,
3294
+ ) -> Tuple[float, float]:
3295
+ return (0.0, 0.0)
3296
+
3297
+ def _teardown(self, overlay: Any) -> None:
3298
+ try:
3299
+ _pn_retained_views.remove(overlay)
3300
+ except ValueError:
3301
+ pass
3302
+ try:
3303
+ overlay.release()
3304
+ except Exception:
3305
+ pass
3306
+
3307
+
3140
3308
  # ======================================================================
3141
3309
  # StatusBar: global side effect, no view in the tree
3142
3310
  # ======================================================================
@@ -3972,6 +4140,28 @@ def _table_row_height(info: dict, row: int) -> float:
3972
4140
  return float(info.get("row_height", 44.0))
3973
4141
 
3974
4142
 
4143
+ def _table_row_start(info: dict, row: int) -> float:
4144
+ """Content-coordinate y where ``row`` begins."""
4145
+ starts = info.get("row_starts")
4146
+ if starts is not None and 0 <= row < len(starts):
4147
+ return float(starts[row])
4148
+ return float(info.get("row_height", 44.0)) * row
4149
+
4150
+
4151
+ def _table_set_heights(info: dict, heights: Optional[List[float]]) -> None:
4152
+ """Store per-row heights plus their prefix sums (for row starts)."""
4153
+ info["row_heights"] = heights
4154
+ if not heights:
4155
+ info["row_starts"] = None
4156
+ return
4157
+ starts = [0.0] * len(heights)
4158
+ acc = 0.0
4159
+ for i, h in enumerate(heights):
4160
+ starts[i] = acc
4161
+ acc += float(h)
4162
+ info["row_starts"] = starts
4163
+
4164
+
3975
4165
  def _table_num_sections_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int) -> int:
3976
4166
  return 1
3977
4167
 
@@ -4041,7 +4231,37 @@ def _table_cell_imp(self_ptr: int, cmd_ptr: int, tv_ptr: int, ip_ptr: int) -> in
4041
4231
  except Exception:
4042
4232
  pass
4043
4233
 
4044
- if info is not None:
4234
+ # Only rows in (or near) the visible content window get a
4235
+ # mounted subtree. UITableView's accessibility container calls
4236
+ # ``cellForRowAtIndexPath:`` for *every* row whenever an
4237
+ # accessibility client (VoiceOver, XCUITest / UI-test drivers)
4238
+ # snapshots the hierarchy, no matter the estimated heights.
4239
+ # Mounting those would defeat virtualization -- a 10k-row list
4240
+ # would mount 10k Python subtrees on the first snapshot -- and
4241
+ # the never-displayed cells would sit stacked at the table
4242
+ # origin where tests read them as visible. Off-window requests
4243
+ # get an empty accessibility-hidden placeholder; when the row
4244
+ # really scrolls on screen UIKit asks again and the content
4245
+ # window has moved, so the real subtree mounts.
4246
+ try:
4247
+ offset_y = float(tv.contentOffset.y)
4248
+ except Exception:
4249
+ offset_y = 0.0
4250
+ try:
4251
+ viewport_h = float(tv.bounds.size.height)
4252
+ except Exception:
4253
+ viewport_h = 0.0
4254
+ row_start = _table_row_start(info, row) if info else 0.0
4255
+ margin = viewport_h if viewport_h > 0 else 800.0
4256
+ in_window = (row_start + row_h >= offset_y - margin) and (
4257
+ row_start <= offset_y + max(viewport_h, row_h) + margin
4258
+ )
4259
+ try:
4260
+ cell.setAccessibilityElementsHidden_(not in_window)
4261
+ except Exception:
4262
+ pass
4263
+
4264
+ if in_window and info is not None:
4045
4265
  render_row = info.get("render_row")
4046
4266
  pool = info.get("pool")
4047
4267
  if render_row is not None and pool is not None:
@@ -4195,7 +4415,19 @@ class VirtualListHandler(IOSViewHandler):
4195
4415
  tv.setTranslatesAutoresizingMaskIntoConstraints_(True)
4196
4416
  tv.setSeparatorStyle_(0) # none by default; rows draw their own
4197
4417
  try:
4198
- tv.setEstimatedRowHeight_(0.0) # force exact heightForRow
4418
+ # A *nonzero* estimate keeps UITableView lazy: with
4419
+ # ``estimatedRowHeight = 0`` modern UIKit builds cells for
4420
+ # every row up front to size its content, which mounts a
4421
+ # Python subtree per row (defeating virtualization) and
4422
+ # leaves the never-displayed cells visible to the
4423
+ # accessibility tree. ``heightForRowAtIndexPath:`` still
4424
+ # returns exact heights for the rows that do come on screen.
4425
+ heights = props.get("row_heights")
4426
+ if heights:
4427
+ estimate = sum(float(h) for h in heights) / max(1, len(heights))
4428
+ else:
4429
+ estimate = float(props.get("row_height", 44.0) or 44.0)
4430
+ tv.setEstimatedRowHeight_(max(1.0, estimate))
4199
4431
  except Exception:
4200
4432
  pass
4201
4433
  tv.retain()
@@ -4205,14 +4437,15 @@ class VirtualListHandler(IOSViewHandler):
4205
4437
  if source_ptr == 0:
4206
4438
  raise RuntimeError("[VirtualList][iOS] dataSource alloc returned NULL")
4207
4439
 
4208
- _pn_table_state[source_ptr] = {
4440
+ state = {
4209
4441
  "count": int(props.get("count", 0) or 0),
4210
4442
  "row_height": float(props.get("row_height", 44.0) or 44.0),
4211
- "row_heights": props.get("row_heights"),
4212
4443
  "render_row": props.get("render_row"),
4213
4444
  "pool": RowHostPool(),
4214
4445
  "tv": tv,
4215
4446
  }
4447
+ _table_set_heights(state, props.get("row_heights"))
4448
+ _pn_table_state[source_ptr] = state
4216
4449
 
4217
4450
  _objc_msgSend.restype = None
4218
4451
  _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
@@ -4220,7 +4453,6 @@ class VirtualListHandler(IOSViewHandler):
4220
4453
  _objc_msgSend(tv_ptr, _SEL_SET_DATA_SOURCE, _ct.c_void_p(source_ptr))
4221
4454
  _objc_msgSend(tv_ptr, _SEL_SET_DELEGATE, _ct.c_void_p(source_ptr))
4222
4455
 
4223
- state = _pn_table_state[source_ptr]
4224
4456
  state["source_ptr"] = source_ptr
4225
4457
  return tv
4226
4458
 
@@ -4245,7 +4477,7 @@ class VirtualListHandler(IOSViewHandler):
4245
4477
  info["row_height"] = float(props["row_height"])
4246
4478
  data_changed = True
4247
4479
  if "row_heights" in props:
4248
- info["row_heights"] = props.get("row_heights")
4480
+ _table_set_heights(info, props.get("row_heights"))
4249
4481
  data_changed = True
4250
4482
  if "render_row" in props:
4251
4483
  info["render_row"] = props.get("render_row")
@@ -4350,6 +4582,7 @@ def register_handlers(registry: Any) -> None:
4350
4582
  registry.register("ScrollView", ScrollViewHandler())
4351
4583
  registry.register("SafeAreaView", SafeAreaViewHandler())
4352
4584
  registry.register("Modal", ModalHandler())
4585
+ registry.register("Portal", PortalHandler())
4353
4586
  registry.register("Slider", SliderHandler())
4354
4587
  registry.register("TabBar", TabBarHandler())
4355
4588
  registry.register("Pressable", PressableHandler())
@@ -4377,6 +4610,7 @@ __all__ = [
4377
4610
  "SpacerHandler",
4378
4611
  "SafeAreaViewHandler",
4379
4612
  "ModalHandler",
4613
+ "PortalHandler",
4380
4614
  "SliderHandler",
4381
4615
  "TabBarHandler",
4382
4616
  "PressableHandler",
@@ -45,11 +45,12 @@ Example:
45
45
  ```
46
46
  """
47
47
 
48
- from typing import Any, Callable, Dict, List, Optional
48
+ from typing import Any, Callable, Dict, List, Optional, TypedDict, Union
49
49
 
50
50
  from .element import Element
51
51
  from .hooks import (
52
52
  Provider,
53
+ Ref,
53
54
  _NavigationContext,
54
55
  component,
55
56
  create_context,
@@ -60,6 +61,29 @@ from .hooks import (
60
61
  use_state,
61
62
  )
62
63
 
64
+
65
+ class ScreenOptions(TypedDict, total=False):
66
+ """Typed per-screen options accepted by ``Screen(..., options=...)``.
67
+
68
+ All keys are optional. Navigators ignore keys they don't use.
69
+
70
+ Attributes:
71
+ title: Human-readable screen title. Stack navigators forward it
72
+ to the host's native navigation bar; tab and drawer
73
+ navigators use it as the item label.
74
+ tab_bar_icon: Native system icon identifier for tab items. A
75
+ string is used on every platform; a dict like
76
+ ``{"ios": "house.fill", "android": "ic_menu_home"}``
77
+ selects per platform. iOS values are resolved via
78
+ SF Symbols (``UIImage.systemImageNamed_``); Android values
79
+ are resolved against ``android.R.drawable.<name>``. Names
80
+ that don't resolve fall back to text-only.
81
+ """
82
+
83
+ title: str
84
+ tab_bar_icon: Union[str, Dict[str, str]]
85
+
86
+
63
87
  # ======================================================================
64
88
  # Focus context
65
89
  # ======================================================================
@@ -85,14 +109,14 @@ class _ScreenDef:
85
109
  name: Route name used by `navigate(name, ...)`.
86
110
  component: A `@component` function rendered when this screen is
87
111
  active.
88
- options: Free-form per-screen options (e.g., `title` for the
89
- tab bar, `headerShown`, etc.). Interpretation is up to the
90
- specific navigator implementation.
112
+ options: Per-screen [`ScreenOptions`][pythonnative.ScreenOptions]
113
+ (e.g., `title`, `tab_bar_icon`). Interpretation is up to
114
+ the specific navigator implementation.
91
115
  """
92
116
 
93
117
  __slots__ = ("name", "component", "options")
94
118
 
95
- def __init__(self, name: str, component_fn: Any, options: Optional[Dict[str, Any]] = None) -> None:
119
+ def __init__(self, name: str, component_fn: Any, options: Optional[ScreenOptions] = None) -> None:
96
120
  self.name = name
97
121
  self.component = component_fn
98
122
  self.options = options or {}
@@ -450,10 +474,10 @@ def _stack_navigator_impl(screens: Any = None, initial_route: Optional[str] = No
450
474
  stack, set_stack = use_state(lambda: [_RouteEntry(first_route, first_params)])
451
475
 
452
476
  stack_ref = use_ref(None)
453
- stack_ref["current"] = stack
477
+ stack_ref.current = stack
454
478
 
455
479
  handle = use_memo(
456
- lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref["current"], set_stack, parent=parent_nav), []
480
+ lambda: _DeclarativeNavHandle(screen_map, lambda: stack_ref.current, set_stack, parent=parent_nav), []
457
481
  )
458
482
  handle._screen_map = screen_map
459
483
  handle._parent = parent_nav
@@ -505,7 +529,7 @@ def create_stack_navigator() -> Any:
505
529
 
506
530
  class _StackNavigator:
507
531
  @staticmethod
508
- def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef":
532
+ def Screen(name: str, *, component: Any, options: Optional[ScreenOptions] = None) -> "_ScreenDef":
509
533
  """Define a screen within this stack navigator.
510
534
 
511
535
  Args:
@@ -556,8 +580,8 @@ def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None
556
580
  active_tab, set_active_tab = use_state(first_route)
557
581
  tab_params, set_tab_params = use_state(lambda: {first_route: {}})
558
582
 
559
- params_ref = use_ref(None)
560
- params_ref["current"] = tab_params
583
+ params_ref: Ref[Dict[str, Dict[str, Any]]] = use_ref(None)
584
+ params_ref.current = tab_params
561
585
 
562
586
  def switch_tab(name: str, params: Optional[Dict[str, Any]] = None) -> None:
563
587
  set_active_tab(name)
@@ -565,7 +589,7 @@ def _tab_navigator_impl(screens: Any = None, initial_route: Optional[str] = None
565
589
  set_tab_params(lambda p: {**p, name: params})
566
590
 
567
591
  def get_stack() -> List[_RouteEntry]:
568
- p = params_ref["current"] or {}
592
+ p = params_ref.current or {}
569
593
  return [_RouteEntry(active_tab, p.get(active_tab, {}))]
570
594
 
571
595
  handle = use_memo(lambda: _TabNavHandle(screen_map, get_stack, lambda _: None, switch_tab, parent=parent_nav), [])
@@ -643,7 +667,7 @@ def create_tab_navigator() -> Any:
643
667
 
644
668
  class _TabNavigator:
645
669
  @staticmethod
646
- def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef":
670
+ def Screen(name: str, *, component: Any, options: Optional[ScreenOptions] = None) -> "_ScreenDef":
647
671
  """Define a screen within this tab navigator.
648
672
 
649
673
  Args:
@@ -704,8 +728,8 @@ def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = N
704
728
  drawer_open, set_drawer_open = use_state(False)
705
729
  screen_params, set_screen_params = use_state(lambda: {first_route: {}})
706
730
 
707
- params_ref = use_ref(None)
708
- params_ref["current"] = screen_params
731
+ params_ref: Ref[Dict[str, Dict[str, Any]]] = use_ref(None)
732
+ params_ref.current = screen_params
709
733
 
710
734
  def switch_screen(name: str, params: Optional[Dict[str, Any]] = None) -> None:
711
735
  set_active_screen(name)
@@ -713,7 +737,7 @@ def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = N
713
737
  set_screen_params(lambda p: {**p, name: params})
714
738
 
715
739
  def get_stack() -> List[_RouteEntry]:
716
- p = params_ref["current"] or {}
740
+ p = params_ref.current or {}
717
741
  return [_RouteEntry(active_screen, p.get(active_screen, {}))]
718
742
 
719
743
  handle = use_memo(
@@ -763,7 +787,7 @@ def _drawer_navigator_impl(screens: Any = None, initial_route: Optional[str] = N
763
787
  return _select
764
788
 
765
789
  menu_items.append(
766
- Element("Button", {"title": label, "on_click": make_select(item_name)}, [], key=f"__drawer_{item_name}")
790
+ Element("Button", {"title": label, "on_press": make_select(item_name)}, [], key=f"__drawer_{item_name}")
767
791
  )
768
792
 
769
793
  drawer_panel = Element(
@@ -813,7 +837,7 @@ def create_drawer_navigator() -> Any:
813
837
 
814
838
  class _DrawerNavigator:
815
839
  @staticmethod
816
- def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = None) -> "_ScreenDef":
840
+ def Screen(name: str, *, component: Any, options: Optional[ScreenOptions] = None) -> "_ScreenDef":
817
841
  """Define a screen within this drawer navigator.
818
842
 
819
843
  Args: