pythonnative 0.4.0__py3-none-any.whl → 0.6.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 (52) hide show
  1. pythonnative/__init__.py +94 -66
  2. pythonnative/cli/pn.py +153 -24
  3. pythonnative/components.py +563 -0
  4. pythonnative/element.py +53 -0
  5. pythonnative/hooks.py +287 -0
  6. pythonnative/hot_reload.py +143 -0
  7. pythonnative/native_modules/__init__.py +19 -0
  8. pythonnative/native_modules/camera.py +105 -0
  9. pythonnative/native_modules/file_system.py +131 -0
  10. pythonnative/native_modules/location.py +61 -0
  11. pythonnative/native_modules/notifications.py +151 -0
  12. pythonnative/native_views.py +1334 -0
  13. pythonnative/page.py +320 -247
  14. pythonnative/reconciler.py +262 -0
  15. pythonnative/style.py +115 -0
  16. pythonnative/templates/android_template/app/build.gradle +2 -7
  17. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +2 -1
  18. pythonnative/templates/android_template/build.gradle +1 -1
  19. pythonnative/utils.py +21 -29
  20. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/METADATA +20 -19
  21. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/RECORD +25 -40
  22. pythonnative/activity_indicator_view.py +0 -71
  23. pythonnative/button.py +0 -113
  24. pythonnative/collection_view.py +0 -0
  25. pythonnative/date_picker.py +0 -76
  26. pythonnative/image_view.py +0 -78
  27. pythonnative/label.py +0 -133
  28. pythonnative/list_view.py +0 -76
  29. pythonnative/material_activity_indicator_view.py +0 -71
  30. pythonnative/material_bottom_navigation_view.py +0 -0
  31. pythonnative/material_button.py +0 -69
  32. pythonnative/material_date_picker.py +0 -87
  33. pythonnative/material_progress_view.py +0 -70
  34. pythonnative/material_search_bar.py +0 -69
  35. pythonnative/material_switch.py +0 -69
  36. pythonnative/material_time_picker.py +0 -76
  37. pythonnative/material_toolbar.py +0 -0
  38. pythonnative/picker_view.py +0 -69
  39. pythonnative/progress_view.py +0 -70
  40. pythonnative/scroll_view.py +0 -101
  41. pythonnative/search_bar.py +0 -69
  42. pythonnative/stack_view.py +0 -199
  43. pythonnative/switch.py +0 -68
  44. pythonnative/text_field.py +0 -132
  45. pythonnative/text_view.py +0 -135
  46. pythonnative/time_picker.py +0 -77
  47. pythonnative/view.py +0 -173
  48. pythonnative/web_view.py +0 -60
  49. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/WHEEL +0 -0
  50. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/entry_points.txt +0 -0
  51. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/licenses/LICENSE +0 -0
  52. {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1334 @@
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
+ _LAYOUT_KEYS = frozenset(
127
+ {
128
+ "width",
129
+ "height",
130
+ "flex",
131
+ "margin",
132
+ "min_width",
133
+ "max_width",
134
+ "min_height",
135
+ "max_height",
136
+ "align_self",
137
+ }
138
+ )
139
+
140
+
141
+ # ======================================================================
142
+ # Platform handler registration (lazy imports inside functions)
143
+ # ======================================================================
144
+
145
+
146
+ def _register_android_handlers(registry: NativeViewRegistry) -> None: # noqa: C901
147
+ from java import dynamic_proxy, jclass
148
+
149
+ from .utils import get_android_context
150
+
151
+ def _ctx() -> Any:
152
+ return get_android_context()
153
+
154
+ def _density() -> float:
155
+ return float(_ctx().getResources().getDisplayMetrics().density)
156
+
157
+ def _dp(value: float) -> int:
158
+ return int(value * _density())
159
+
160
+ def _apply_layout(view: Any, props: Dict[str, Any]) -> None:
161
+ """Apply common layout properties to an Android view."""
162
+ lp = view.getLayoutParams()
163
+ LayoutParams = jclass("android.widget.LinearLayout$LayoutParams")
164
+ ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams")
165
+ needs_set = False
166
+
167
+ if lp is None:
168
+ lp = LayoutParams(ViewGroupLP.WRAP_CONTENT, ViewGroupLP.WRAP_CONTENT)
169
+ needs_set = True
170
+
171
+ if "width" in props and props["width"] is not None:
172
+ lp.width = _dp(float(props["width"]))
173
+ needs_set = True
174
+ if "height" in props and props["height"] is not None:
175
+ lp.height = _dp(float(props["height"]))
176
+ needs_set = True
177
+ if "flex" in props and props["flex"] is not None:
178
+ try:
179
+ lp.weight = float(props["flex"])
180
+ needs_set = True
181
+ except Exception:
182
+ pass
183
+ if "margin" in props and props["margin"] is not None:
184
+ left, top, right, bottom = _resolve_padding(props["margin"])
185
+ try:
186
+ lp.setMargins(_dp(left), _dp(top), _dp(right), _dp(bottom))
187
+ needs_set = True
188
+ except Exception:
189
+ pass
190
+
191
+ if needs_set:
192
+ view.setLayoutParams(lp)
193
+
194
+ # ---- Text -----------------------------------------------------------
195
+ class AndroidTextHandler(ViewHandler):
196
+ def create(self, props: Dict[str, Any]) -> Any:
197
+ tv = jclass("android.widget.TextView")(_ctx())
198
+ self._apply(tv, props)
199
+ _apply_layout(tv, props)
200
+ return tv
201
+
202
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
203
+ self._apply(native_view, changed)
204
+ if changed.keys() & _LAYOUT_KEYS:
205
+ _apply_layout(native_view, changed)
206
+
207
+ def _apply(self, tv: Any, props: Dict[str, Any]) -> None:
208
+ if "text" in props:
209
+ tv.setText(str(props["text"]))
210
+ if "font_size" in props and props["font_size"] is not None:
211
+ tv.setTextSize(float(props["font_size"]))
212
+ if "color" in props and props["color"] is not None:
213
+ tv.setTextColor(parse_color_int(props["color"]))
214
+ if "background_color" in props and props["background_color"] is not None:
215
+ tv.setBackgroundColor(parse_color_int(props["background_color"]))
216
+ if "bold" in props and props["bold"]:
217
+ tv.setTypeface(tv.getTypeface(), 1) # Typeface.BOLD = 1
218
+ if "max_lines" in props and props["max_lines"] is not None:
219
+ tv.setMaxLines(int(props["max_lines"]))
220
+ if "text_align" in props:
221
+ Gravity = jclass("android.view.Gravity")
222
+ mapping = {"left": Gravity.START, "center": Gravity.CENTER, "right": Gravity.END}
223
+ tv.setGravity(mapping.get(props["text_align"], Gravity.START))
224
+
225
+ # ---- Button ---------------------------------------------------------
226
+ class AndroidButtonHandler(ViewHandler):
227
+ def create(self, props: Dict[str, Any]) -> Any:
228
+ btn = jclass("android.widget.Button")(_ctx())
229
+ self._apply(btn, props)
230
+ _apply_layout(btn, props)
231
+ return btn
232
+
233
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
234
+ self._apply(native_view, changed)
235
+ if changed.keys() & _LAYOUT_KEYS:
236
+ _apply_layout(native_view, changed)
237
+
238
+ def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
239
+ if "title" in props:
240
+ btn.setText(str(props["title"]))
241
+ if "font_size" in props and props["font_size"] is not None:
242
+ btn.setTextSize(float(props["font_size"]))
243
+ if "color" in props and props["color"] is not None:
244
+ btn.setTextColor(parse_color_int(props["color"]))
245
+ if "background_color" in props and props["background_color"] is not None:
246
+ btn.setBackgroundColor(parse_color_int(props["background_color"]))
247
+ if "enabled" in props:
248
+ btn.setEnabled(bool(props["enabled"]))
249
+ if "on_click" in props:
250
+ cb = props["on_click"]
251
+ if cb is not None:
252
+
253
+ class ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
254
+ def __init__(self, callback: Callable[[], None]) -> None:
255
+ super().__init__()
256
+ self.callback = callback
257
+
258
+ def onClick(self, view: Any) -> None:
259
+ self.callback()
260
+
261
+ btn.setOnClickListener(ClickProxy(cb))
262
+ else:
263
+ btn.setOnClickListener(None)
264
+
265
+ # ---- Column (vertical LinearLayout) ---------------------------------
266
+ class AndroidColumnHandler(ViewHandler):
267
+ def create(self, props: Dict[str, Any]) -> Any:
268
+ ll = jclass("android.widget.LinearLayout")(_ctx())
269
+ ll.setOrientation(jclass("android.widget.LinearLayout").VERTICAL)
270
+ self._apply(ll, props)
271
+ _apply_layout(ll, props)
272
+ return ll
273
+
274
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
275
+ self._apply(native_view, changed)
276
+ if changed.keys() & _LAYOUT_KEYS:
277
+ _apply_layout(native_view, changed)
278
+
279
+ def _apply(self, ll: Any, props: Dict[str, Any]) -> None:
280
+ if "spacing" in props and props["spacing"]:
281
+ px = _dp(float(props["spacing"]))
282
+ GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
283
+ d = GradientDrawable()
284
+ d.setColor(0x00000000)
285
+ d.setSize(1, px)
286
+ ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE)
287
+ ll.setDividerDrawable(d)
288
+ if "padding" in props:
289
+ left, top, right, bottom = _resolve_padding(props["padding"])
290
+ ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
291
+ if "alignment" in props and props["alignment"]:
292
+ Gravity = jclass("android.view.Gravity")
293
+ mapping = {
294
+ "fill": Gravity.FILL_HORIZONTAL,
295
+ "center": Gravity.CENTER_HORIZONTAL,
296
+ "leading": Gravity.START,
297
+ "start": Gravity.START,
298
+ "trailing": Gravity.END,
299
+ "end": Gravity.END,
300
+ }
301
+ ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_HORIZONTAL))
302
+ if "background_color" in props and props["background_color"] is not None:
303
+ ll.setBackgroundColor(parse_color_int(props["background_color"]))
304
+
305
+ def add_child(self, parent: Any, child: Any) -> None:
306
+ parent.addView(child)
307
+
308
+ def remove_child(self, parent: Any, child: Any) -> None:
309
+ parent.removeView(child)
310
+
311
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
312
+ parent.addView(child, index)
313
+
314
+ # ---- Row (horizontal LinearLayout) ----------------------------------
315
+ class AndroidRowHandler(ViewHandler):
316
+ def create(self, props: Dict[str, Any]) -> Any:
317
+ ll = jclass("android.widget.LinearLayout")(_ctx())
318
+ ll.setOrientation(jclass("android.widget.LinearLayout").HORIZONTAL)
319
+ self._apply(ll, props)
320
+ _apply_layout(ll, props)
321
+ return ll
322
+
323
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
324
+ self._apply(native_view, changed)
325
+ if changed.keys() & _LAYOUT_KEYS:
326
+ _apply_layout(native_view, changed)
327
+
328
+ def _apply(self, ll: Any, props: Dict[str, Any]) -> None:
329
+ if "spacing" in props and props["spacing"]:
330
+ px = _dp(float(props["spacing"]))
331
+ GradientDrawable = jclass("android.graphics.drawable.GradientDrawable")
332
+ d = GradientDrawable()
333
+ d.setColor(0x00000000)
334
+ d.setSize(px, 1)
335
+ ll.setShowDividers(jclass("android.widget.LinearLayout").SHOW_DIVIDER_MIDDLE)
336
+ ll.setDividerDrawable(d)
337
+ if "padding" in props:
338
+ left, top, right, bottom = _resolve_padding(props["padding"])
339
+ ll.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
340
+ if "alignment" in props and props["alignment"]:
341
+ Gravity = jclass("android.view.Gravity")
342
+ mapping = {
343
+ "fill": Gravity.FILL_VERTICAL,
344
+ "center": Gravity.CENTER_VERTICAL,
345
+ "top": Gravity.TOP,
346
+ "bottom": Gravity.BOTTOM,
347
+ }
348
+ ll.setGravity(mapping.get(props["alignment"], Gravity.FILL_VERTICAL))
349
+ if "background_color" in props and props["background_color"] is not None:
350
+ ll.setBackgroundColor(parse_color_int(props["background_color"]))
351
+
352
+ def add_child(self, parent: Any, child: Any) -> None:
353
+ parent.addView(child)
354
+
355
+ def remove_child(self, parent: Any, child: Any) -> None:
356
+ parent.removeView(child)
357
+
358
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
359
+ parent.addView(child, index)
360
+
361
+ # ---- ScrollView -----------------------------------------------------
362
+ class AndroidScrollViewHandler(ViewHandler):
363
+ def create(self, props: Dict[str, Any]) -> Any:
364
+ sv = jclass("android.widget.ScrollView")(_ctx())
365
+ if "background_color" in props and props["background_color"] is not None:
366
+ sv.setBackgroundColor(parse_color_int(props["background_color"]))
367
+ _apply_layout(sv, props)
368
+ return sv
369
+
370
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
371
+ if "background_color" in changed and changed["background_color"] is not None:
372
+ native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
373
+ if changed.keys() & _LAYOUT_KEYS:
374
+ _apply_layout(native_view, changed)
375
+
376
+ def add_child(self, parent: Any, child: Any) -> None:
377
+ parent.addView(child)
378
+
379
+ def remove_child(self, parent: Any, child: Any) -> None:
380
+ parent.removeView(child)
381
+
382
+ # ---- TextInput (EditText) with on_change ----------------------------
383
+ class AndroidTextInputHandler(ViewHandler):
384
+ def create(self, props: Dict[str, Any]) -> Any:
385
+ et = jclass("android.widget.EditText")(_ctx())
386
+ self._apply(et, props)
387
+ _apply_layout(et, props)
388
+ return et
389
+
390
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
391
+ self._apply(native_view, changed)
392
+ if changed.keys() & _LAYOUT_KEYS:
393
+ _apply_layout(native_view, changed)
394
+
395
+ def _apply(self, et: Any, props: Dict[str, Any]) -> None:
396
+ if "value" in props:
397
+ et.setText(str(props["value"]))
398
+ if "placeholder" in props:
399
+ et.setHint(str(props["placeholder"]))
400
+ if "font_size" in props and props["font_size"] is not None:
401
+ et.setTextSize(float(props["font_size"]))
402
+ if "color" in props and props["color"] is not None:
403
+ et.setTextColor(parse_color_int(props["color"]))
404
+ if "background_color" in props and props["background_color"] is not None:
405
+ et.setBackgroundColor(parse_color_int(props["background_color"]))
406
+ if "secure" in props and props["secure"]:
407
+ InputType = jclass("android.text.InputType")
408
+ et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD)
409
+ if "on_change" in props:
410
+ cb = props["on_change"]
411
+ if cb is not None:
412
+ TextWatcher = jclass("android.text.TextWatcher")
413
+
414
+ class ChangeProxy(dynamic_proxy(TextWatcher)):
415
+ def __init__(self, callback: Callable[[str], None]) -> None:
416
+ super().__init__()
417
+ self.callback = callback
418
+
419
+ def afterTextChanged(self, s: Any) -> None:
420
+ self.callback(str(s))
421
+
422
+ def beforeTextChanged(self, s: Any, start: int, count: int, after: int) -> None:
423
+ pass
424
+
425
+ def onTextChanged(self, s: Any, start: int, before: int, count: int) -> None:
426
+ pass
427
+
428
+ et.addTextChangedListener(ChangeProxy(cb))
429
+
430
+ # ---- Image (with URL loading) ---------------------------------------
431
+ class AndroidImageHandler(ViewHandler):
432
+ def create(self, props: Dict[str, Any]) -> Any:
433
+ iv = jclass("android.widget.ImageView")(_ctx())
434
+ self._apply(iv, props)
435
+ _apply_layout(iv, props)
436
+ return iv
437
+
438
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
439
+ self._apply(native_view, changed)
440
+ if changed.keys() & _LAYOUT_KEYS:
441
+ _apply_layout(native_view, changed)
442
+
443
+ def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
444
+ if "background_color" in props and props["background_color"] is not None:
445
+ iv.setBackgroundColor(parse_color_int(props["background_color"]))
446
+ if "source" in props and props["source"]:
447
+ self._load_source(iv, props["source"])
448
+ if "scale_type" in props and props["scale_type"]:
449
+ ScaleType = jclass("android.widget.ImageView$ScaleType")
450
+ mapping = {
451
+ "cover": ScaleType.CENTER_CROP,
452
+ "contain": ScaleType.FIT_CENTER,
453
+ "stretch": ScaleType.FIT_XY,
454
+ "center": ScaleType.CENTER,
455
+ }
456
+ st = mapping.get(props["scale_type"])
457
+ if st:
458
+ iv.setScaleType(st)
459
+
460
+ def _load_source(self, iv: Any, source: str) -> None:
461
+ try:
462
+ if source.startswith(("http://", "https://")):
463
+ Thread = jclass("java.lang.Thread")
464
+ Runnable = jclass("java.lang.Runnable")
465
+ URL = jclass("java.net.URL")
466
+ BitmapFactory = jclass("android.graphics.BitmapFactory")
467
+ Handler = jclass("android.os.Handler")
468
+ Looper = jclass("android.os.Looper")
469
+ handler = Handler(Looper.getMainLooper())
470
+
471
+ class LoadTask(dynamic_proxy(Runnable)):
472
+ def __init__(self, image_view: Any, url_str: str, main_handler: Any) -> None:
473
+ super().__init__()
474
+ self.image_view = image_view
475
+ self.url_str = url_str
476
+ self.main_handler = main_handler
477
+
478
+ def run(self) -> None:
479
+ try:
480
+ url = URL(self.url_str)
481
+ stream = url.openStream()
482
+ bitmap = BitmapFactory.decodeStream(stream)
483
+ stream.close()
484
+
485
+ class SetImage(dynamic_proxy(Runnable)):
486
+ def __init__(self, view: Any, bmp: Any) -> None:
487
+ super().__init__()
488
+ self.view = view
489
+ self.bmp = bmp
490
+
491
+ def run(self) -> None:
492
+ self.view.setImageBitmap(self.bmp)
493
+
494
+ self.main_handler.post(SetImage(self.image_view, bitmap))
495
+ except Exception:
496
+ pass
497
+
498
+ Thread(LoadTask(iv, source, handler)).start()
499
+ else:
500
+ ctx = _ctx()
501
+ res = ctx.getResources()
502
+ pkg = ctx.getPackageName()
503
+ res_name = source.rsplit(".", 1)[0] if "." in source else source
504
+ res_id = res.getIdentifier(res_name, "drawable", pkg)
505
+ if res_id != 0:
506
+ iv.setImageResource(res_id)
507
+ except Exception:
508
+ pass
509
+
510
+ # ---- Switch (with on_change) ----------------------------------------
511
+ class AndroidSwitchHandler(ViewHandler):
512
+ def create(self, props: Dict[str, Any]) -> Any:
513
+ sw = jclass("android.widget.Switch")(_ctx())
514
+ self._apply(sw, props)
515
+ _apply_layout(sw, props)
516
+ return sw
517
+
518
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
519
+ self._apply(native_view, changed)
520
+
521
+ def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
522
+ if "value" in props:
523
+ sw.setChecked(bool(props["value"]))
524
+ if "on_change" in props and props["on_change"] is not None:
525
+ cb = props["on_change"]
526
+
527
+ class CheckedProxy(dynamic_proxy(jclass("android.widget.CompoundButton").OnCheckedChangeListener)):
528
+ def __init__(self, callback: Callable[[bool], None]) -> None:
529
+ super().__init__()
530
+ self.callback = callback
531
+
532
+ def onCheckedChanged(self, button: Any, checked: bool) -> None:
533
+ self.callback(checked)
534
+
535
+ sw.setOnCheckedChangeListener(CheckedProxy(cb))
536
+
537
+ # ---- ProgressBar ----------------------------------------------------
538
+ class AndroidProgressBarHandler(ViewHandler):
539
+ def create(self, props: Dict[str, Any]) -> Any:
540
+ style = jclass("android.R$attr").progressBarStyleHorizontal
541
+ pb = jclass("android.widget.ProgressBar")(_ctx(), None, 0, style)
542
+ pb.setMax(1000)
543
+ self._apply(pb, props)
544
+ _apply_layout(pb, props)
545
+ return pb
546
+
547
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
548
+ self._apply(native_view, changed)
549
+
550
+ def _apply(self, pb: Any, props: Dict[str, Any]) -> None:
551
+ if "value" in props:
552
+ pb.setProgress(int(float(props["value"]) * 1000))
553
+
554
+ # ---- ActivityIndicator (circular ProgressBar) -----------------------
555
+ class AndroidActivityIndicatorHandler(ViewHandler):
556
+ def create(self, props: Dict[str, Any]) -> Any:
557
+ pb = jclass("android.widget.ProgressBar")(_ctx())
558
+ if not props.get("animating", True):
559
+ pb.setVisibility(jclass("android.view.View").GONE)
560
+ _apply_layout(pb, props)
561
+ return pb
562
+
563
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
564
+ View = jclass("android.view.View")
565
+ if "animating" in changed:
566
+ native_view.setVisibility(View.VISIBLE if changed["animating"] else View.GONE)
567
+
568
+ # ---- WebView --------------------------------------------------------
569
+ class AndroidWebViewHandler(ViewHandler):
570
+ def create(self, props: Dict[str, Any]) -> Any:
571
+ wv = jclass("android.webkit.WebView")(_ctx())
572
+ if "url" in props and props["url"]:
573
+ wv.loadUrl(str(props["url"]))
574
+ _apply_layout(wv, props)
575
+ return wv
576
+
577
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
578
+ if "url" in changed and changed["url"]:
579
+ native_view.loadUrl(str(changed["url"]))
580
+
581
+ # ---- Spacer ---------------------------------------------------------
582
+ class AndroidSpacerHandler(ViewHandler):
583
+ def create(self, props: Dict[str, Any]) -> Any:
584
+ v = jclass("android.view.View")(_ctx())
585
+ if "size" in props and props["size"] is not None:
586
+ px = _dp(float(props["size"]))
587
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
588
+ v.setLayoutParams(lp)
589
+ if "flex" in props and props["flex"] is not None:
590
+ lp = v.getLayoutParams()
591
+ if lp is None:
592
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(0, 0)
593
+ lp.weight = float(props["flex"])
594
+ v.setLayoutParams(lp)
595
+ return v
596
+
597
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
598
+ if "size" in changed and changed["size"] is not None:
599
+ px = _dp(float(changed["size"]))
600
+ lp = jclass("android.widget.LinearLayout$LayoutParams")(px, px)
601
+ native_view.setLayoutParams(lp)
602
+
603
+ # ---- View (generic container FrameLayout) ---------------------------
604
+ class AndroidViewHandler(ViewHandler):
605
+ def create(self, props: Dict[str, Any]) -> Any:
606
+ fl = jclass("android.widget.FrameLayout")(_ctx())
607
+ if "background_color" in props and props["background_color"] is not None:
608
+ fl.setBackgroundColor(parse_color_int(props["background_color"]))
609
+ if "padding" in props:
610
+ left, top, right, bottom = _resolve_padding(props["padding"])
611
+ fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
612
+ _apply_layout(fl, props)
613
+ return fl
614
+
615
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
616
+ if "background_color" in changed and changed["background_color"] is not None:
617
+ native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
618
+ if "padding" in changed:
619
+ left, top, right, bottom = _resolve_padding(changed["padding"])
620
+ native_view.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
621
+ if changed.keys() & _LAYOUT_KEYS:
622
+ _apply_layout(native_view, changed)
623
+
624
+ def add_child(self, parent: Any, child: Any) -> None:
625
+ parent.addView(child)
626
+
627
+ def remove_child(self, parent: Any, child: Any) -> None:
628
+ parent.removeView(child)
629
+
630
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
631
+ parent.addView(child, index)
632
+
633
+ # ---- SafeAreaView (FrameLayout with fitsSystemWindows) ---------------
634
+ class AndroidSafeAreaViewHandler(ViewHandler):
635
+ def create(self, props: Dict[str, Any]) -> Any:
636
+ fl = jclass("android.widget.FrameLayout")(_ctx())
637
+ fl.setFitsSystemWindows(True)
638
+ if "background_color" in props and props["background_color"] is not None:
639
+ fl.setBackgroundColor(parse_color_int(props["background_color"]))
640
+ if "padding" in props:
641
+ left, top, right, bottom = _resolve_padding(props["padding"])
642
+ fl.setPadding(_dp(left), _dp(top), _dp(right), _dp(bottom))
643
+ return fl
644
+
645
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
646
+ if "background_color" in changed and changed["background_color"] is not None:
647
+ native_view.setBackgroundColor(parse_color_int(changed["background_color"]))
648
+
649
+ def add_child(self, parent: Any, child: Any) -> None:
650
+ parent.addView(child)
651
+
652
+ def remove_child(self, parent: Any, child: Any) -> None:
653
+ parent.removeView(child)
654
+
655
+ # ---- Modal (AlertDialog) -------------------------------------------
656
+ class AndroidModalHandler(ViewHandler):
657
+ def create(self, props: Dict[str, Any]) -> Any:
658
+ placeholder = jclass("android.view.View")(_ctx())
659
+ placeholder.setVisibility(jclass("android.view.View").GONE)
660
+ return placeholder
661
+
662
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
663
+ pass
664
+
665
+ def add_child(self, parent: Any, child: Any) -> None:
666
+ pass
667
+
668
+ # ---- Slider (SeekBar) -----------------------------------------------
669
+ class AndroidSliderHandler(ViewHandler):
670
+ def create(self, props: Dict[str, Any]) -> Any:
671
+ sb = jclass("android.widget.SeekBar")(_ctx())
672
+ sb.setMax(1000)
673
+ self._apply(sb, props)
674
+ _apply_layout(sb, props)
675
+ return sb
676
+
677
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
678
+ self._apply(native_view, changed)
679
+
680
+ def _apply(self, sb: Any, props: Dict[str, Any]) -> None:
681
+ min_val = float(props.get("min_value", 0))
682
+ max_val = float(props.get("max_value", 1))
683
+ rng = max_val - min_val if max_val != min_val else 1
684
+ if "value" in props:
685
+ normalized = (float(props["value"]) - min_val) / rng
686
+ sb.setProgress(int(normalized * 1000))
687
+ if "on_change" in props and props["on_change"] is not None:
688
+ cb = props["on_change"]
689
+
690
+ class SeekProxy(dynamic_proxy(jclass("android.widget.SeekBar").OnSeekBarChangeListener)):
691
+ def __init__(self, callback: Callable[[float], None], mn: float, rn: float) -> None:
692
+ super().__init__()
693
+ self.callback = callback
694
+ self.mn = mn
695
+ self.rn = rn
696
+
697
+ def onProgressChanged(self, seekBar: Any, progress: int, fromUser: bool) -> None:
698
+ if fromUser:
699
+ self.callback(self.mn + (progress / 1000.0) * self.rn)
700
+
701
+ def onStartTrackingTouch(self, seekBar: Any) -> None:
702
+ pass
703
+
704
+ def onStopTrackingTouch(self, seekBar: Any) -> None:
705
+ pass
706
+
707
+ sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng))
708
+
709
+ # ---- Pressable (FrameLayout with click listener) --------------------
710
+ class AndroidPressableHandler(ViewHandler):
711
+ def create(self, props: Dict[str, Any]) -> Any:
712
+ fl = jclass("android.widget.FrameLayout")(_ctx())
713
+ fl.setClickable(True)
714
+ self._apply(fl, props)
715
+ return fl
716
+
717
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
718
+ self._apply(native_view, changed)
719
+
720
+ def _apply(self, fl: Any, props: Dict[str, Any]) -> None:
721
+ if "on_press" in props and props["on_press"] is not None:
722
+ cb = props["on_press"]
723
+
724
+ class PressProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
725
+ def __init__(self, callback: Callable[[], None]) -> None:
726
+ super().__init__()
727
+ self.callback = callback
728
+
729
+ def onClick(self, view: Any) -> None:
730
+ self.callback()
731
+
732
+ fl.setOnClickListener(PressProxy(cb))
733
+ if "on_long_press" in props and props["on_long_press"] is not None:
734
+ cb = props["on_long_press"]
735
+
736
+ class LongPressProxy(dynamic_proxy(jclass("android.view.View").OnLongClickListener)):
737
+ def __init__(self, callback: Callable[[], None]) -> None:
738
+ super().__init__()
739
+ self.callback = callback
740
+
741
+ def onLongClick(self, view: Any) -> bool:
742
+ self.callback()
743
+ return True
744
+
745
+ fl.setOnLongClickListener(LongPressProxy(cb))
746
+
747
+ def add_child(self, parent: Any, child: Any) -> None:
748
+ parent.addView(child)
749
+
750
+ def remove_child(self, parent: Any, child: Any) -> None:
751
+ parent.removeView(child)
752
+
753
+ registry.register("Text", AndroidTextHandler())
754
+ registry.register("Button", AndroidButtonHandler())
755
+ registry.register("Column", AndroidColumnHandler())
756
+ registry.register("Row", AndroidRowHandler())
757
+ registry.register("ScrollView", AndroidScrollViewHandler())
758
+ registry.register("TextInput", AndroidTextInputHandler())
759
+ registry.register("Image", AndroidImageHandler())
760
+ registry.register("Switch", AndroidSwitchHandler())
761
+ registry.register("ProgressBar", AndroidProgressBarHandler())
762
+ registry.register("ActivityIndicator", AndroidActivityIndicatorHandler())
763
+ registry.register("WebView", AndroidWebViewHandler())
764
+ registry.register("Spacer", AndroidSpacerHandler())
765
+ registry.register("View", AndroidViewHandler())
766
+ registry.register("SafeAreaView", AndroidSafeAreaViewHandler())
767
+ registry.register("Modal", AndroidModalHandler())
768
+ registry.register("Slider", AndroidSliderHandler())
769
+ registry.register("Pressable", AndroidPressableHandler())
770
+
771
+
772
+ def _register_ios_handlers(registry: NativeViewRegistry) -> None: # noqa: C901
773
+ from rubicon.objc import SEL, ObjCClass, objc_method
774
+
775
+ NSObject = ObjCClass("NSObject")
776
+ UIColor = ObjCClass("UIColor")
777
+ UIFont = ObjCClass("UIFont")
778
+
779
+ def _uicolor(color: Any) -> Any:
780
+ argb = parse_color_int(color)
781
+ if argb < 0:
782
+ argb += 0x100000000
783
+ a = ((argb >> 24) & 0xFF) / 255.0
784
+ r = ((argb >> 16) & 0xFF) / 255.0
785
+ g = ((argb >> 8) & 0xFF) / 255.0
786
+ b = (argb & 0xFF) / 255.0
787
+ return UIColor.colorWithRed_green_blue_alpha_(r, g, b, a)
788
+
789
+ def _apply_ios_layout(view: Any, props: Dict[str, Any]) -> None:
790
+ """Apply common layout constraints to an iOS view."""
791
+ if "width" in props and props["width"] is not None:
792
+ try:
793
+ for c in list(view.constraints or []):
794
+ if c.firstAttribute == 7: # NSLayoutAttributeWidth
795
+ c.setActive_(False)
796
+ view.widthAnchor.constraintEqualToConstant_(float(props["width"])).setActive_(True)
797
+ except Exception:
798
+ pass
799
+ if "height" in props and props["height"] is not None:
800
+ try:
801
+ for c in list(view.constraints or []):
802
+ if c.firstAttribute == 8: # NSLayoutAttributeHeight
803
+ c.setActive_(False)
804
+ view.heightAnchor.constraintEqualToConstant_(float(props["height"])).setActive_(True)
805
+ except Exception:
806
+ pass
807
+
808
+ # ---- Text -----------------------------------------------------------
809
+ class IOSTextHandler(ViewHandler):
810
+ def create(self, props: Dict[str, Any]) -> Any:
811
+ label = ObjCClass("UILabel").alloc().init()
812
+ self._apply(label, props)
813
+ _apply_ios_layout(label, props)
814
+ return label
815
+
816
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
817
+ self._apply(native_view, changed)
818
+ if changed.keys() & _LAYOUT_KEYS:
819
+ _apply_ios_layout(native_view, changed)
820
+
821
+ def _apply(self, label: Any, props: Dict[str, Any]) -> None:
822
+ if "text" in props:
823
+ label.setText_(str(props["text"]))
824
+ if "font_size" in props and props["font_size"] is not None:
825
+ if props.get("bold"):
826
+ label.setFont_(UIFont.boldSystemFontOfSize_(float(props["font_size"])))
827
+ else:
828
+ label.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
829
+ elif "bold" in props and props["bold"]:
830
+ size = label.font().pointSize() if label.font() else 17.0
831
+ label.setFont_(UIFont.boldSystemFontOfSize_(size))
832
+ if "color" in props and props["color"] is not None:
833
+ label.setTextColor_(_uicolor(props["color"]))
834
+ if "background_color" in props and props["background_color"] is not None:
835
+ label.setBackgroundColor_(_uicolor(props["background_color"]))
836
+ if "max_lines" in props and props["max_lines"] is not None:
837
+ label.setNumberOfLines_(int(props["max_lines"]))
838
+ if "text_align" in props:
839
+ mapping = {"left": 0, "center": 1, "right": 2}
840
+ label.setTextAlignment_(mapping.get(props["text_align"], 0))
841
+
842
+ # ---- Button ---------------------------------------------------------
843
+
844
+ _pn_btn_handler_map: dict = {}
845
+
846
+ class _PNButtonTarget(NSObject): # type: ignore[valid-type]
847
+ _callback: Optional[Callable[[], None]] = None
848
+
849
+ @objc_method
850
+ def onTap_(self, sender: object) -> None:
851
+ if self._callback is not None:
852
+ self._callback()
853
+
854
+ _pn_retained_views: list = []
855
+
856
+ class IOSButtonHandler(ViewHandler):
857
+ def create(self, props: Dict[str, Any]) -> Any:
858
+ btn = ObjCClass("UIButton").alloc().init()
859
+ btn.retain()
860
+ _pn_retained_views.append(btn)
861
+ _ios_blue = UIColor.colorWithRed_green_blue_alpha_(0.0, 0.478, 1.0, 1.0)
862
+ btn.setTitleColor_forState_(_ios_blue, 0)
863
+ self._apply(btn, props)
864
+ _apply_ios_layout(btn, props)
865
+ return btn
866
+
867
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
868
+ self._apply(native_view, changed)
869
+ if changed.keys() & _LAYOUT_KEYS:
870
+ _apply_ios_layout(native_view, changed)
871
+
872
+ def _apply(self, btn: Any, props: Dict[str, Any]) -> None:
873
+ if "title" in props:
874
+ btn.setTitle_forState_(str(props["title"]), 0)
875
+ if "font_size" in props and props["font_size"] is not None:
876
+ btn.titleLabel().setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
877
+ if "background_color" in props and props["background_color"] is not None:
878
+ btn.setBackgroundColor_(_uicolor(props["background_color"]))
879
+ if "color" not in props:
880
+ _white = UIColor.colorWithRed_green_blue_alpha_(1.0, 1.0, 1.0, 1.0)
881
+ btn.setTitleColor_forState_(_white, 0)
882
+ if "color" in props and props["color"] is not None:
883
+ btn.setTitleColor_forState_(_uicolor(props["color"]), 0)
884
+ if "enabled" in props:
885
+ btn.setEnabled_(bool(props["enabled"]))
886
+ if "on_click" in props:
887
+ existing = _pn_btn_handler_map.get(id(btn))
888
+ if existing is not None:
889
+ existing._callback = props["on_click"]
890
+ else:
891
+ handler = _PNButtonTarget.new()
892
+ handler._callback = props["on_click"]
893
+ _pn_btn_handler_map[id(btn)] = handler
894
+ btn.addTarget_action_forControlEvents_(handler, SEL("onTap:"), 1 << 6)
895
+
896
+ # ---- Column (vertical UIStackView) ----------------------------------
897
+ class IOSColumnHandler(ViewHandler):
898
+ def create(self, props: Dict[str, Any]) -> Any:
899
+ sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0)))
900
+ sv.setAxis_(1) # vertical
901
+ self._apply(sv, props)
902
+ _apply_ios_layout(sv, props)
903
+ return sv
904
+
905
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
906
+ self._apply(native_view, changed)
907
+ if changed.keys() & _LAYOUT_KEYS:
908
+ _apply_ios_layout(native_view, changed)
909
+
910
+ def _apply(self, sv: Any, props: Dict[str, Any]) -> None:
911
+ if "spacing" in props and props["spacing"]:
912
+ sv.setSpacing_(float(props["spacing"]))
913
+ if "alignment" in props and props["alignment"]:
914
+ mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4}
915
+ sv.setAlignment_(mapping.get(props["alignment"], 0))
916
+ if "background_color" in props and props["background_color"] is not None:
917
+ sv.setBackgroundColor_(_uicolor(props["background_color"]))
918
+ if "padding" in props:
919
+ left, top, right, bottom = _resolve_padding(props["padding"])
920
+ sv.setLayoutMarginsRelativeArrangement_(True)
921
+ try:
922
+ sv.setDirectionalLayoutMargins_((top, left, bottom, right))
923
+ except Exception:
924
+ sv.setLayoutMargins_((top, left, bottom, right))
925
+
926
+ def add_child(self, parent: Any, child: Any) -> None:
927
+ parent.addArrangedSubview_(child)
928
+
929
+ def remove_child(self, parent: Any, child: Any) -> None:
930
+ parent.removeArrangedSubview_(child)
931
+ child.removeFromSuperview()
932
+
933
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
934
+ parent.insertArrangedSubview_atIndex_(child, index)
935
+
936
+ # ---- Row (horizontal UIStackView) -----------------------------------
937
+ class IOSRowHandler(ViewHandler):
938
+ def create(self, props: Dict[str, Any]) -> Any:
939
+ sv = ObjCClass("UIStackView").alloc().initWithFrame_(((0, 0), (0, 0)))
940
+ sv.setAxis_(0) # horizontal
941
+ self._apply(sv, props)
942
+ _apply_ios_layout(sv, props)
943
+ return sv
944
+
945
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
946
+ self._apply(native_view, changed)
947
+ if changed.keys() & _LAYOUT_KEYS:
948
+ _apply_ios_layout(native_view, changed)
949
+
950
+ def _apply(self, sv: Any, props: Dict[str, Any]) -> None:
951
+ if "spacing" in props and props["spacing"]:
952
+ sv.setSpacing_(float(props["spacing"]))
953
+ if "alignment" in props and props["alignment"]:
954
+ mapping = {"fill": 0, "leading": 1, "top": 1, "center": 3, "trailing": 4, "bottom": 4}
955
+ sv.setAlignment_(mapping.get(props["alignment"], 0))
956
+ if "background_color" in props and props["background_color"] is not None:
957
+ sv.setBackgroundColor_(_uicolor(props["background_color"]))
958
+
959
+ def add_child(self, parent: Any, child: Any) -> None:
960
+ parent.addArrangedSubview_(child)
961
+
962
+ def remove_child(self, parent: Any, child: Any) -> None:
963
+ parent.removeArrangedSubview_(child)
964
+ child.removeFromSuperview()
965
+
966
+ def insert_child(self, parent: Any, child: Any, index: int) -> None:
967
+ parent.insertArrangedSubview_atIndex_(child, index)
968
+
969
+ # ---- ScrollView -----------------------------------------------------
970
+ class IOSScrollViewHandler(ViewHandler):
971
+ def create(self, props: Dict[str, Any]) -> Any:
972
+ sv = ObjCClass("UIScrollView").alloc().init()
973
+ if "background_color" in props and props["background_color"] is not None:
974
+ sv.setBackgroundColor_(_uicolor(props["background_color"]))
975
+ _apply_ios_layout(sv, props)
976
+ return sv
977
+
978
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
979
+ if "background_color" in changed and changed["background_color"] is not None:
980
+ native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
981
+
982
+ def add_child(self, parent: Any, child: Any) -> None:
983
+ child.setTranslatesAutoresizingMaskIntoConstraints_(False)
984
+ parent.addSubview_(child)
985
+ content_guide = parent.contentLayoutGuide
986
+ frame_guide = parent.frameLayoutGuide
987
+ child.topAnchor.constraintEqualToAnchor_(content_guide.topAnchor).setActive_(True)
988
+ child.leadingAnchor.constraintEqualToAnchor_(content_guide.leadingAnchor).setActive_(True)
989
+ child.trailingAnchor.constraintEqualToAnchor_(content_guide.trailingAnchor).setActive_(True)
990
+ child.bottomAnchor.constraintEqualToAnchor_(content_guide.bottomAnchor).setActive_(True)
991
+ child.widthAnchor.constraintEqualToAnchor_(frame_guide.widthAnchor).setActive_(True)
992
+
993
+ def remove_child(self, parent: Any, child: Any) -> None:
994
+ child.removeFromSuperview()
995
+
996
+ # ---- TextInput (UITextField with on_change) -------------------------
997
+ _pn_tf_handler_map: dict = {}
998
+
999
+ class _PNTextFieldTarget(NSObject): # type: ignore[valid-type]
1000
+ _callback: Optional[Callable[[str], None]] = None
1001
+
1002
+ @objc_method
1003
+ def onEdit_(self, sender: object) -> None:
1004
+ if self._callback is not None:
1005
+ try:
1006
+ text = str(sender.text) if sender and hasattr(sender, "text") else ""
1007
+ self._callback(text)
1008
+ except Exception:
1009
+ pass
1010
+
1011
+ class IOSTextInputHandler(ViewHandler):
1012
+ def create(self, props: Dict[str, Any]) -> Any:
1013
+ tf = ObjCClass("UITextField").alloc().init()
1014
+ tf.setBorderStyle_(2) # RoundedRect
1015
+ self._apply(tf, props)
1016
+ _apply_ios_layout(tf, props)
1017
+ return tf
1018
+
1019
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1020
+ self._apply(native_view, changed)
1021
+ if changed.keys() & _LAYOUT_KEYS:
1022
+ _apply_ios_layout(native_view, changed)
1023
+
1024
+ def _apply(self, tf: Any, props: Dict[str, Any]) -> None:
1025
+ if "value" in props:
1026
+ tf.setText_(str(props["value"]))
1027
+ if "placeholder" in props:
1028
+ tf.setPlaceholder_(str(props["placeholder"]))
1029
+ if "font_size" in props and props["font_size"] is not None:
1030
+ tf.setFont_(UIFont.systemFontOfSize_(float(props["font_size"])))
1031
+ if "color" in props and props["color"] is not None:
1032
+ tf.setTextColor_(_uicolor(props["color"]))
1033
+ if "background_color" in props and props["background_color"] is not None:
1034
+ tf.setBackgroundColor_(_uicolor(props["background_color"]))
1035
+ if "secure" in props and props["secure"]:
1036
+ tf.setSecureTextEntry_(True)
1037
+ if "on_change" in props:
1038
+ existing = _pn_tf_handler_map.get(id(tf))
1039
+ if existing is not None:
1040
+ existing._callback = props["on_change"]
1041
+ else:
1042
+ handler = _PNTextFieldTarget.new()
1043
+ handler._callback = props["on_change"]
1044
+ _pn_tf_handler_map[id(tf)] = handler
1045
+ tf.addTarget_action_forControlEvents_(handler, SEL("onEdit:"), 1 << 17)
1046
+
1047
+ # ---- Image (with URL loading) ---------------------------------------
1048
+ class IOSImageHandler(ViewHandler):
1049
+ def create(self, props: Dict[str, Any]) -> Any:
1050
+ iv = ObjCClass("UIImageView").alloc().init()
1051
+ self._apply(iv, props)
1052
+ _apply_ios_layout(iv, props)
1053
+ return iv
1054
+
1055
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1056
+ self._apply(native_view, changed)
1057
+ if changed.keys() & _LAYOUT_KEYS:
1058
+ _apply_ios_layout(native_view, changed)
1059
+
1060
+ def _apply(self, iv: Any, props: Dict[str, Any]) -> None:
1061
+ if "background_color" in props and props["background_color"] is not None:
1062
+ iv.setBackgroundColor_(_uicolor(props["background_color"]))
1063
+ if "source" in props and props["source"]:
1064
+ self._load_source(iv, props["source"])
1065
+ if "scale_type" in props and props["scale_type"]:
1066
+ mapping = {"cover": 2, "contain": 1, "stretch": 0, "center": 4}
1067
+ iv.setContentMode_(mapping.get(props["scale_type"], 1))
1068
+
1069
+ def _load_source(self, iv: Any, source: str) -> None:
1070
+ try:
1071
+ if source.startswith(("http://", "https://")):
1072
+ NSURL = ObjCClass("NSURL")
1073
+ NSData = ObjCClass("NSData")
1074
+ UIImage = ObjCClass("UIImage")
1075
+ url = NSURL.URLWithString_(source)
1076
+ data = NSData.dataWithContentsOfURL_(url)
1077
+ if data:
1078
+ image = UIImage.imageWithData_(data)
1079
+ if image:
1080
+ iv.setImage_(image)
1081
+ else:
1082
+ UIImage = ObjCClass("UIImage")
1083
+ image = UIImage.imageNamed_(source)
1084
+ if image:
1085
+ iv.setImage_(image)
1086
+ except Exception:
1087
+ pass
1088
+
1089
+ # ---- Switch (with on_change) ----------------------------------------
1090
+ _pn_switch_handler_map: dict = {}
1091
+
1092
+ class _PNSwitchTarget(NSObject): # type: ignore[valid-type]
1093
+ _callback: Optional[Callable[[bool], None]] = None
1094
+
1095
+ @objc_method
1096
+ def onToggle_(self, sender: object) -> None:
1097
+ if self._callback is not None:
1098
+ try:
1099
+ self._callback(bool(sender.isOn()))
1100
+ except Exception:
1101
+ pass
1102
+
1103
+ class IOSSwitchHandler(ViewHandler):
1104
+ def create(self, props: Dict[str, Any]) -> Any:
1105
+ sw = ObjCClass("UISwitch").alloc().init()
1106
+ self._apply(sw, props)
1107
+ return sw
1108
+
1109
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1110
+ self._apply(native_view, changed)
1111
+
1112
+ def _apply(self, sw: Any, props: Dict[str, Any]) -> None:
1113
+ if "value" in props:
1114
+ sw.setOn_animated_(bool(props["value"]), False)
1115
+ if "on_change" in props:
1116
+ existing = _pn_switch_handler_map.get(id(sw))
1117
+ if existing is not None:
1118
+ existing._callback = props["on_change"]
1119
+ else:
1120
+ handler = _PNSwitchTarget.new()
1121
+ handler._callback = props["on_change"]
1122
+ _pn_switch_handler_map[id(sw)] = handler
1123
+ sw.addTarget_action_forControlEvents_(handler, SEL("onToggle:"), 1 << 12)
1124
+
1125
+ # ---- ProgressBar (UIProgressView) -----------------------------------
1126
+ class IOSProgressBarHandler(ViewHandler):
1127
+ def create(self, props: Dict[str, Any]) -> Any:
1128
+ pv = ObjCClass("UIProgressView").alloc().init()
1129
+ if "value" in props:
1130
+ pv.setProgress_(float(props["value"]))
1131
+ _apply_ios_layout(pv, props)
1132
+ return pv
1133
+
1134
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1135
+ if "value" in changed:
1136
+ native_view.setProgress_(float(changed["value"]))
1137
+
1138
+ # ---- ActivityIndicator ----------------------------------------------
1139
+ class IOSActivityIndicatorHandler(ViewHandler):
1140
+ def create(self, props: Dict[str, Any]) -> Any:
1141
+ ai = ObjCClass("UIActivityIndicatorView").alloc().init()
1142
+ if props.get("animating", True):
1143
+ ai.startAnimating()
1144
+ return ai
1145
+
1146
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1147
+ if "animating" in changed:
1148
+ if changed["animating"]:
1149
+ native_view.startAnimating()
1150
+ else:
1151
+ native_view.stopAnimating()
1152
+
1153
+ # ---- WebView (WKWebView) --------------------------------------------
1154
+ class IOSWebViewHandler(ViewHandler):
1155
+ def create(self, props: Dict[str, Any]) -> Any:
1156
+ wv = ObjCClass("WKWebView").alloc().init()
1157
+ if "url" in props and props["url"]:
1158
+ NSURL = ObjCClass("NSURL")
1159
+ NSURLRequest = ObjCClass("NSURLRequest")
1160
+ url_obj = NSURL.URLWithString_(str(props["url"]))
1161
+ wv.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
1162
+ _apply_ios_layout(wv, props)
1163
+ return wv
1164
+
1165
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1166
+ if "url" in changed and changed["url"]:
1167
+ NSURL = ObjCClass("NSURL")
1168
+ NSURLRequest = ObjCClass("NSURLRequest")
1169
+ url_obj = NSURL.URLWithString_(str(changed["url"]))
1170
+ native_view.loadRequest_(NSURLRequest.requestWithURL_(url_obj))
1171
+
1172
+ # ---- Spacer ---------------------------------------------------------
1173
+ class IOSSpacerHandler(ViewHandler):
1174
+ def create(self, props: Dict[str, Any]) -> Any:
1175
+ v = ObjCClass("UIView").alloc().init()
1176
+ if "size" in props and props["size"] is not None:
1177
+ size = float(props["size"])
1178
+ v.setFrame_(((0, 0), (size, size)))
1179
+ return v
1180
+
1181
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1182
+ if "size" in changed and changed["size"] is not None:
1183
+ size = float(changed["size"])
1184
+ native_view.setFrame_(((0, 0), (size, size)))
1185
+
1186
+ # ---- View (generic UIView) -----------------------------------------
1187
+ class IOSViewHandler(ViewHandler):
1188
+ def create(self, props: Dict[str, Any]) -> Any:
1189
+ v = ObjCClass("UIView").alloc().init()
1190
+ if "background_color" in props and props["background_color"] is not None:
1191
+ v.setBackgroundColor_(_uicolor(props["background_color"]))
1192
+ _apply_ios_layout(v, props)
1193
+ return v
1194
+
1195
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1196
+ if "background_color" in changed and changed["background_color"] is not None:
1197
+ native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
1198
+ if changed.keys() & _LAYOUT_KEYS:
1199
+ _apply_ios_layout(native_view, changed)
1200
+
1201
+ def add_child(self, parent: Any, child: Any) -> None:
1202
+ parent.addSubview_(child)
1203
+
1204
+ def remove_child(self, parent: Any, child: Any) -> None:
1205
+ child.removeFromSuperview()
1206
+
1207
+ # ---- SafeAreaView ---------------------------------------------------
1208
+ class IOSSafeAreaViewHandler(ViewHandler):
1209
+ def create(self, props: Dict[str, Any]) -> Any:
1210
+ v = ObjCClass("UIView").alloc().init()
1211
+ if "background_color" in props and props["background_color"] is not None:
1212
+ v.setBackgroundColor_(_uicolor(props["background_color"]))
1213
+ return v
1214
+
1215
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1216
+ if "background_color" in changed and changed["background_color"] is not None:
1217
+ native_view.setBackgroundColor_(_uicolor(changed["background_color"]))
1218
+
1219
+ def add_child(self, parent: Any, child: Any) -> None:
1220
+ parent.addSubview_(child)
1221
+
1222
+ def remove_child(self, parent: Any, child: Any) -> None:
1223
+ child.removeFromSuperview()
1224
+
1225
+ # ---- Modal ----------------------------------------------------------
1226
+ class IOSModalHandler(ViewHandler):
1227
+ def create(self, props: Dict[str, Any]) -> Any:
1228
+ v = ObjCClass("UIView").alloc().init()
1229
+ v.setHidden_(True)
1230
+ return v
1231
+
1232
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1233
+ pass
1234
+
1235
+ # ---- Slider (UISlider) ----------------------------------------------
1236
+ _pn_slider_handler_map: dict = {}
1237
+
1238
+ class _PNSliderTarget(NSObject): # type: ignore[valid-type]
1239
+ _callback: Optional[Callable[[float], None]] = None
1240
+
1241
+ @objc_method
1242
+ def onSlide_(self, sender: object) -> None:
1243
+ if self._callback is not None:
1244
+ try:
1245
+ self._callback(float(sender.value))
1246
+ except Exception:
1247
+ pass
1248
+
1249
+ class IOSSliderHandler(ViewHandler):
1250
+ def create(self, props: Dict[str, Any]) -> Any:
1251
+ sl = ObjCClass("UISlider").alloc().init()
1252
+ self._apply(sl, props)
1253
+ _apply_ios_layout(sl, props)
1254
+ return sl
1255
+
1256
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1257
+ self._apply(native_view, changed)
1258
+
1259
+ def _apply(self, sl: Any, props: Dict[str, Any]) -> None:
1260
+ if "min_value" in props:
1261
+ sl.setMinimumValue_(float(props["min_value"]))
1262
+ if "max_value" in props:
1263
+ sl.setMaximumValue_(float(props["max_value"]))
1264
+ if "value" in props:
1265
+ sl.setValue_(float(props["value"]))
1266
+ if "on_change" in props:
1267
+ existing = _pn_slider_handler_map.get(id(sl))
1268
+ if existing is not None:
1269
+ existing._callback = props["on_change"]
1270
+ else:
1271
+ handler = _PNSliderTarget.new()
1272
+ handler._callback = props["on_change"]
1273
+ _pn_slider_handler_map[id(sl)] = handler
1274
+ sl.addTarget_action_forControlEvents_(handler, SEL("onSlide:"), 1 << 12)
1275
+
1276
+ # ---- Pressable (UIView with tap gesture) ----------------------------
1277
+ class IOSPressableHandler(ViewHandler):
1278
+ def create(self, props: Dict[str, Any]) -> Any:
1279
+ v = ObjCClass("UIView").alloc().init()
1280
+ v.setUserInteractionEnabled_(True)
1281
+ return v
1282
+
1283
+ def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
1284
+ pass
1285
+
1286
+ def add_child(self, parent: Any, child: Any) -> None:
1287
+ parent.addSubview_(child)
1288
+
1289
+ def remove_child(self, parent: Any, child: Any) -> None:
1290
+ child.removeFromSuperview()
1291
+
1292
+ registry.register("Text", IOSTextHandler())
1293
+ registry.register("Button", IOSButtonHandler())
1294
+ registry.register("Column", IOSColumnHandler())
1295
+ registry.register("Row", IOSRowHandler())
1296
+ registry.register("ScrollView", IOSScrollViewHandler())
1297
+ registry.register("TextInput", IOSTextInputHandler())
1298
+ registry.register("Image", IOSImageHandler())
1299
+ registry.register("Switch", IOSSwitchHandler())
1300
+ registry.register("ProgressBar", IOSProgressBarHandler())
1301
+ registry.register("ActivityIndicator", IOSActivityIndicatorHandler())
1302
+ registry.register("WebView", IOSWebViewHandler())
1303
+ registry.register("Spacer", IOSSpacerHandler())
1304
+ registry.register("View", IOSViewHandler())
1305
+ registry.register("SafeAreaView", IOSSafeAreaViewHandler())
1306
+ registry.register("Modal", IOSModalHandler())
1307
+ registry.register("Slider", IOSSliderHandler())
1308
+ registry.register("Pressable", IOSPressableHandler())
1309
+
1310
+
1311
+ # ======================================================================
1312
+ # Factory
1313
+ # ======================================================================
1314
+
1315
+ _registry: Optional[NativeViewRegistry] = None
1316
+
1317
+
1318
+ def get_registry() -> NativeViewRegistry:
1319
+ """Return the singleton registry, lazily creating platform handlers."""
1320
+ global _registry
1321
+ if _registry is not None:
1322
+ return _registry
1323
+ _registry = NativeViewRegistry()
1324
+ if IS_ANDROID:
1325
+ _register_android_handlers(_registry)
1326
+ else:
1327
+ _register_ios_handlers(_registry)
1328
+ return _registry
1329
+
1330
+
1331
+ def set_registry(registry: NativeViewRegistry) -> None:
1332
+ """Inject a custom or mock registry (primarily for testing)."""
1333
+ global _registry
1334
+ _registry = registry