pythonnative 0.7.0__py3-none-any.whl → 0.9.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.
@@ -0,0 +1,777 @@
1
+ """iOS native view handlers (rubicon-objc).
2
+
3
+ Each handler class maps a PythonNative element type to a UIKit widget,
4
+ implementing view creation, property updates, and child management.
5
+
6
+ This module is only imported on iOS at runtime; desktop tests inject
7
+ a mock registry via :func:`~.set_registry` and never trigger this import.
8
+ """
9
+
10
+ import ctypes as _ct
11
+ from typing import Any, Callable, Dict, Optional
12
+
13
+ from rubicon.objc import SEL, ObjCClass, objc_method
14
+
15
+ from .base import CONTAINER_KEYS, LAYOUT_KEYS, ViewHandler, is_vertical, parse_color_int, resolve_padding
16
+
17
+ NSObject = ObjCClass("NSObject")
18
+ UIColor = ObjCClass("UIColor")
19
+ UIFont = ObjCClass("UIFont")
20
+
21
+
22
+ # ======================================================================
23
+ # Shared helpers
24
+ # ======================================================================
25
+
26
+
27
+ def _uicolor(color: Any) -> Any:
28
+ """Convert a color value to a ``UIColor`` instance."""
29
+ argb = parse_color_int(color)
30
+ if argb < 0:
31
+ argb += 0x100000000
32
+ a = ((argb >> 24) & 0xFF) / 255.0
33
+ r = ((argb >> 16) & 0xFF) / 255.0
34
+ g = ((argb >> 8) & 0xFF) / 255.0
35
+ b = (argb & 0xFF) / 255.0
36
+ return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a)
37
+
38
+
39
+ def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None:
40
+ """Apply common layout constraints to an iOS view."""
41
+ if "width" in props and props["width"] is not None:
42
+ try:
43
+ for c in list(view.constraints or []):
44
+ if c.firstAttribute == 7: # NSLayoutAttributeWidth
45
+ c.setActive_(False)
46
+ view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True)
47
+ except Exception:
48
+ pass
49
+ if "height" in props and props["height"] is not None:
50
+ try:
51
+ for c in list(view.constraints or []):
52
+ if c.firstAttribute == 8: # NSLayoutAttributeHeight
53
+ c.setActive_(False)
54
+ view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True)
55
+ except Exception:
56
+ pass
57
+ if "min_width" in props and props["min_width"] is not None:
58
+ try:
59
+ view.widthAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_width"])).setActive_(True)
60
+ except Exception:
61
+ pass
62
+ if "min_height" in props and props["min_height"] is not None:
63
+ try:
64
+ view.heightAnchor.constraintGreaterThanOrEqualToConstant_(float(props["min_height"])).setActive_(True)
65
+ except Exception:
66
+ pass
67
+
68
+
69
+ def _apply_common_visual(view: Any, props: Dict[str, Any]) -> None:
70
+ """Apply visual properties shared across many handlers."""
71
+ if "background_color" in props and props["background_color"] is not None:
72
+ view.setBackgroundColor_(_uicolor(props["background_color"]))
73
+ if "overflow" in props:
74
+ view.setClipsToBounds_(props["overflow"] == "hidden")
75
+
76
+
77
+ def _apply_flex_container(sv: Any, props: Dict[str, Any]) -> None:
78
+ """Apply flex container properties to a UIStackView.
79
+
80
+ Handles axis, spacing, alignment, distribution, background, padding, and overflow.
81
+ """
82
+ if "flex_direction" in props:
83
+ vertical = is_vertical(props["flex_direction"])
84
+ sv.setAxis_(1 if vertical else 0)
85
+
86
+ if "spacing" in props and props["spacing"]:
87
+ sv.setSpacing_(float(props["spacing"]))
88
+
89
+ ai = props.get("align_items") or props.get("alignment")
90
+ if ai:
91
+ direction = props.get("flex_direction")
92
+ vertical = is_vertical(direction) if direction else bool(sv.axis())
93
+ if vertical:
94
+ alignment_map = {
95
+ "stretch": 0,
96
+ "fill": 0,
97
+ "flex_start": 1,
98
+ "leading": 1,
99
+ "center": 3,
100
+ "flex_end": 4,
101
+ "trailing": 4,
102
+ }
103
+ else:
104
+ alignment_map = {
105
+ "stretch": 0,
106
+ "fill": 0,
107
+ "flex_start": 1,
108
+ "top": 1,
109
+ "center": 3,
110
+ "flex_end": 4,
111
+ "bottom": 4,
112
+ }
113
+ sv.setAlignment_(alignment_map.get(ai, 0))
114
+
115
+ jc = props.get("justify_content")
116
+ if jc:
117
+ # UIStackViewDistribution:
118
+ # 0 = fill, 1 = fillEqually, 2 = fillProportionally,
119
+ # 3 = equalSpacing (≈ space_between), 4 = equalCentering (≈ space_evenly)
120
+ distribution_map = {
121
+ "flex_start": 0,
122
+ "center": 0,
123
+ "flex_end": 0,
124
+ "space_between": 3,
125
+ "space_around": 4,
126
+ "space_evenly": 4,
127
+ }
128
+ sv.setDistribution_(distribution_map.get(jc, 0))
129
+
130
+ _apply_common_visual(sv, props)
131
+
132
+ if "padding" in props:
133
+ left, top, right, bottom = resolve_padding(props["padding"])
134
+ sv.setLayoutMarginsRelativeArrangement_(True)
135
+ try:
136
+ sv.setDirectionalLayoutMargins_((top, left, bottom, right))
137
+ except Exception:
138
+ sv.setLayoutMargins_((top, left, bottom, right))
139
+
140
+
141
+ # ======================================================================
142
+ # ObjC callback targets (retained at module level)
143
+ # ======================================================================
144
+
145
+ _pn_btn_handler_map: dict = {}
146
+ _pn_retained_views: list = []
147
+
148
+
149
+ class _PNButtonTarget(NSObject): # type: ignore[valid-type]
150
+ _callback: Optional[Callable[[], None]] = None
151
+
152
+ @objc_method
153
+ def onTap_(self, sender: object) -> None:
154
+ if self._callback is not None:
155
+ self._callback()
156
+
157
+
158
+ _pn_tf_handler_map: dict = {}
159
+
160
+
161
+ class _PNTextFieldTarget(NSObject): # type: ignore[valid-type]
162
+ _callback: Optional[Callable[[str], None]] = None
163
+
164
+ @objc_method
165
+ def onEdit_(self, sender: object) -> None:
166
+ if self._callback is not None:
167
+ try:
168
+ text = str(sender.text) if sender and hasattr(sender, "text") else ""
169
+ self._callback(text)
170
+ except Exception:
171
+ pass
172
+
173
+
174
+ _pn_switch_handler_map: dict = {}
175
+
176
+
177
+ class _PNSwitchTarget(NSObject): # type: ignore[valid-type]
178
+ _callback: Optional[Callable[[bool], None]] = None
179
+
180
+ @objc_method
181
+ def onToggle_(self, sender: object) -> None:
182
+ if self._callback is not None:
183
+ try:
184
+ self._callback(bool(sender.isOn()))
185
+ except Exception:
186
+ pass
187
+
188
+
189
+ _pn_slider_handler_map: dict = {}
190
+
191
+
192
+ class _PNSliderTarget(NSObject): # type: ignore[valid-type]
193
+ _callback: Optional[Callable[[float], None]] = None
194
+
195
+ @objc_method
196
+ def onSlide_(self, sender: object) -> None:
197
+ if self._callback is not None:
198
+ try:
199
+ self._callback(float(sender.value))
200
+ except Exception:
201
+ pass
202
+
203
+
204
+ # ======================================================================
205
+ # Flex container handler (shared by Column, Row, View)
206
+ # ======================================================================
207
+
208
+
209
+ class FlexContainerHandler(ViewHandler):
210
+ """Unified handler for flex layout containers (Column, Row, View).
211
+
212
+ All three element types use ``UIStackView`` with axis determined
213
+ by the ``flex_direction`` prop.
214
+ """
215
+
216
+ def create(self, props: Dict[str, Any]) -> Any:
217
+ sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0)))
218
+ direction = props.get("flex_direction", "column")
219
+ sv.setAxis_(1 if is_vertical(direction) else 0)
220
+ _apply_flex_container(sv, props)
221
+ _apply_ios_layout(sv, props)
222
+ return sv
223
+
224
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
225
+ if changed.keys() & CONTAINER_KEYS:
226
+ _apply_flex_container(native_view, changed)
227
+ if changed.keys() & LAYOUT_KEYS:
228
+ _apply_ios_layout(native_view, changed)
229
+
230
+ def add_child(self, parent: Any, child: Any) -> None:
231
+ parent.addArrangedSubview_(child)
232
+
233
+ def remove_child(self, parent: Any, child: Any) -> None:
234
+ parent.removeArrangedSubview_(child)
235
+ child.removeFromSuperview()
236
+
237
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
238
+ parent.insertArrangedSubview_atIndex_(child, index)
239
+
240
+
241
+ # ======================================================================
242
+ # Leaf handlers
243
+ # ======================================================================
244
+
245
+
246
+ class TextHandler(ViewHandler):
247
+ def create(self, props: Dict[str, Any]) -> Any:
248
+ label = ObjCClass("UILabel").alloc().init()
249
+ self._apply(label, props)
250
+ _apply_ios_layout(label, props)
251
+ return label
252
+
253
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
254
+ self._apply(native_view, changed)
255
+ if changed.keys() & LAYOUT_KEYS:
256
+ _apply_ios_layout(native_view, changed)
257
+
258
+ def _apply(self, label: Any, props: Dict[str, Any]) -> None:
259
+ if "text" in props:
260
+ label.setText_(str(props["text"]))
261
+ if "font_size" in props and props["font_size"] is not None:
262
+ if props.get("bold"):
263
+ label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"])))
264
+ else:
265
+ label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
266
+ elif "bold" in props and props["bold"]:
267
+ size = label.font().pointSize() if label.font() else 17.0
268
+ label.setFont_(UIFont.boldSystemFontOfSize_(size))
269
+ if "color" in props and props["color"] is not None:
270
+ label.setTextColor_(_uicolor(props["color"]))
271
+ if "background_color" in props and props["background_color"] is not None:
272
+ label.setBackgroundColor_(_uicolor(props["background_color"]))
273
+ if "max_lines" in props and props["max_lines"] is not None:
274
+ label.setNumberOfLines_(int(props["max_lines"]))
275
+ if "text_align" in props:
276
+ mapping = {"left": 0, "center": 1, "right": 2}
277
+ label.setTextAlignment_(mapping.get(props["text_align"], 0))
278
+
279
+
280
+ class ButtonHandler(ViewHandler):
281
+ def create(self, props: Dict[str, Any]) -> Any:
282
+ btn = ObjCClass("UIButton").alloc().init()
283
+ btn.retain()
284
+ _pn_retained_views.append(btn)
285
+ _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0)
286
+ btn.setTitleColor_forState_(_ios_blue, 0)
287
+ self._apply(btn, props)
288
+ _apply_ios_layout(btn, props)
289
+ return btn
290
+
291
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
292
+ self._apply(native_view, changed)
293
+ if changed.keys() & LAYOUT_KEYS:
294
+ _apply_ios_layout(native_view, changed)
295
+
296
+ def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
297
+ if "title" in props:
298
+ btn.setTitle_forState_(str(props["title"]), 0)
299
+ if "font_size" in props and props["font_size"] is not None:
300
+ btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
301
+ if "background_color" in props and props["background_color"] is not None:
302
+ btn.setBackgroundColor_(_uicolor(props["background_color"]))
303
+ if "color" not in props:
304
+ _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0)
305
+ btn.setTitleColor_forState_(_white, 0)
306
+ if "color" in props and props["color"] is not None:
307
+ btn.setTitleColor_forState_(_uicolor(props["color"]), 0)
308
+ if "enabled" in props:
309
+ btn.setEnabled_(bool(props["enabled"]))
310
+ if "on_click" in props:
311
+ existing = _pn_btn_handler_map.get(id(btn))
312
+ if existing is not None:
313
+ existing._callback = props["on_click"]
314
+ else:
315
+ handler = _PNButtonTarget.new()
316
+ handler._callback = props["on_click"]
317
+ _pn_btn_handler_map[id(btn)] = handler
318
+ btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6)
319
+
320
+
321
+ class ScrollViewHandler(ViewHandler):
322
+ def create(self, props: Dict[str, Any]) -> Any:
323
+ sv = ObjCClass("UIScrollView").alloc().init()
324
+ if "background_color" in props and props["background_color"] is not None:
325
+ sv.setBackgroundColor_(_uicolor(props["background_color"]))
326
+ _apply_ios_layout(sv, props)
327
+ return sv
328
+
329
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
330
+ if "background_color" in changed and changed["background_color"] is not None:
331
+ native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
332
+
333
+ def add_child(self, parent: Any, child: Any) -> None:
334
+ child.setTranslatesAutoresizingMaskIntoConstraints_(False)
335
+ parent.addSubview_(child)
336
+ content_guide = parent.contentLayoutGuide
337
+ frame_guide = parent.frameLayoutGuide
338
+ child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True)
339
+ child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True)
340
+ child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True)
341
+ child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True)
342
+ child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True)
343
+
344
+ def remove_child(self, parent: Any, child: Any) -> None:
345
+ child.removeFromSuperview()
346
+
347
+
348
+ class TextInputHandler(ViewHandler):
349
+ def create(self, props: Dict[str, Any]) -> Any:
350
+ tf = ObjCClass("UITextField").alloc().init()
351
+ tf.setBorderStyle_(2) # RoundedRect
352
+ self._apply(tf, props)
353
+ _apply_ios_layout(tf, props)
354
+ return tf
355
+
356
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
357
+ self._apply(native_view, changed)
358
+ if changed.keys() & LAYOUT_KEYS:
359
+ _apply_ios_layout(native_view, changed)
360
+
361
+ def _apply(self, tf: Any, props: Dict[str, Any]) -> None:
362
+ if "value" in props:
363
+ tf.setText_(str(props["value"]))
364
+ if "placeholder" in props:
365
+ tf.setPlaceholder_(str(props["placeholder"]))
366
+ if "font_size" in props and props["font_size"] is not None:
367
+ tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
368
+ if "color" in props and props["color"] is not None:
369
+ tf.setTextColor_(_uicolor(props["color"]))
370
+ if "background_color" in props and props["background_color"] is not None:
371
+ tf.setBackgroundColor_(_uicolor(props["background_color"]))
372
+ if "secure" in props and props["secure"]:
373
+ tf.setSecureTextEntry_(True)
374
+ if "on_change" in props:
375
+ existing = _pn_tf_handler_map.get(id(tf))
376
+ if existing is not None:
377
+ existing._callback = props["on_change"]
378
+ else:
379
+ handler = _PNTextFieldTarget.new()
380
+ handler._callback = props["on_change"]
381
+ _pn_tf_handler_map[id(tf)] = handler
382
+ tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17)
383
+
384
+
385
+ class ImageHandler(ViewHandler):
386
+ def create(self, props: Dict[str, Any]) -> Any:
387
+ iv = ObjCClass("UIImageView").alloc().init()
388
+ self._apply(iv, props)
389
+ _apply_ios_layout(iv, props)
390
+ return iv
391
+
392
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
393
+ self._apply(native_view, changed)
394
+ if changed.keys() & LAYOUT_KEYS:
395
+ _apply_ios_layout(native_view, changed)
396
+
397
+ def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
398
+ if "background_color" in props and props["background_color"] is not None:
399
+ iv.setBackgroundColor_(_uicolor(props["background_color"]))
400
+ if "source" in props and props["source"]:
401
+ self._load_source(iv, props["source"])
402
+ if "scale_type" in props and props["scale_type"]:
403
+ mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4}
404
+ iv.setContentMode_(mapping.get(props["scale_type"], 1))
405
+
406
+ def _load_source(self, iv: Any, source: str) -> None:
407
+ try:
408
+ if source.startswith(("http://", "https://")):
409
+ NSURL = ObjCClass("NSURL")
410
+ NSData = ObjCClass("NSData")
411
+ UIImage = ObjCClass("UIImage")
412
+ url = NSURL.URLWithString_(source)
413
+ data = NSData.dataWithContentsOfURL_(url)
414
+ if data:
415
+ image = UIImage.imageWithData_(data)
416
+ if image:
417
+ iv.setImage_(image)
418
+ else:
419
+ UIImage = ObjCClass("UIImage")
420
+ image = UIImage.imageNamed_(source)
421
+ if image:
422
+ iv.setImage_(image)
423
+ except Exception:
424
+ pass
425
+
426
+
427
+ class SwitchHandler(ViewHandler):
428
+ def create(self, props: Dict[str, Any]) -> Any:
429
+ sw = ObjCClass("UISwitch").alloc().init()
430
+ self._apply(sw, props)
431
+ return sw
432
+
433
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
434
+ self._apply(native_view, changed)
435
+
436
+ def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
437
+ if "value" in props:
438
+ sw.setOn_animated_(bool(props["value"]), False)
439
+ if "on_change" in props:
440
+ existing = _pn_switch_handler_map.get(id(sw))
441
+ if existing is not None:
442
+ existing._callback = props["on_change"]
443
+ else:
444
+ handler = _PNSwitchTarget.new()
445
+ handler._callback = props["on_change"]
446
+ _pn_switch_handler_map[id(sw)] = handler
447
+ sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12)
448
+
449
+
450
+ class ProgressBarHandler(ViewHandler):
451
+ def create(self, props: Dict[str, Any]) -> Any:
452
+ pv = ObjCClass("UIProgressView").alloc().init()
453
+ if "value" in props:
454
+ pv.setProgress_(float(props["value"]))
455
+ _apply_ios_layout(pv, props)
456
+ return pv
457
+
458
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
459
+ if "value" in changed:
460
+ native_view.setProgress_(float(changed["value"]))
461
+
462
+
463
+ class ActivityIndicatorHandler(ViewHandler):
464
+ def create(self, props: Dict[str, Any]) -> Any:
465
+ ai = ObjCClass("UIActivityIndicatorView").alloc().init()
466
+ if props.get("animating", True):
467
+ ai.startAnimating()
468
+ return ai
469
+
470
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
471
+ if "animating" in changed:
472
+ if changed["animating"]:
473
+ native_view.startAnimating()
474
+ else:
475
+ native_view.stopAnimating()
476
+
477
+
478
+ class WebViewHandler(ViewHandler):
479
+ def create(self, props: Dict[str, Any]) -> Any:
480
+ wv = ObjCClass("WKWebView").alloc().init()
481
+ if "url" in props and props["url"]:
482
+ NSURL = ObjCClass("NSURL")
483
+ NSURLRequest = ObjCClass("NSURLRequest")
484
+ url_obj = NSURL.URLWithString_(str(props["url"]))
485
+ wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
486
+ _apply_ios_layout(wv, props)
487
+ return wv
488
+
489
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
490
+ if "url" in changed and changed["url"]:
491
+ NSURL = ObjCClass("NSURL")
492
+ NSURLRequest = ObjCClass("NSURLRequest")
493
+ url_obj = NSURL.URLWithString_(str(changed["url"]))
494
+ native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
495
+
496
+
497
+ class SpacerHandler(ViewHandler):
498
+ def create(self, props: Dict[str, Any]) -> Any:
499
+ v = ObjCClass("UIView").alloc().init()
500
+ if "size" in props and props["size"] is not None:
501
+ size = float(props["size"])
502
+ v.setFrame_(((0, 0), (size, size)))
503
+ return v
504
+
505
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
506
+ if "size" in changed and changed["size"] is not None:
507
+ size = float(changed["size"])
508
+ native_view.setFrame_(((0, 0), (size, size)))
509
+
510
+
511
+ class SafeAreaViewHandler(ViewHandler):
512
+ def create(self, props: Dict[str, Any]) -> Any:
513
+ v = ObjCClass("UIView").alloc().init()
514
+ if "background_color" in props and props["background_color"] is not None:
515
+ v.setBackgroundColor_(_uicolor(props["background_color"]))
516
+ return v
517
+
518
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
519
+ if "background_color" in changed and changed["background_color"] is not None:
520
+ native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
521
+
522
+ def add_child(self, parent: Any, child: Any) -> None:
523
+ parent.addSubview_(child)
524
+
525
+ def remove_child(self, parent: Any, child: Any) -> None:
526
+ child.removeFromSuperview()
527
+
528
+
529
+ class ModalHandler(ViewHandler):
530
+ def create(self, props: Dict[str, Any]) -> Any:
531
+ v = ObjCClass("UIView").alloc().init()
532
+ v.setHidden_(True)
533
+ return v
534
+
535
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
536
+ pass
537
+
538
+
539
+ class SliderHandler(ViewHandler):
540
+ def create(self, props: Dict[str, Any]) -> Any:
541
+ sl = ObjCClass("UISlider").alloc().init()
542
+ self._apply(sl, props)
543
+ _apply_ios_layout(sl, props)
544
+ return sl
545
+
546
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
547
+ self._apply(native_view, changed)
548
+
549
+ def _apply(self, sl: Any, props: Dict[str, Any]) -> None:
550
+ if "min_value" in props:
551
+ sl.setMinimumValue_(float(props["min_value"]))
552
+ if "max_value" in props:
553
+ sl.setMaximumValue_(float(props["max_value"]))
554
+ if "value" in props:
555
+ sl.setValue_(float(props["value"]))
556
+ if "on_change" in props:
557
+ existing = _pn_slider_handler_map.get(id(sl))
558
+ if existing is not None:
559
+ existing._callback = props["on_change"]
560
+ else:
561
+ handler = _PNSliderTarget.new()
562
+ handler._callback = props["on_change"]
563
+ _pn_slider_handler_map[id(sl)] = handler
564
+ sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12)
565
+
566
+
567
+ _pn_tabbar_state: dict = {"callback": None, "items": []}
568
+ _pn_tabbar_delegate_installed: bool = False
569
+ _pn_tabbar_delegate_ptr: Any = None
570
+
571
+ # ---------------------------------------------------------------------------
572
+ # UITabBar delegate via raw ctypes
573
+ #
574
+ # rubicon-objc's @objc_method crashes (SIGSEGV in PyObject_GetAttr) when
575
+ # UIKit invokes the delegate through the FFI closure — the reconstructed
576
+ # Python wrappers for ``self`` or ``item`` end up with ob_type == NULL.
577
+ #
578
+ # We sidestep rubicon-objc entirely: create a minimal ObjC class with
579
+ # libobjc, register a CFUNCTYPE IMP for tabBar:didSelectItem:, and use
580
+ # objc_msgSend to read ``item.tag`` from the raw pointer.
581
+ # ---------------------------------------------------------------------------
582
+
583
+ _libobjc = _ct.cdll.LoadLibrary("libobjc.A.dylib")
584
+
585
+ _sel_reg = _libobjc.sel_registerName
586
+ _sel_reg.restype = _ct.c_void_p
587
+ _sel_reg.argtypes = [_ct.c_char_p]
588
+
589
+ _get_cls = _libobjc.objc_getClass
590
+ _get_cls.restype = _ct.c_void_p
591
+ _get_cls.argtypes = [_ct.c_char_p]
592
+
593
+ _alloc_cls = _libobjc.objc_allocateClassPair
594
+ _alloc_cls.restype = _ct.c_void_p
595
+ _alloc_cls.argtypes = [_ct.c_void_p, _ct.c_char_p, _ct.c_size_t]
596
+
597
+ _reg_cls = _libobjc.objc_registerClassPair
598
+ _reg_cls.argtypes = [_ct.c_void_p]
599
+
600
+ _add_method = _libobjc.class_addMethod
601
+ _add_method.restype = _ct.c_bool
602
+ _add_method.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_char_p]
603
+
604
+ _objc_msgSend = _libobjc.objc_msgSend
605
+
606
+ # Pre-register selectors used in the raw delegate path
607
+ _SEL_ALLOC = _sel_reg(b"alloc")
608
+ _SEL_INIT = _sel_reg(b"init")
609
+ _SEL_RETAIN = _sel_reg(b"retain")
610
+ _SEL_SET_DELEGATE = _sel_reg(b"setDelegate:")
611
+ _SEL_TAG = _sel_reg(b"tag")
612
+
613
+ # IMP type: void (id self, SEL _cmd, id tabBar, id item)
614
+ _DELEGATE_IMP_TYPE = _ct.CFUNCTYPE(None, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p, _ct.c_void_p)
615
+
616
+
617
+ def _tabbar_did_select_imp(self_ptr: int, cmd_ptr: int, tabbar_ptr: int, item_ptr: int) -> None:
618
+ """Raw C callback for ``tabBar:didSelectItem:``."""
619
+ try:
620
+ _objc_msgSend.restype = _ct.c_long
621
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
622
+ tag: int = _objc_msgSend(item_ptr, _SEL_TAG)
623
+
624
+ cb = _pn_tabbar_state["callback"]
625
+ tab_items = _pn_tabbar_state["items"]
626
+ if cb is not None and tab_items and 0 <= tag < len(tab_items):
627
+ cb(tab_items[tag].get("name", ""))
628
+ except Exception:
629
+ pass
630
+
631
+
632
+ # prevent GC of the C callback
633
+ _tabbar_imp_ref = _DELEGATE_IMP_TYPE(_tabbar_did_select_imp)
634
+
635
+ # Create and register a minimal ObjC class for the delegate
636
+ _NS_OBJECT_CLS = _get_cls(b"NSObject")
637
+ _PN_DELEGATE_CLS = _alloc_cls(_NS_OBJECT_CLS, b"_PNTabBarDelegateCTypes", 0)
638
+ if _PN_DELEGATE_CLS:
639
+ _add_method(
640
+ _PN_DELEGATE_CLS,
641
+ _sel_reg(b"tabBar:didSelectItem:"),
642
+ _ct.cast(_tabbar_imp_ref, _ct.c_void_p),
643
+ b"v@:@@",
644
+ )
645
+ _reg_cls(_PN_DELEGATE_CLS)
646
+
647
+
648
+ def _ensure_tabbar_delegate(tab_bar: Any) -> None:
649
+ """Create the singleton delegate (if needed) and assign it to *tab_bar*."""
650
+ global _pn_tabbar_delegate_ptr
651
+ if _pn_tabbar_delegate_ptr is None and _PN_DELEGATE_CLS:
652
+ _objc_msgSend.restype = _ct.c_void_p
653
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p]
654
+ raw = _objc_msgSend(_PN_DELEGATE_CLS, _SEL_ALLOC)
655
+ raw = _objc_msgSend(raw, _SEL_INIT)
656
+ raw = _objc_msgSend(raw, _SEL_RETAIN)
657
+ _pn_tabbar_delegate_ptr = raw
658
+
659
+ if _pn_tabbar_delegate_ptr is not None:
660
+ _objc_msgSend.restype = None
661
+ _objc_msgSend.argtypes = [_ct.c_void_p, _ct.c_void_p, _ct.c_void_p]
662
+ tab_bar_ptr = tab_bar.ptr if hasattr(tab_bar, "ptr") else tab_bar
663
+ _objc_msgSend(tab_bar_ptr, _SEL_SET_DELEGATE, _pn_tabbar_delegate_ptr)
664
+
665
+
666
+ class TabBarHandler(ViewHandler):
667
+ """Native tab bar using ``UITabBar``.
668
+
669
+ Each tab is a ``UITabBarItem`` with a ``tag`` matching its index
670
+ in the items list. A raw ctypes delegate forwards selection
671
+ events back to the Python ``on_tab_select`` callback.
672
+ """
673
+
674
+ def create(self, props: Dict[str, Any]) -> Any:
675
+ tab_bar = ObjCClass("UITabBar").alloc().initWithFrame_(((0, 0), (0, 49)))
676
+ tab_bar.retain()
677
+ _pn_retained_views.append(tab_bar)
678
+ self._apply_full(tab_bar, props)
679
+ _apply_ios_layout(tab_bar, props)
680
+ return tab_bar
681
+
682
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
683
+ self._apply_partial(native_view, changed)
684
+ if changed.keys() & LAYOUT_KEYS:
685
+ _apply_ios_layout(native_view, changed)
686
+
687
+ def _apply_full(self, tab_bar: Any, props: Dict[str, Any]) -> None:
688
+ items = props.get("items", [])
689
+ self._set_bar_items(tab_bar, items)
690
+ self._set_active(tab_bar, props.get("active_tab"), items)
691
+ self._set_callback(tab_bar, props.get("on_tab_select"), items)
692
+
693
+ def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None:
694
+ prev_items = _pn_tabbar_state["items"]
695
+
696
+ if "items" in changed:
697
+ items = changed["items"]
698
+ self._set_bar_items(tab_bar, items)
699
+ else:
700
+ items = prev_items
701
+
702
+ if "active_tab" in changed:
703
+ self._set_active(tab_bar, changed["active_tab"], items)
704
+
705
+ if "on_tab_select" in changed:
706
+ self._set_callback(tab_bar, changed["on_tab_select"], items)
707
+
708
+ def _set_bar_items(self, tab_bar: Any, items: list) -> None:
709
+ UITabBarItem = ObjCClass("UITabBarItem")
710
+ bar_items = []
711
+ for i, item in enumerate(items):
712
+ title = item.get("title", item.get("name", ""))
713
+ bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), None, i)
714
+ bar_items.append(bar_item)
715
+ tab_bar.setItems_animated_(bar_items, False)
716
+
717
+ def _set_active(self, tab_bar: Any, active: Any, items: list) -> None:
718
+ if not active or not items:
719
+ return
720
+ for i, item in enumerate(items):
721
+ if item.get("name") == active:
722
+ try:
723
+ all_items = list(tab_bar.items or [])
724
+ if i < len(all_items):
725
+ tab_bar.setSelectedItem_(all_items[i])
726
+ except Exception:
727
+ pass
728
+ break
729
+
730
+ def _set_callback(self, tab_bar: Any, cb: Any, items: list) -> None:
731
+ _pn_tabbar_state["callback"] = cb
732
+ _pn_tabbar_state["items"] = items
733
+ _ensure_tabbar_delegate(tab_bar)
734
+
735
+
736
+ class PressableHandler(ViewHandler):
737
+ def create(self, props: Dict[str, Any]) -> Any:
738
+ v = ObjCClass("UIView").alloc().init()
739
+ v.setUserInteractionEnabled_(True)
740
+ return v
741
+
742
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
743
+ pass
744
+
745
+ def add_child(self, parent: Any, child: Any) -> None:
746
+ parent.addSubview_(child)
747
+
748
+ def remove_child(self, parent: Any, child: Any) -> None:
749
+ child.removeFromSuperview()
750
+
751
+
752
+ # ======================================================================
753
+ # Registration
754
+ # ======================================================================
755
+
756
+
757
+ def register_handlers(registry: Any) -> None:
758
+ """Register all iOS view handlers with the given registry."""
759
+ flex = FlexContainerHandler()
760
+ registry.register("Text", TextHandler())
761
+ registry.register("Button", ButtonHandler())
762
+ registry.register("Column", flex)
763
+ registry.register("Row", flex)
764
+ registry.register("View", flex)
765
+ registry.register("ScrollView", ScrollViewHandler())
766
+ registry.register("TextInput", TextInputHandler())
767
+ registry.register("Image", ImageHandler())
768
+ registry.register("Switch", SwitchHandler())
769
+ registry.register("ProgressBar", ProgressBarHandler())
770
+ registry.register("ActivityIndicator", ActivityIndicatorHandler())
771
+ registry.register("WebView", WebViewHandler())
772
+ registry.register("Spacer", SpacerHandler())
773
+ registry.register("SafeAreaView", SafeAreaViewHandler())
774
+ registry.register("Modal", ModalHandler())
775
+ registry.register("Slider", SliderHandler())
776
+ registry.register("TabBar", TabBarHandler())
777
+ registry.register("Pressable", PressableHandler())