pythonnative 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. pythonnative/__init__.py +45 -65
  2. pythonnative/cli/pn.py +16 -10
  3. pythonnative/components.py +241 -0
  4. pythonnative/element.py +47 -0
  5. pythonnative/native_views.py +800 -0
  6. pythonnative/page.py +321 -249
  7. pythonnative/reconciler.py +129 -0
  8. pythonnative/templates/android_template/app/build.gradle +2 -2
  9. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +2 -1
  10. pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
  11. pythonnative/templates/android_template/build.gradle +3 -3
  12. pythonnative/templates/android_template/gradle/wrapper/gradle-wrapper.properties +1 -1
  13. pythonnative/utils.py +21 -29
  14. pythonnative-0.5.0.dist-info/METADATA +161 -0
  15. {pythonnative-0.3.0.dist-info → pythonnative-0.5.0.dist-info}/RECORD +19 -39
  16. {pythonnative-0.3.0.dist-info → pythonnative-0.5.0.dist-info}/WHEEL +1 -1
  17. {pythonnative-0.3.0.dist-info → pythonnative-0.5.0.dist-info}/licenses/LICENSE +1 -1
  18. pythonnative/activity_indicator_view.py +0 -71
  19. pythonnative/button.py +0 -109
  20. pythonnative/date_picker.py +0 -72
  21. pythonnative/image_view.py +0 -76
  22. pythonnative/label.py +0 -66
  23. pythonnative/list_view.py +0 -73
  24. pythonnative/material_activity_indicator_view.py +0 -69
  25. pythonnative/material_button.py +0 -65
  26. pythonnative/material_date_picker.py +0 -85
  27. pythonnative/material_progress_view.py +0 -66
  28. pythonnative/material_search_bar.py +0 -65
  29. pythonnative/material_switch.py +0 -65
  30. pythonnative/material_time_picker.py +0 -72
  31. pythonnative/picker_view.py +0 -65
  32. pythonnative/progress_view.py +0 -68
  33. pythonnative/scroll_view.py +0 -63
  34. pythonnative/search_bar.py +0 -65
  35. pythonnative/stack_view.py +0 -60
  36. pythonnative/switch.py +0 -66
  37. pythonnative/text_field.py +0 -67
  38. pythonnative/text_view.py +0 -70
  39. pythonnative/time_picker.py +0 -73
  40. pythonnative/view.py +0 -25
  41. pythonnative/web_view.py +0 -58
  42. pythonnative-0.3.0.dist-info/METADATA +0 -137
  43. {pythonnative-0.3.0.dist-info → pythonnative-0.5.0.dist-info}/entry_points.txt +0 -0
  44. {pythonnative-0.3.0.dist-info → pythonnative-0.5.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,800 @@
1
+ """Platform-specific native view creation and update logic.
2
+
3
+ This module replaces the old per-widget files. All platform-branching
4
+ lives here, guarded behind lazy imports so the module can be imported
5
+ on desktop for testing.
6
+ """
7
+
8
+ from typing import Any, Callable, Dict, Optional, Union
9
+
10
+ from .utils import IS_ANDROID
11
+
12
+ # ======================================================================
13
+ # Abstract handler protocol
14
+ # ======================================================================
15
+
16
+
17
+ class ViewHandler:
18
+ """Protocol for creating, updating, and managing children of a native view type."""
19
+
20
+ def create(self, props: Dict[str, Any]) -> Any:
21
+ raise NotImplementedError
22
+
23
+ def update(self, native_view: Any, changed_props: Dict[str, Any]) -> None:
24
+ raise NotImplementedError
25
+
26
+ def add_child(self, parent: Any, child: Any) -> None:
27
+ pass
28
+
29
+ def remove_child(self, parent: Any, child: Any) -> None:
30
+ pass
31
+
32
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
33
+ self.add_child(parent, child)
34
+
35
+
36
+ # ======================================================================
37
+ # Registry
38
+ # ======================================================================
39
+
40
+
41
+ class NativeViewRegistry:
42
+ """Maps element type names to platform-specific :class:`ViewHandler` instances."""
43
+
44
+ def __init__(self) -> None:
45
+ self._handlers: Dict[str, ViewHandler] = {}
46
+
47
+ def register(self, type_name: str, handler: ViewHandler) -> None:
48
+ self._handlers[type_name] = handler
49
+
50
+ def create_view(self, type_name: str, props: Dict[str, Any]) -> Any:
51
+ handler = self._handlers.get(type_name)
52
+ if handler is None:
53
+ raise ValueError(f"Unknown element type: {type_name!r}")
54
+ return handler.create(props)
55
+
56
+ def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None:
57
+ handler = self._handlers.get(type_name)
58
+ if handler is not None:
59
+ handler.update(native_view, changed_props)
60
+
61
+ def add_child(self, parent: Any, child: Any, parent_type: str) -> None:
62
+ handler = self._handlers.get(parent_type)
63
+ if handler is not None:
64
+ handler.add_child(parent, child)
65
+
66
+ def remove_child(self, parent: Any, child: Any, parent_type: str) -> None:
67
+ handler = self._handlers.get(parent_type)
68
+ if handler is not None:
69
+ handler.remove_child(parent, child)
70
+
71
+ def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None:
72
+ handler = self._handlers.get(parent_type)
73
+ if handler is not None:
74
+ handler.insert_child(parent, child, index)
75
+
76
+
77
+ # ======================================================================
78
+ # Shared helpers
79
+ # ======================================================================
80
+
81
+
82
+ def parse_color_int(color: Union[str, int]) -> int:
83
+ """Parse ``#RRGGBB`` / ``#AARRGGBB`` hex string or raw int to a *signed* ARGB int.
84
+
85
+ Java's ``setBackgroundColor`` et al. expect a signed 32-bit int, so values
86
+ with a high alpha byte (e.g. 0xFF…) must be converted to negative ints.
87
+ """
88
+ if isinstance(color, int):
89
+ val = color
90
+ else:
91
+ c = color.strip().lstrip("#")
92
+ if len(c) == 6:
93
+ c = "FF" + c
94
+ val = int(c, 16)
95
+ if val > 0x7FFFFFFF:
96
+ val -= 0x100000000
97
+ return val
98
+
99
+
100
+ def _resolve_padding(
101
+ padding: Any,
102
+ ) -> tuple:
103
+ """Normalise various padding representations to ``(left, top, right, bottom)``."""
104
+ if padding is None:
105
+ return (0, 0, 0, 0)
106
+ if isinstance(padding, (int, float)):
107
+ v = int(padding)
108
+ return (v, v, v, v)
109
+ if isinstance(padding, dict):
110
+ h = int(padding.get("horizontal", 0))
111
+ v = int(padding.get("vertical", 0))
112
+ left = int(padding.get("left", h))
113
+ right = int(padding.get("right", h))
114
+ top = int(padding.get("top", v))
115
+ bottom = int(padding.get("bottom", v))
116
+ a = int(padding.get("all", 0))
117
+ if a:
118
+ left = left or a
119
+ right = right or a
120
+ top = top or a
121
+ bottom = bottom or a
122
+ return (left, top, right, bottom)
123
+ return (0, 0, 0, 0)
124
+
125
+
126
+ # ======================================================================
127
+ # Platform handler registration (lazy imports inside functions)
128
+ # ======================================================================
129
+
130
+
131
+ def _register_android_handlers(registry: NativeViewRegistry) -> None: # noqa: C901
132
+ from java import dynamic_proxy, jclass
133
+
134
+ from .utils import get_android_context
135
+
136
+ def _ctx() -> Any:
137
+ return get_android_context()
138
+
139
+ def _density() -> float:
140
+ return float(_ctx().getResources().getDisplayMetrics().density)
141
+
142
+ def _dp(value: float) -> int:
143
+ return int(value * _density())
144
+
145
+ # ---- Text -----------------------------------------------------------
146
+ class AndroidTextHandler(ViewHandler):
147
+ def create(self, props: Dict[str, Any]) -> Any:
148
+ tv = jclass("android.widget.TextView")(_ctx())
149
+ self._apply(tv, props)
150
+ return tv
151
+
152
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
153
+ self._apply(native_view, changed)
154
+
155
+ def _apply(self, tv: Any, props: Dict[str, Any]) -> None:
156
+ if "text" in props:
157
+ tv.setText(str(props["text"]))
158
+ if "font_size" in props and props["font_size"] is not None:
159
+ tv.setTextSize(float(props["font_size"]))
160
+ if "color" in props and props["color"] is not None:
161
+ tv.setTextColor(parse_color_int(props["color"]))
162
+ if "background_color" in props and props["background_color"] is not None:
163
+ tv.setBackgroundColor(parse_color_int(props["background_color"]))
164
+ if "bold" in props and props["bold"]:
165
+ tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1
166
+ if "max_lines" in props and props["max_lines"] is not None:
167
+ tv.setMaxLines(int(props["max_lines"]))
168
+ if "text_align" in props:
169
+ Gravity = jclass("android.view.Gravity")
170
+ mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END}
171
+ tv.setGravity(mapping.get(props["text_align"], Gravity.START))
172
+
173
+ # ---- Button ---------------------------------------------------------
174
+ class AndroidButtonHandler(ViewHandler):
175
+ def create(self, props: Dict[str, Any]) -> Any:
176
+ btn = jclass("android.widget.Button")(_ctx())
177
+ self._apply(btn, props)
178
+ return btn
179
+
180
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
181
+ self._apply(native_view, changed)
182
+
183
+ def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
184
+ if "title" in props:
185
+ btn.setText(str(props["title"]))
186
+ if "font_size" in props and props["font_size"] is not None:
187
+ btn.setTextSize(float(props["font_size"]))
188
+ if "color" in props and props["color"] is not None:
189
+ btn.setTextColor(parse_color_int(props["color"]))
190
+ if "background_color" in props and props["background_color"] is not None:
191
+ btn.setBackgroundColor(parse_color_int(props["background_color"]))
192
+ if "enabled" in props:
193
+ btn.setEnabled(bool(props["enabled"]))
194
+ if "on_click" in props:
195
+ cb = props["on_click"]
196
+ if cb is not None:
197
+
198
+ class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
199
+ def __init__(self, callback: Callable[[], None]) -> None:
200
+ super().__init__()
201
+ self.callback = callback
202
+
203
+ def onClick(self, view: Any) -> None:
204
+ self.callback()
205
+
206
+ btn.setOnClickListener(ClickProxy(cb))
207
+ else:
208
+ btn.setOnClickListener(None)
209
+
210
+ # ---- Column (vertical LinearLayout) ---------------------------------
211
+ class AndroidColumnHandler(ViewHandler):
212
+ def create(self, props: Dict[str, Any]) -> Any:
213
+ ll = jclass("android.widget.LinearLayout")(_ctx())
214
+ ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL)
215
+ self._apply(ll, props)
216
+ return ll
217
+
218
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
219
+ self._apply(native_view, changed)
220
+
221
+ def _apply(self, ll: Any, props: Dict[str, Any]) -> None:
222
+ if "spacing" in props and props["spacing"]:
223
+ px = _dp(float(props["spacing"]))
224
+ GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
225
+ d = GradientDrawable()
226
+ d.setColor(0x00000000)
227
+ d.setSize(1, px)
228
+ ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE)
229
+ ll.setDividerDrawable(d)
230
+ if "padding" in props:
231
+ left, top, right, bottom = _resolve_padding(props["padding"])
232
+ ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
233
+ if "alignment" in props and props["alignment"]:
234
+ Gravity = jclass("android.view.Gravity")
235
+ mapping = {
236
+ "fill": Gravity.FILL_HORIZONTAL,
237
+ "center": Gravity.CENTER_HORIZONTAL,
238
+ "leading": Gravity.START,
239
+ "start": Gravity.START,
240
+ "trailing": Gravity.END,
241
+ "end": Gravity.END,
242
+ }
243
+ ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_HORIZONTAL))
244
+ if "background_color" in props and props["background_color"] is not None:
245
+ ll.setBackgroundColor(parse_color_int(props["background_color"]))
246
+
247
+ def add_child(self, parent: Any, child: Any) -> None:
248
+ parent.addView(child)
249
+
250
+ def remove_child(self, parent: Any, child: Any) -> None:
251
+ parent.removeView(child)
252
+
253
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
254
+ parent.addView(child, index)
255
+
256
+ # ---- Row (horizontal LinearLayout) ----------------------------------
257
+ class AndroidRowHandler(ViewHandler):
258
+ def create(self, props: Dict[str, Any]) -> Any:
259
+ ll = jclass("android.widget.LinearLayout")(_ctx())
260
+ ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL)
261
+ self._apply(ll, props)
262
+ return ll
263
+
264
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
265
+ self._apply(native_view, changed)
266
+
267
+ def _apply(self, ll: Any, props: Dict[str, Any]) -> None:
268
+ if "spacing" in props and props["spacing"]:
269
+ px = _dp(float(props["spacing"]))
270
+ GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
271
+ d = GradientDrawable()
272
+ d.setColor(0x00000000)
273
+ d.setSize(px, 1)
274
+ ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE)
275
+ ll.setDividerDrawable(d)
276
+ if "padding" in props:
277
+ left, top, right, bottom = _resolve_padding(props["padding"])
278
+ ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
279
+ if "alignment" in props and props["alignment"]:
280
+ Gravity = jclass("android.view.Gravity")
281
+ mapping = {
282
+ "fill": Gravity.FILL_VERTICAL,
283
+ "center": Gravity.CENTER_VERTICAL,
284
+ "top": Gravity.TOP,
285
+ "bottom": Gravity.BOTTOM,
286
+ }
287
+ ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_VERTICAL))
288
+ if "background_color" in props and props["background_color"] is not None:
289
+ ll.setBackgroundColor(parse_color_int(props["background_color"]))
290
+
291
+ def add_child(self, parent: Any, child: Any) -> None:
292
+ parent.addView(child)
293
+
294
+ def remove_child(self, parent: Any, child: Any) -> None:
295
+ parent.removeView(child)
296
+
297
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
298
+ parent.addView(child, index)
299
+
300
+ # ---- ScrollView -----------------------------------------------------
301
+ class AndroidScrollViewHandler(ViewHandler):
302
+ def create(self, props: Dict[str, Any]) -> Any:
303
+ sv = jclass("android.widget.ScrollView")(_ctx())
304
+ if "background_color" in props and props["background_color"] is not None:
305
+ sv.setBackgroundColor(parse_color_int(props["background_color"]))
306
+ return sv
307
+
308
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
309
+ if "background_color" in changed and changed["background_color"] is not None:
310
+ native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
311
+
312
+ def add_child(self, parent: Any, child: Any) -> None:
313
+ parent.addView(child)
314
+
315
+ def remove_child(self, parent: Any, child: Any) -> None:
316
+ parent.removeView(child)
317
+
318
+ # ---- TextInput (EditText) -------------------------------------------
319
+ class AndroidTextInputHandler(ViewHandler):
320
+ def create(self, props: Dict[str, Any]) -> Any:
321
+ et = jclass("android.widget.EditText")(_ctx())
322
+ self._apply(et, props)
323
+ return et
324
+
325
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
326
+ self._apply(native_view, changed)
327
+
328
+ def _apply(self, et: Any, props: Dict[str, Any]) -> None:
329
+ if "value" in props:
330
+ et.setText(str(props["value"]))
331
+ if "placeholder" in props:
332
+ et.setHint(str(props["placeholder"]))
333
+ if "font_size" in props and props["font_size"] is not None:
334
+ et.setTextSize(float(props["font_size"]))
335
+ if "color" in props and props["color"] is not None:
336
+ et.setTextColor(parse_color_int(props["color"]))
337
+ if "background_color" in props and props["background_color"] is not None:
338
+ et.setBackgroundColor(parse_color_int(props["background_color"]))
339
+ if "secure" in props and props["secure"]:
340
+ InputType = jclass("android.text.InputType")
341
+ et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD)
342
+
343
+ # ---- Image ----------------------------------------------------------
344
+ class AndroidImageHandler(ViewHandler):
345
+ def create(self, props: Dict[str, Any]) -> Any:
346
+ iv = jclass("android.widget.ImageView")(_ctx())
347
+ self._apply(iv, props)
348
+ return iv
349
+
350
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
351
+ self._apply(native_view, changed)
352
+
353
+ def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
354
+ if "background_color" in props and props["background_color"] is not None:
355
+ iv.setBackgroundColor(parse_color_int(props["background_color"]))
356
+
357
+ # ---- Switch ---------------------------------------------------------
358
+ class AndroidSwitchHandler(ViewHandler):
359
+ def create(self, props: Dict[str, Any]) -> Any:
360
+ sw = jclass("android.widget.Switch")(_ctx())
361
+ self._apply(sw, props)
362
+ return sw
363
+
364
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
365
+ self._apply(native_view, changed)
366
+
367
+ def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
368
+ if "value" in props:
369
+ sw.setChecked(bool(props["value"]))
370
+ if "on_change" in props and props["on_change"] is not None:
371
+ cb = props["on_change"]
372
+
373
+ class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
374
+ def __init__(self, callback: Callable[[bool], None]) -> None:
375
+ super().__init__()
376
+ self.callback = callback
377
+
378
+ def onCheckedChanged(self, button: Any, checked: bool) -> None:
379
+ self.callback(checked)
380
+
381
+ sw.setOnCheckedChangeListener(CheckedProxy(cb))
382
+
383
+ # ---- ProgressBar ----------------------------------------------------
384
+ class AndroidProgressBarHandler(ViewHandler):
385
+ def create(self, props: Dict[str, Any]) -> Any:
386
+ style = jclass("android.R$attr").progressBarStyleHorizontal
387
+ pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
388
+ pb.setMax(1000)
389
+ self._apply(pb, props)
390
+ return pb
391
+
392
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
393
+ self._apply(native_view, changed)
394
+
395
+ def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
396
+ if "value" in props:
397
+ pb.setProgress(int(float(props["value"]) * 1000))
398
+
399
+ # ---- ActivityIndicator (circular ProgressBar) -----------------------
400
+ class AndroidActivityIndicatorHandler(ViewHandler):
401
+ def create(self, props: Dict[str, Any]) -> Any:
402
+ pb = jclass("android.widget.ProgressBar")(_ctx())
403
+ if not props.get("animating", True):
404
+ pb.setVisibility(jclass("android.view.View").GONE)
405
+ return pb
406
+
407
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
408
+ View = jclass("android.view.View")
409
+ if "animating" in changed:
410
+ native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE)
411
+
412
+ # ---- WebView --------------------------------------------------------
413
+ class AndroidWebViewHandler(ViewHandler):
414
+ def create(self, props: Dict[str, Any]) -> Any:
415
+ wv = jclass("android.webkit.WebView")(_ctx())
416
+ if "url" in props and props["url"]:
417
+ wv.loadUrl(str(props["url"]))
418
+ return wv
419
+
420
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
421
+ if "url" in changed and changed["url"]:
422
+ native_view.loadUrl(str(changed["url"]))
423
+
424
+ # ---- Spacer ---------------------------------------------------------
425
+ class AndroidSpacerHandler(ViewHandler):
426
+ def create(self, props: Dict[str, Any]) -> Any:
427
+ v = jclass("android.view.View")(_ctx())
428
+ if "size" in props and props["size"] is not None:
429
+ px = _dp(float(props["size"]))
430
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
431
+ v.setLayoutParams(lp)
432
+ return v
433
+
434
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
435
+ if "size" in changed and changed["size"] is not None:
436
+ px = _dp(float(changed["size"]))
437
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
438
+ native_view.setLayoutParams(lp)
439
+
440
+ registry.register("Text", AndroidTextHandler())
441
+ registry.register("Button", AndroidButtonHandler())
442
+ registry.register("Column", AndroidColumnHandler())
443
+ registry.register("Row", AndroidRowHandler())
444
+ registry.register("ScrollView", AndroidScrollViewHandler())
445
+ registry.register("TextInput", AndroidTextInputHandler())
446
+ registry.register("Image", AndroidImageHandler())
447
+ registry.register("Switch", AndroidSwitchHandler())
448
+ registry.register("ProgressBar", AndroidProgressBarHandler())
449
+ registry.register("ActivityIndicator", AndroidActivityIndicatorHandler())
450
+ registry.register("WebView", AndroidWebViewHandler())
451
+ registry.register("Spacer", AndroidSpacerHandler())
452
+
453
+
454
+ def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901
455
+ from rubicon.objc import SEL, ObjCClass, objc_method
456
+
457
+ NSObject = ObjCClass("NSObject")
458
+ UIColor = ObjCClass("UIColor")
459
+ UIFont = ObjCClass("UIFont")
460
+
461
+ def _uicolor(color: Any) -> Any:
462
+ argb = parse_color_int(color)
463
+ if argb < 0:
464
+ argb += 0x100000000
465
+ a = ((argb >> 24) & 0xFF) / 255.0
466
+ r = ((argb >> 16) & 0xFF) / 255.0
467
+ g = ((argb >> 8) & 0xFF) / 255.0
468
+ b = (argb & 0xFF) / 255.0
469
+ return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a)
470
+
471
+ # ---- Text -----------------------------------------------------------
472
+ class IOSTextHandler(ViewHandler):
473
+ def create(self, props: Dict[str, Any]) -> Any:
474
+ label = ObjCClass("UILabel").alloc().init()
475
+ self._apply(label, props)
476
+ return label
477
+
478
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
479
+ self._apply(native_view, changed)
480
+
481
+ def _apply(self, label: Any, props: Dict[str, Any]) -> None:
482
+ if "text" in props:
483
+ label.setText_(str(props["text"]))
484
+ if "font_size" in props and props["font_size"] is not None:
485
+ if props.get("bold"):
486
+ label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"])))
487
+ else:
488
+ label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
489
+ elif "bold" in props and props["bold"]:
490
+ size = label.font().pointSize() if label.font() else 17.0
491
+ label.setFont_(UIFont.boldSystemFontOfSize_(size))
492
+ if "color" in props and props["color"] is not None:
493
+ label.setTextColor_(_uicolor(props["color"]))
494
+ if "background_color" in props and props["background_color"] is not None:
495
+ label.setBackgroundColor_(_uicolor(props["background_color"]))
496
+ if "max_lines" in props and props["max_lines"] is not None:
497
+ label.setNumberOfLines_(int(props["max_lines"]))
498
+ if "text_align" in props:
499
+ mapping = {"left": 0, "center": 1, "right": 2}
500
+ label.setTextAlignment_(mapping.get(props["text_align"], 0))
501
+
502
+ # ---- Button ---------------------------------------------------------
503
+
504
+ # btn id(ObjCInstance) -> _PNButtonTarget. Keeps a strong ref to
505
+ # each handler (preventing GC) and lets us swap the callback on
506
+ # re-render without calling removeTarget/addTarget (which crashes
507
+ # due to rubicon-objc wrapper lifecycle issues).
508
+ _pn_btn_handler_map: dict = {}
509
+
510
+ class _PNButtonTarget(NSObject): # type: ignore[valid-type]
511
+ _callback: Optional[Callable[[], None]] = None
512
+
513
+ @objc_method
514
+ def onTap_(self, sender: object) -> None:
515
+ if self._callback is not None:
516
+ self._callback()
517
+
518
+ # Strong refs to retained UIButton wrappers so the ObjCInstance
519
+ # (and its prevent-deallocation retain) stays alive for the
520
+ # lifetime of the app.
521
+ _pn_retained_views: list = []
522
+
523
+ class IOSButtonHandler(ViewHandler):
524
+ def create(self, props: Dict[str, Any]) -> Any:
525
+ btn = ObjCClass("UIButton").alloc().init()
526
+ btn.retain()
527
+ _pn_retained_views.append(btn)
528
+ _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0)
529
+ btn.setTitleColor_forState_(_ios_blue, 0)
530
+ self._apply(btn, props)
531
+ return btn
532
+
533
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
534
+ self._apply(native_view, changed)
535
+
536
+ def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
537
+ if "title" in props:
538
+ btn.setTitle_forState_(str(props["title"]), 0)
539
+ if "font_size" in props and props["font_size"] is not None:
540
+ btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
541
+ if "background_color" in props and props["background_color"] is not None:
542
+ btn.setBackgroundColor_(_uicolor(props["background_color"]))
543
+ if "color" not in props:
544
+ _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0)
545
+ btn.setTitleColor_forState_(_white, 0)
546
+ if "color" in props and props["color"] is not None:
547
+ btn.setTitleColor_forState_(_uicolor(props["color"]), 0)
548
+ if "enabled" in props:
549
+ btn.setEnabled_(bool(props["enabled"]))
550
+ if "on_click" in props:
551
+ existing = _pn_btn_handler_map.get(id(btn))
552
+ if existing is not None:
553
+ existing._callback = props["on_click"]
554
+ else:
555
+ handler = _PNButtonTarget.new()
556
+ handler._callback = props["on_click"]
557
+ _pn_btn_handler_map[id(btn)] = handler
558
+ btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6)
559
+
560
+ # ---- Column (vertical UIStackView) ----------------------------------
561
+ class IOSColumnHandler(ViewHandler):
562
+ def create(self, props: Dict[str, Any]) -> Any:
563
+ sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0)))
564
+ sv.setAxis_(1) # vertical
565
+ self._apply(sv, props)
566
+ return sv
567
+
568
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
569
+ self._apply(native_view, changed)
570
+
571
+ def _apply(self, sv: Any, props: Dict[str, Any]) -> None:
572
+ if "spacing" in props and props["spacing"]:
573
+ sv.setSpacing_(float(props["spacing"]))
574
+ if "alignment" in props and props["alignment"]:
575
+ mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4}
576
+ sv.setAlignment_(mapping.get(props["alignment"], 0))
577
+ if "background_color" in props and props["background_color"] is not None:
578
+ sv.setBackgroundColor_(_uicolor(props["background_color"]))
579
+ if "padding" in props:
580
+ left, top, right, bottom = _resolve_padding(props["padding"])
581
+ sv.setLayoutMarginsRelativeArrangement_(True)
582
+ try:
583
+ sv.setDirectionalLayoutMargins_((top, left, bottom, right))
584
+ except Exception:
585
+ sv.setLayoutMargins_((top, left, bottom, right))
586
+
587
+ def add_child(self, parent: Any, child: Any) -> None:
588
+ parent.addArrangedSubview_(child)
589
+
590
+ def remove_child(self, parent: Any, child: Any) -> None:
591
+ parent.removeArrangedSubview_(child)
592
+ child.removeFromSuperview()
593
+
594
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
595
+ parent.insertArrangedSubview_atIndex_(child, index)
596
+
597
+ # ---- Row (horizontal UIStackView) -----------------------------------
598
+ class IOSRowHandler(ViewHandler):
599
+ def create(self, props: Dict[str, Any]) -> Any:
600
+ sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0)))
601
+ sv.setAxis_(0) # horizontal
602
+ self._apply(sv, props)
603
+ return sv
604
+
605
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
606
+ self._apply(native_view, changed)
607
+
608
+ def _apply(self, sv: Any, props: Dict[str, Any]) -> None:
609
+ if "spacing" in props and props["spacing"]:
610
+ sv.setSpacing_(float(props["spacing"]))
611
+ if "alignment" in props and props["alignment"]:
612
+ mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4}
613
+ sv.setAlignment_(mapping.get(props["alignment"], 0))
614
+ if "background_color" in props and props["background_color"] is not None:
615
+ sv.setBackgroundColor_(_uicolor(props["background_color"]))
616
+
617
+ def add_child(self, parent: Any, child: Any) -> None:
618
+ parent.addArrangedSubview_(child)
619
+
620
+ def remove_child(self, parent: Any, child: Any) -> None:
621
+ parent.removeArrangedSubview_(child)
622
+ child.removeFromSuperview()
623
+
624
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
625
+ parent.insertArrangedSubview_atIndex_(child, index)
626
+
627
+ # ---- ScrollView -----------------------------------------------------
628
+ class IOSScrollViewHandler(ViewHandler):
629
+ def create(self, props: Dict[str, Any]) -> Any:
630
+ sv = ObjCClass("UIScrollView").alloc().init()
631
+ if "background_color" in props and props["background_color"] is not None:
632
+ sv.setBackgroundColor_(_uicolor(props["background_color"]))
633
+ return sv
634
+
635
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
636
+ if "background_color" in changed and changed["background_color"] is not None:
637
+ native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
638
+
639
+ def add_child(self, parent: Any, child: Any) -> None:
640
+ child.setTranslatesAutoresizingMaskIntoConstraints_(False)
641
+ parent.addSubview_(child)
642
+ content_guide = parent.contentLayoutGuide
643
+ frame_guide = parent.frameLayoutGuide
644
+ child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True)
645
+ child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True)
646
+ child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True)
647
+ child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True)
648
+ child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True)
649
+
650
+ def remove_child(self, parent: Any, child: Any) -> None:
651
+ child.removeFromSuperview()
652
+
653
+ # ---- TextInput (UITextField) ----------------------------------------
654
+ class IOSTextInputHandler(ViewHandler):
655
+ def create(self, props: Dict[str, Any]) -> Any:
656
+ tf = ObjCClass("UITextField").alloc().init()
657
+ tf.setBorderStyle_(2) # RoundedRect
658
+ self._apply(tf, props)
659
+ return tf
660
+
661
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
662
+ self._apply(native_view, changed)
663
+
664
+ def _apply(self, tf: Any, props: Dict[str, Any]) -> None:
665
+ if "value" in props:
666
+ tf.setText_(str(props["value"]))
667
+ if "placeholder" in props:
668
+ tf.setPlaceholder_(str(props["placeholder"]))
669
+ if "font_size" in props and props["font_size"] is not None:
670
+ tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
671
+ if "color" in props and props["color"] is not None:
672
+ tf.setTextColor_(_uicolor(props["color"]))
673
+ if "background_color" in props and props["background_color"] is not None:
674
+ tf.setBackgroundColor_(_uicolor(props["background_color"]))
675
+ if "secure" in props and props["secure"]:
676
+ tf.setSecureTextEntry_(True)
677
+
678
+ # ---- Image ----------------------------------------------------------
679
+ class IOSImageHandler(ViewHandler):
680
+ def create(self, props: Dict[str, Any]) -> Any:
681
+ iv = ObjCClass("UIImageView").alloc().init()
682
+ if "background_color" in props and props["background_color"] is not None:
683
+ iv.setBackgroundColor_(_uicolor(props["background_color"]))
684
+ return iv
685
+
686
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
687
+ if "background_color" in changed and changed["background_color"] is not None:
688
+ native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
689
+
690
+ # ---- Switch ---------------------------------------------------------
691
+ class IOSSwitchHandler(ViewHandler):
692
+ def create(self, props: Dict[str, Any]) -> Any:
693
+ sw = ObjCClass("UISwitch").alloc().init()
694
+ self._apply(sw, props)
695
+ return sw
696
+
697
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
698
+ self._apply(native_view, changed)
699
+
700
+ def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
701
+ if "value" in props:
702
+ sw.setOn_animated_(bool(props["value"]), False)
703
+
704
+ # ---- ProgressBar (UIProgressView) -----------------------------------
705
+ class IOSProgressBarHandler(ViewHandler):
706
+ def create(self, props: Dict[str, Any]) -> Any:
707
+ pv = ObjCClass("UIProgressView").alloc().init()
708
+ if "value" in props:
709
+ pv.setProgress_(float(props["value"]))
710
+ return pv
711
+
712
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
713
+ if "value" in changed:
714
+ native_view.setProgress_(float(changed["value"]))
715
+
716
+ # ---- ActivityIndicator ----------------------------------------------
717
+ class IOSActivityIndicatorHandler(ViewHandler):
718
+ def create(self, props: Dict[str, Any]) -> Any:
719
+ ai = ObjCClass("UIActivityIndicatorView").alloc().init()
720
+ if props.get("animating", True):
721
+ ai.startAnimating()
722
+ return ai
723
+
724
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
725
+ if "animating" in changed:
726
+ if changed["animating"]:
727
+ native_view.startAnimating()
728
+ else:
729
+ native_view.stopAnimating()
730
+
731
+ # ---- WebView (WKWebView) --------------------------------------------
732
+ class IOSWebViewHandler(ViewHandler):
733
+ def create(self, props: Dict[str, Any]) -> Any:
734
+ wv = ObjCClass("WKWebView").alloc().init()
735
+ if "url" in props and props["url"]:
736
+ NSURL = ObjCClass("NSURL")
737
+ NSURLRequest = ObjCClass("NSURLRequest")
738
+ url_obj = NSURL.URLWithString_(str(props["url"]))
739
+ wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
740
+ return wv
741
+
742
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
743
+ if "url" in changed and changed["url"]:
744
+ NSURL = ObjCClass("NSURL")
745
+ NSURLRequest = ObjCClass("NSURLRequest")
746
+ url_obj = NSURL.URLWithString_(str(changed["url"]))
747
+ native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
748
+
749
+ # ---- Spacer ---------------------------------------------------------
750
+ class IOSSpacerHandler(ViewHandler):
751
+ def create(self, props: Dict[str, Any]) -> Any:
752
+ v = ObjCClass("UIView").alloc().init()
753
+ if "size" in props and props["size"] is not None:
754
+ size = float(props["size"])
755
+ v.setFrame_(((0, 0), (size, size)))
756
+ return v
757
+
758
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
759
+ if "size" in changed and changed["size"] is not None:
760
+ size = float(changed["size"])
761
+ native_view.setFrame_(((0, 0), (size, size)))
762
+
763
+ registry.register("Text", IOSTextHandler())
764
+ registry.register("Button", IOSButtonHandler())
765
+ registry.register("Column", IOSColumnHandler())
766
+ registry.register("Row", IOSRowHandler())
767
+ registry.register("ScrollView", IOSScrollViewHandler())
768
+ registry.register("TextInput", IOSTextInputHandler())
769
+ registry.register("Image", IOSImageHandler())
770
+ registry.register("Switch", IOSSwitchHandler())
771
+ registry.register("ProgressBar", IOSProgressBarHandler())
772
+ registry.register("ActivityIndicator", IOSActivityIndicatorHandler())
773
+ registry.register("WebView", IOSWebViewHandler())
774
+ registry.register("Spacer", IOSSpacerHandler())
775
+
776
+
777
+ # ======================================================================
778
+ # Factory
779
+ # ======================================================================
780
+
781
+ _registry: Optional[NativeViewRegistry] = None
782
+
783
+
784
+ def get_registry() -> NativeViewRegistry:
785
+ """Return the singleton registry, lazily creating platform handlers."""
786
+ global _registry
787
+ if _registry is not None:
788
+ return _registry
789
+ _registry = NativeViewRegistry()
790
+ if IS_ANDROID:
791
+ _register_android_handlers(_registry)
792
+ else:
793
+ _register_ios_handlers(_registry)
794
+ return _registry
795
+
796
+
797
+ def set_registry(registry: NativeViewRegistry) -> None:
798
+ """Inject a custom or mock registry (primarily for testing)."""
799
+ global _registry
800
+ _registry = registry