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.
- pythonnative/__init__.py +22 -1
- pythonnative/_ios_log.py +94 -0
- pythonnative/cli/pn.py +131 -11
- pythonnative/components.py +78 -21
- pythonnative/hooks.py +135 -29
- pythonnative/hot_reload.py +2 -2
- pythonnative/native_views/__init__.py +87 -0
- pythonnative/native_views/android.py +832 -0
- pythonnative/native_views/base.py +150 -0
- pythonnative/native_views/ios.py +777 -0
- pythonnative/navigation.py +571 -0
- pythonnative/page.py +77 -17
- pythonnative/reconciler.py +89 -1
- pythonnative/templates/ios_template/ios_template/ViewController.swift +19 -25
- pythonnative/utils.py +40 -1
- {pythonnative-0.7.0.dist-info → pythonnative-0.9.0.dist-info}/METADATA +1 -1
- {pythonnative-0.7.0.dist-info → pythonnative-0.9.0.dist-info}/RECORD +21 -16
- pythonnative/native_views.py +0 -1404
- {pythonnative-0.7.0.dist-info → pythonnative-0.9.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.7.0.dist-info → pythonnative-0.9.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.7.0.dist-info → pythonnative-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.7.0.dist-info → pythonnative-0.9.0.dist-info}/top_level.txt +0 -0
|
@@ -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())
|